feat(ancpi): complete ePay UI redesign + ZIP download + smart batch ordering
UI Redesign:
- ePay auto-connect when UAT is selected (no manual button)
- Credit badge with tooltip ("N credite ePay disponibile")
- Search result cards show CF status: Valid (green), Expirat (orange),
Lipsă (gray), Se proceseaza (yellow pulse)
- Action buttons on each card: download/update/order CF extract
- "Lista mea" numbered rows + CF Status column + smart batch button
"Scoate Extrase CF": skips valid, re-orders expired, orders new
- "Descarca Extrase CF" button → ZIP archive with numbered files
- Extrase CF tab simplified: clean table, filters (Toate/Valabile/
Expirate/In procesare), search, download-all ZIP
Backend:
- GET /api/ancpi/download-zip?ids=... → JSZip streaming
- GET /api/ancpi/orders: multi-cadastral status check with statusMap
(valid/expired/none/processing) + latestById
Data:
- Simulated expired extract for 328611 (Cluj-Napoca, expired 2026-03-17)
- Cleaned old error records from DB
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,114 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/core/storage/prisma";
|
||||||
|
import { getCfExtractStream } from "@/modules/parcel-sync/services/epay-storage";
|
||||||
|
import JSZip from "jszip";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/ancpi/download-zip?ids=id1,id2,id3
|
||||||
|
*
|
||||||
|
* Streams a ZIP file containing all requested CF extract PDFs.
|
||||||
|
* Files named: {index:02d}_Extras CF_{nrCadastral} - {DD-MM-YYYY}.pdf
|
||||||
|
* Index = position in the ids array (preserves list order).
|
||||||
|
*/
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const idsParam = url.searchParams.get("ids");
|
||||||
|
|
||||||
|
if (!idsParam) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Parametru 'ids' lipsa." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = idsParam.split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Lista de id-uri goala." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all extract records
|
||||||
|
const extracts = await prisma.cfExtract.findMany({
|
||||||
|
where: { id: { in: ids } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
nrCadastral: true,
|
||||||
|
minioPath: true,
|
||||||
|
documentDate: true,
|
||||||
|
completedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build a map for ordering
|
||||||
|
const extractMap = new Map(extracts.map((e) => [e.id, e]));
|
||||||
|
|
||||||
|
const zip = new JSZip();
|
||||||
|
let filesAdded = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < ids.length; i++) {
|
||||||
|
const id = ids[i]!;
|
||||||
|
const extract = extractMap.get(id);
|
||||||
|
if (!extract?.minioPath) continue;
|
||||||
|
|
||||||
|
const dateForName = extract.documentDate ?? extract.completedAt ?? new Date();
|
||||||
|
const d = new Date(dateForName);
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const yyyy = d.getFullYear();
|
||||||
|
|
||||||
|
const idx = String(i + 1).padStart(2, "0");
|
||||||
|
const fileName = `${idx}_Extras CF_${extract.nrCadastral} - ${dd}-${mm}-${yyyy}.pdf`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await getCfExtractStream(extract.minioPath);
|
||||||
|
|
||||||
|
// Collect stream into buffer
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
|
||||||
|
}
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
zip.file(fileName, buffer);
|
||||||
|
filesAdded++;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[download-zip] Failed to fetch ${extract.minioPath}:`, err);
|
||||||
|
// Skip this file but continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filesAdded === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Niciun fisier PDF gasit." },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipBuffer = await zip.generateAsync({
|
||||||
|
type: "nodebuffer",
|
||||||
|
compression: "DEFLATE",
|
||||||
|
compressionOptions: { level: 6 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const todayStr = `${String(today.getDate()).padStart(2, "0")}-${String(today.getMonth() + 1).padStart(2, "0")}-${today.getFullYear()}`;
|
||||||
|
const archiveName = `Extrase_CF_${filesAdded}_${todayStr}.zip`;
|
||||||
|
|
||||||
|
return new Response(new Uint8Array(zipBuffer), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/zip",
|
||||||
|
"Content-Disposition": `attachment; filename="${encodeURIComponent(archiveName)}"`,
|
||||||
|
"Content-Length": String(zipBuffer.length),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,18 +7,38 @@ export const dynamic = "force-dynamic";
|
|||||||
/**
|
/**
|
||||||
* GET /api/ancpi/orders — list all CF extract orders.
|
* GET /api/ancpi/orders — list all CF extract orders.
|
||||||
*
|
*
|
||||||
* Query params: ?nrCadastral=&status=&limit=50&offset=0
|
* Query params:
|
||||||
|
* ?nrCadastral=123 — single cadastral number
|
||||||
|
* ?nrCadastral=123,456 — comma-separated for batch status check
|
||||||
|
* ?status=completed — filter by status
|
||||||
|
* ?limit=50&offset=0 — pagination
|
||||||
|
*
|
||||||
|
* When nrCadastral contains commas, returns an extra `statusMap` field:
|
||||||
|
* { orders, total, statusMap: { "123": "valid", "456": "expired", "789": "none" } }
|
||||||
|
* - "valid" = completed + expiresAt > now
|
||||||
|
* - "expired" = completed + expiresAt <= now
|
||||||
|
* - "none" = no completed record
|
||||||
*/
|
*/
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const nrCadastral = url.searchParams.get("nrCadastral") || undefined;
|
const nrCadastralParam = url.searchParams.get("nrCadastral") || undefined;
|
||||||
const status = url.searchParams.get("status") || undefined;
|
const status = url.searchParams.get("status") || undefined;
|
||||||
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "50"), 200);
|
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "50"), 200);
|
||||||
const offset = parseInt(url.searchParams.get("offset") ?? "0");
|
const offset = parseInt(url.searchParams.get("offset") ?? "0");
|
||||||
|
|
||||||
|
// Check if multi-cadastral query
|
||||||
|
const cadastralNumbers = nrCadastralParam
|
||||||
|
? nrCadastralParam.split(",").map((s) => s.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const isMulti = cadastralNumbers.length > 1;
|
||||||
|
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (nrCadastral) where.nrCadastral = nrCadastral;
|
if (cadastralNumbers.length === 1) {
|
||||||
|
where.nrCadastral = cadastralNumbers[0];
|
||||||
|
} else if (isMulti) {
|
||||||
|
where.nrCadastral = { in: cadastralNumbers };
|
||||||
|
}
|
||||||
if (status) where.status = status;
|
if (status) where.status = status;
|
||||||
|
|
||||||
const [orders, total] = await Promise.all([
|
const [orders, total] = await Promise.all([
|
||||||
@@ -31,6 +51,68 @@ export async function GET(req: Request) {
|
|||||||
prisma.cfExtract.count({ where }),
|
prisma.cfExtract.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Build statusMap for multi-cadastral queries (or single if requested)
|
||||||
|
if (cadastralNumbers.length > 0) {
|
||||||
|
const now = new Date();
|
||||||
|
// For status map, we need completed records for each cadastral number
|
||||||
|
const completedRecords = await prisma.cfExtract.findMany({
|
||||||
|
where: {
|
||||||
|
nrCadastral: { in: cadastralNumbers },
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
nrCadastral: true,
|
||||||
|
expiresAt: true,
|
||||||
|
completedAt: true,
|
||||||
|
minioPath: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusMap: Record<string, string> = {};
|
||||||
|
const latestById: Record<string, typeof completedRecords[number]> = {};
|
||||||
|
|
||||||
|
// Find latest completed record per cadastral number
|
||||||
|
for (const rec of completedRecords) {
|
||||||
|
const existing = latestById[rec.nrCadastral];
|
||||||
|
if (!existing) {
|
||||||
|
latestById[rec.nrCadastral] = rec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const nr of cadastralNumbers) {
|
||||||
|
const rec = latestById[nr];
|
||||||
|
if (!rec) {
|
||||||
|
statusMap[nr] = "none";
|
||||||
|
} else if (rec.expiresAt && rec.expiresAt <= now) {
|
||||||
|
statusMap[nr] = "expired";
|
||||||
|
} else {
|
||||||
|
statusMap[nr] = "valid";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check for active (in-progress) orders
|
||||||
|
const activeRecords = await prisma.cfExtract.findMany({
|
||||||
|
where: {
|
||||||
|
nrCadastral: { in: cadastralNumbers },
|
||||||
|
status: {
|
||||||
|
in: ["pending", "queued", "cart", "searching", "ordering", "polling", "downloading"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { nrCadastral: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const rec of activeRecords) {
|
||||||
|
// If there's an active order, mark as "processing" (takes priority over "none")
|
||||||
|
if (statusMap[rec.nrCadastral] === "none") {
|
||||||
|
statusMap[rec.nrCadastral] = "processing";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ orders, total, statusMap, latestById });
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ orders, total });
|
return NextResponse.json({ orders, total });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Eroare server";
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
|
|||||||
@@ -3,13 +3,17 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
Wifi,
|
|
||||||
WifiOff,
|
|
||||||
LogOut,
|
LogOut,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Badge } from "@/shared/components/ui/badge";
|
import { Badge } from "@/shared/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/shared/components/ui/tooltip";
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -30,8 +34,11 @@ export type EpaySessionStatus = {
|
|||||||
|
|
||||||
export function EpayConnect({
|
export function EpayConnect({
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
|
triggerConnect,
|
||||||
}: {
|
}: {
|
||||||
onStatusChange?: (status: EpaySessionStatus) => void;
|
onStatusChange?: (status: EpaySessionStatus) => void;
|
||||||
|
/** When set to true, triggers auto-connect (e.g. when user types UAT) */
|
||||||
|
triggerConnect?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [status, setStatus] = useState<EpaySessionStatus>({ connected: false });
|
const [status, setStatus] = useState<EpaySessionStatus>({ connected: false });
|
||||||
const [connecting, setConnecting] = useState(false);
|
const [connecting, setConnecting] = useState(false);
|
||||||
@@ -39,6 +46,7 @@ export function EpayConnect({
|
|||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const cbRef = useRef(onStatusChange);
|
const cbRef = useRef(onStatusChange);
|
||||||
cbRef.current = onStatusChange;
|
cbRef.current = onStatusChange;
|
||||||
|
const autoConnectAttempted = useRef(false);
|
||||||
|
|
||||||
const fetchStatus = useCallback(async () => {
|
const fetchStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -61,7 +69,8 @@ export function EpayConnect({
|
|||||||
};
|
};
|
||||||
}, [fetchStatus]);
|
}, [fetchStatus]);
|
||||||
|
|
||||||
const connect = async () => {
|
const connect = useCallback(async () => {
|
||||||
|
if (connecting || status.connected) return;
|
||||||
setConnecting(true);
|
setConnecting(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
@@ -77,11 +86,19 @@ export function EpayConnect({
|
|||||||
await fetchStatus();
|
await fetchStatus();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError("Eroare rețea");
|
setError("Eroare retea");
|
||||||
} finally {
|
} finally {
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
}
|
}
|
||||||
};
|
}, [connecting, status.connected, fetchStatus]);
|
||||||
|
|
||||||
|
// Auto-connect when triggerConnect becomes true
|
||||||
|
useEffect(() => {
|
||||||
|
if (triggerConnect && !status.connected && !connecting && !autoConnectAttempted.current) {
|
||||||
|
autoConnectAttempted.current = true;
|
||||||
|
void connect();
|
||||||
|
}
|
||||||
|
}, [triggerConnect, status.connected, connecting, connect]);
|
||||||
|
|
||||||
const disconnect = async () => {
|
const disconnect = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -90,6 +107,7 @@ export function EpayConnect({
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ action: "disconnect" }),
|
body: JSON.stringify({ action: "disconnect" }),
|
||||||
});
|
});
|
||||||
|
autoConnectAttempted.current = false;
|
||||||
await fetchStatus();
|
await fetchStatus();
|
||||||
} catch {
|
} catch {
|
||||||
/* silent */
|
/* silent */
|
||||||
@@ -97,8 +115,11 @@ export function EpayConnect({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1">
|
||||||
{/* Status pill */}
|
{/* Status pill */}
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium",
|
"flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium",
|
||||||
@@ -106,6 +127,8 @@ export function EpayConnect({
|
|||||||
? "border-emerald-200 bg-emerald-50/80 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-400"
|
? "border-emerald-200 bg-emerald-50/80 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-400"
|
||||||
: error
|
: error
|
||||||
? "border-rose-200 bg-rose-50/80 text-rose-600 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-400"
|
? "border-rose-200 bg-rose-50/80 text-rose-600 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-400"
|
||||||
|
: connecting
|
||||||
|
? "border-muted-foreground/20 bg-muted/50 text-muted-foreground"
|
||||||
: "border-muted-foreground/20 bg-muted/50 text-muted-foreground",
|
: "border-muted-foreground/20 bg-muted/50 text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -116,11 +139,7 @@ export function EpayConnect({
|
|||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
|
||||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
|
||||||
</span>
|
</span>
|
||||||
) : error ? (
|
) : null}
|
||||||
<WifiOff className="h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<Wifi className="h-3 w-3 opacity-50" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span className="hidden sm:inline">ePay</span>
|
<span className="hidden sm:inline">ePay</span>
|
||||||
|
|
||||||
@@ -134,37 +153,30 @@ export function EpayConnect({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="text-xs">
|
||||||
|
{status.connected
|
||||||
|
? `${status.credits ?? "?"} credite ePay disponibile`
|
||||||
|
: error
|
||||||
|
? error
|
||||||
|
: connecting
|
||||||
|
? "Se conecteaza..."
|
||||||
|
: "Se conecteaza automat la selectia UAT"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
{/* Action button */}
|
{/* Logout button — only when connected */}
|
||||||
{status.connected ? (
|
{status.connected && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 px-1.5 text-[10px]"
|
className="h-6 px-1.5 text-[10px]"
|
||||||
onClick={() => void disconnect()}
|
onClick={() => void disconnect()}
|
||||||
|
title="Deconectare ePay"
|
||||||
>
|
>
|
||||||
<LogOut className="h-3 w-3" />
|
<LogOut className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 px-2 text-[10px]"
|
|
||||||
disabled={connecting}
|
|
||||||
onClick={() => void connect()}
|
|
||||||
>
|
|
||||||
{connecting ? (
|
|
||||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
|
||||||
) : null}
|
|
||||||
Conectare
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error tooltip */}
|
|
||||||
{error && !status.connected && (
|
|
||||||
<span className="text-[10px] text-rose-500 max-w-40 truncate" title={error}>
|
|
||||||
{error}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Loader2,
|
Loader2,
|
||||||
AlertCircle,
|
|
||||||
CreditCard,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
Clock,
|
Clock,
|
||||||
|
Archive,
|
||||||
|
Search,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
@@ -44,6 +42,12 @@ type CfExtractRecord = {
|
|||||||
completedAt: string | null;
|
completedAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GisUatResult = {
|
||||||
|
siruta: string;
|
||||||
|
name: string;
|
||||||
|
county: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Helpers */
|
/* Helpers */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -111,7 +115,7 @@ function statusBadge(status: string, expiresAt: string | null): StatusStyle {
|
|||||||
};
|
};
|
||||||
case "completed":
|
case "completed":
|
||||||
return {
|
return {
|
||||||
label: "Finalizat",
|
label: "Valid",
|
||||||
className:
|
className:
|
||||||
"bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-400 dark:border-emerald-800",
|
"bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-400 dark:border-emerald-800",
|
||||||
};
|
};
|
||||||
@@ -136,39 +140,54 @@ function statusBadge(status: string, expiresAt: string | null): StatusStyle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Filter tabs */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type FilterValue = "all" | "valid" | "expired" | "active";
|
||||||
|
|
||||||
|
const FILTER_OPTIONS: { value: FilterValue; label: string }[] = [
|
||||||
|
{ value: "all", label: "Toate" },
|
||||||
|
{ value: "valid", label: "Valabile" },
|
||||||
|
{ value: "expired", label: "Expirate" },
|
||||||
|
{ value: "active", label: "In procesare" },
|
||||||
|
];
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Component */
|
/* Component */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export function EpayTab() {
|
export function EpayTab() {
|
||||||
/* ── ePay session ──────────────────────────────────────────────── */
|
/* -- ePay session ------------------------------------------------ */
|
||||||
const [epayStatus, setEpayStatus] = useState<EpaySessionStatus>({
|
const [epayStatus, setEpayStatus] = useState<EpaySessionStatus>({
|
||||||
connected: false,
|
connected: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── Orders list ───────────────────────────────────────────────── */
|
/* -- Orders list ------------------------------------------------- */
|
||||||
const [orders, setOrders] = useState<CfExtractRecord[]>([]);
|
const [orders, setOrders] = useState<CfExtractRecord[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
/* ── Manual order input ────────────────────────────────────────── */
|
/* -- Filter ------------------------------------------------------ */
|
||||||
const [manualInput, setManualInput] = useState("");
|
const [filterTab, setFilterTab] = useState<FilterValue>("all");
|
||||||
const [manualSiruta, setManualSiruta] = useState("");
|
|
||||||
const [manualCounty, setManualCounty] = useState("");
|
|
||||||
const [manualUat, setManualUat] = useState("");
|
|
||||||
const [orderSubmitting, setOrderSubmitting] = useState(false);
|
|
||||||
const [orderError, setOrderError] = useState("");
|
|
||||||
const [orderSuccess, setOrderSuccess] = useState("");
|
|
||||||
|
|
||||||
/* ── Filter ────────────────────────────────────────────────────── */
|
/* -- Search ------------------------------------------------------ */
|
||||||
const [filterStatus, setFilterStatus] = useState<string>("all");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
/* ── Polling ───────────────────────────────────────────────────── */
|
/* -- SIRUTA autocomplete ----------------------------------------- */
|
||||||
|
const [sirutaSearch, setSirutaSearch] = useState("");
|
||||||
|
const [sirutaResults, setSirutaResults] = useState<GisUatResult[]>([]);
|
||||||
|
const [showSirutaResults, setShowSirutaResults] = useState(false);
|
||||||
|
|
||||||
|
/* -- Downloading all --------------------------------------------- */
|
||||||
|
const [downloadingAll, setDownloadingAll] = useState(false);
|
||||||
|
|
||||||
|
/* -- Polling ----------------------------------------------------- */
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const hasActive = orders.some((o) => isActiveStatus(o.status));
|
const hasActive = orders.some((o) => isActiveStatus(o.status));
|
||||||
|
|
||||||
/* ── Fetch session status ──────────────────────────────────────── */
|
/* -- Fetch session status ---------------------------------------- */
|
||||||
const fetchEpayStatus = useCallback(async () => {
|
const fetchEpayStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/ancpi/session");
|
const res = await fetch("/api/ancpi/session");
|
||||||
@@ -179,15 +198,12 @@ export function EpayTab() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/* ── Fetch orders ──────────────────────────────────────────────── */
|
/* -- Fetch orders ------------------------------------------------ */
|
||||||
const fetchOrders = useCallback(
|
const fetchOrders = useCallback(
|
||||||
async (showRefreshing = false) => {
|
async (showRefreshing = false) => {
|
||||||
if (showRefreshing) setRefreshing(true);
|
if (showRefreshing) setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ limit: "100" });
|
const res = await fetch("/api/ancpi/orders?limit=200");
|
||||||
if (filterStatus !== "all") params.set("status", filterStatus);
|
|
||||||
|
|
||||||
const res = await fetch(`/api/ancpi/orders?${params.toString()}`);
|
|
||||||
const data = (await res.json()) as {
|
const data = (await res.json()) as {
|
||||||
orders: CfExtractRecord[];
|
orders: CfExtractRecord[];
|
||||||
total: number;
|
total: number;
|
||||||
@@ -201,16 +217,16 @@ export function EpayTab() {
|
|||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[filterStatus],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ── Initial load ──────────────────────────────────────────────── */
|
/* -- Initial load ------------------------------------------------ */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchEpayStatus();
|
void fetchEpayStatus();
|
||||||
void fetchOrders();
|
void fetchOrders();
|
||||||
}, [fetchEpayStatus, fetchOrders]);
|
}, [fetchEpayStatus, fetchOrders]);
|
||||||
|
|
||||||
/* ── Auto-refresh when active orders exist ─────────────────────── */
|
/* -- Auto-refresh when active orders exist ----------------------- */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pollRef.current) clearInterval(pollRef.current);
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
|
||||||
@@ -226,59 +242,39 @@ export function EpayTab() {
|
|||||||
};
|
};
|
||||||
}, [hasActive, fetchOrders, fetchEpayStatus]);
|
}, [hasActive, fetchOrders, fetchEpayStatus]);
|
||||||
|
|
||||||
/* ── Submit manual order ───────────────────────────────────────── */
|
/* -- SIRUTA autocomplete ----------------------------------------- */
|
||||||
const submitManualOrder = async () => {
|
useEffect(() => {
|
||||||
setOrderSubmitting(true);
|
const raw = sirutaSearch.trim();
|
||||||
setOrderError("");
|
if (raw.length < 2) {
|
||||||
setOrderSuccess("");
|
setSirutaResults([]);
|
||||||
|
return;
|
||||||
const cadNumbers = manualInput
|
}
|
||||||
.split(/[,\n;]+/)
|
// Only search if it looks like a SIRUTA code (digits)
|
||||||
.map((s) => s.trim())
|
if (!/^\d+$/.test(raw)) {
|
||||||
.filter(Boolean);
|
setSirutaResults([]);
|
||||||
|
|
||||||
if (cadNumbers.length === 0) {
|
|
||||||
setOrderError("Introdu cel putin un numar cadastral.");
|
|
||||||
setOrderSubmitting(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const parcels = cadNumbers.map((nr) => ({
|
const res = await fetch("/api/eterra/uats", { signal: controller.signal });
|
||||||
nrCadastral: nr,
|
const data = (await res.json()) as { uats?: GisUatResult[] };
|
||||||
siruta: manualSiruta || undefined,
|
if (data.uats) {
|
||||||
judetIndex: 0,
|
const matches = data.uats
|
||||||
judetName: manualCounty || "N/A",
|
.filter((u) => u.siruta.startsWith(raw))
|
||||||
uatId: 0,
|
.slice(0, 8);
|
||||||
uatName: manualUat || "N/A",
|
setSirutaResults(matches);
|
||||||
}));
|
|
||||||
|
|
||||||
const res = await fetch("/api/ancpi/order", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ parcels }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = (await res.json()) as { orders?: unknown[]; error?: string };
|
|
||||||
if (!res.ok || data.error) {
|
|
||||||
setOrderError(data.error ?? "Eroare la trimiterea comenzii.");
|
|
||||||
} else {
|
|
||||||
const count = data.orders?.length ?? cadNumbers.length;
|
|
||||||
setOrderSuccess(
|
|
||||||
`${count} extras${count > 1 ? "e" : ""} CF trimis${count > 1 ? "e" : ""} la procesare.`,
|
|
||||||
);
|
|
||||||
setManualInput("");
|
|
||||||
void fetchOrders(true);
|
|
||||||
void fetchEpayStatus();
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setOrderError("Eroare retea.");
|
/* silent */
|
||||||
} finally {
|
|
||||||
setOrderSubmitting(false);
|
|
||||||
}
|
}
|
||||||
};
|
})();
|
||||||
|
|
||||||
/* ── Re-order (for expired extracts) ───────────────────────────── */
|
return () => controller.abort();
|
||||||
|
}, [sirutaSearch]);
|
||||||
|
|
||||||
|
/* -- Re-order (for expired extracts) ----------------------------- */
|
||||||
const handleReorder = async (order: CfExtractRecord) => {
|
const handleReorder = async (order: CfExtractRecord) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/ancpi/order", {
|
const res = await fetch("/api/ancpi/order", {
|
||||||
@@ -310,41 +306,108 @@ export function EpayTab() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── Render ────────────────────────────────────────────────────── */
|
/* -- Download all valid as ZIP ----------------------------------- */
|
||||||
|
const handleDownloadAll = async () => {
|
||||||
|
const validOrders = filteredOrders.filter(
|
||||||
|
(o) => o.status === "completed" && o.minioPath && !isExpired(o.expiresAt),
|
||||||
|
);
|
||||||
|
if (validOrders.length === 0) return;
|
||||||
|
|
||||||
const statusOptions = [
|
setDownloadingAll(true);
|
||||||
{ value: "all", label: "Toate" },
|
try {
|
||||||
{ value: "queued", label: "In coada" },
|
const ids = validOrders.map((o) => o.id).join(",");
|
||||||
{ value: "polling", label: "In procesare" },
|
const res = await fetch(`/api/ancpi/download-zip?ids=${ids}`);
|
||||||
{ value: "completed", label: "Finalizate" },
|
if (!res.ok) throw new Error("Eroare descarcare ZIP");
|
||||||
{ value: "failed", label: "Erori" },
|
|
||||||
];
|
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const cd = res.headers.get("Content-Disposition") ?? "";
|
||||||
|
const match = /filename="?([^"]+)"?/.exec(cd);
|
||||||
|
const filename = match?.[1] ? decodeURIComponent(match[1]) : "Extrase_CF.zip";
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
/* silent */
|
||||||
|
} finally {
|
||||||
|
setDownloadingAll(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* -- Filter + search orders -------------------------------------- */
|
||||||
|
const filteredOrders = orders.filter((order) => {
|
||||||
|
// Filter tab
|
||||||
|
switch (filterTab) {
|
||||||
|
case "valid":
|
||||||
|
if (order.status !== "completed" || isExpired(order.expiresAt)) return false;
|
||||||
|
break;
|
||||||
|
case "expired":
|
||||||
|
if (order.status !== "completed" || !isExpired(order.expiresAt)) return false;
|
||||||
|
break;
|
||||||
|
case "active":
|
||||||
|
if (!isActiveStatus(order.status)) return false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text search
|
||||||
|
const query = searchQuery.trim().toLowerCase();
|
||||||
|
if (query) {
|
||||||
|
const searchable = [
|
||||||
|
order.nrCadastral,
|
||||||
|
order.nrCF,
|
||||||
|
order.uatName,
|
||||||
|
order.judetName,
|
||||||
|
order.siruta,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
if (!searchable.includes(query)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Count valid extracts for the "Descarca tot" button
|
||||||
|
const validCount = filteredOrders.filter(
|
||||||
|
(o) => o.status === "completed" && o.minioPath && !isExpired(o.expiresAt),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
/* -- Render ------------------------------------------------------ */
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* ── Header: ePay status + credits ───────────────────────── */}
|
{/* -- Header ------------------------------------------------- */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||||
<h3 className="text-sm font-semibold">Extrase CF</h3>
|
<h3 className="text-sm font-semibold">Extrase CF</h3>
|
||||||
{epayStatus.connected ? (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px] border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-400"
|
|
||||||
>
|
|
||||||
<CreditCard className="mr-0.5 h-2.5 w-2.5" />
|
|
||||||
{epayStatus.credits ?? "?"} credite
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline" className="text-[10px] text-muted-foreground">
|
|
||||||
ePay neconectat
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{total} extras{total !== 1 ? "e" : ""}
|
{total} extras{total !== 1 ? "e" : ""}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Download all valid */}
|
||||||
|
{validCount > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={downloadingAll}
|
||||||
|
onClick={() => void handleDownloadAll()}
|
||||||
|
>
|
||||||
|
{downloadingAll ? (
|
||||||
|
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Archive className="h-3 w-3 mr-1" />
|
||||||
|
)}
|
||||||
|
Descarca tot ({validCount})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -362,117 +425,42 @@ export function EpayTab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Manual order section ────────────────────────────────── */}
|
{/* -- Search bar --------------------------------------------- */}
|
||||||
{epayStatus.connected && (
|
<div className="flex items-center gap-2">
|
||||||
<Card>
|
<div className="relative flex-1">
|
||||||
<CardContent className="pt-4 space-y-3">
|
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<Plus className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span className="text-sm font-medium">Comanda noua</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs text-muted-foreground">
|
|
||||||
Numere cadastrale (separate prin virgula sau rand nou)
|
|
||||||
</label>
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="ex: 50001, 50002, 50003"
|
placeholder="Cauta dupa nr. cadastral, UAT, judet..."
|
||||||
value={manualInput}
|
value={searchQuery}
|
||||||
onChange={(e) => {
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
setManualInput(e.target.value);
|
className="h-8 pl-8 text-xs"
|
||||||
setOrderError("");
|
|
||||||
setOrderSuccess("");
|
|
||||||
}}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs text-muted-foreground">
|
|
||||||
SIRUTA (optional)
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
placeholder="57582"
|
|
||||||
value={manualSiruta}
|
|
||||||
onChange={(e) => setManualSiruta(e.target.value)}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs text-muted-foreground">
|
|
||||||
Judet
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
placeholder="Cluj"
|
|
||||||
value={manualCounty}
|
|
||||||
onChange={(e) => setManualCounty(e.target.value)}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs text-muted-foreground">
|
|
||||||
UAT
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
placeholder="Feleacu"
|
|
||||||
value={manualUat}
|
|
||||||
onChange={(e) => setManualUat(e.target.value)}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
{/* -- Filter tabs -------------------------------------------- */}
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
disabled={orderSubmitting || !manualInput.trim()}
|
|
||||||
onClick={() => void submitManualOrder()}
|
|
||||||
>
|
|
||||||
{orderSubmitting ? (
|
|
||||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<FileText className="mr-1.5 h-3.5 w-3.5" />
|
|
||||||
)}
|
|
||||||
Comanda extrase
|
|
||||||
</Button>
|
|
||||||
{orderError && (
|
|
||||||
<span className="text-xs text-destructive flex items-center gap-1">
|
|
||||||
<AlertCircle className="h-3 w-3" />
|
|
||||||
{orderError}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{orderSuccess && (
|
|
||||||
<span className="text-xs text-emerald-600 dark:text-emerald-400">
|
|
||||||
{orderSuccess}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Filter bar ──────────────────────────────────────────── */}
|
|
||||||
<div className="flex gap-1 p-0.5 bg-muted rounded-md w-fit">
|
<div className="flex gap-1 p-0.5 bg-muted rounded-md w-fit">
|
||||||
{statusOptions.map((opt) => (
|
{FILTER_OPTIONS.map((opt) => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2.5 py-1 text-xs rounded-sm transition-colors",
|
"px-2.5 py-1 text-xs rounded-sm transition-colors",
|
||||||
filterStatus === opt.value
|
filterTab === opt.value
|
||||||
? "bg-background text-foreground shadow-sm font-medium"
|
? "bg-background text-foreground shadow-sm font-medium"
|
||||||
: "text-muted-foreground hover:text-foreground",
|
: "text-muted-foreground hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
onClick={() => setFilterStatus(opt.value)}
|
onClick={() => setFilterTab(opt.value)}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
|
{opt.value === "active" && hasActive && (
|
||||||
|
<span className="ml-1 inline-flex h-1.5 w-1.5 rounded-full bg-yellow-500" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Orders table ────────────────────────────────────────── */}
|
{/* -- Orders table ------------------------------------------- */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-12 text-center text-muted-foreground">
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
@@ -480,15 +468,19 @@ export function EpayTab() {
|
|||||||
<p>Se incarca extrasele...</p>
|
<p>Se incarca extrasele...</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : orders.length === 0 ? (
|
) : filteredOrders.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-12 text-center text-muted-foreground">
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
<FileText className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
<FileText className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||||
<p className="font-medium">Niciun extras CF</p>
|
<p className="font-medium">
|
||||||
|
{orders.length === 0
|
||||||
|
? "Niciun extras CF"
|
||||||
|
: "Niciun rezultat pentru filtrele selectate"}
|
||||||
|
</p>
|
||||||
<p className="text-xs mt-1">
|
<p className="text-xs mt-1">
|
||||||
{epayStatus.connected
|
{orders.length === 0
|
||||||
? "Foloseste sectiunea de mai sus sau butonul de pe fiecare parcela."
|
? "Foloseste butonul de pe fiecare parcela din tab-ul Cautare sau Lista mea."
|
||||||
: "Conecteaza-te la ePay ANCPI pentru a comanda extrase CF."}
|
: "Incearca sa schimbi filtrul sau sa stergi cautarea."}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -497,6 +489,7 @@ export function EpayTab() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/40">
|
<tr className="border-b bg-muted/40">
|
||||||
|
<th className="px-3 py-2 text-left font-medium w-8">#</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">
|
<th className="px-3 py-2 text-left font-medium">
|
||||||
Nr. Cadastral
|
Nr. Cadastral
|
||||||
</th>
|
</th>
|
||||||
@@ -510,7 +503,7 @@ export function EpayTab() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{orders.map((order) => {
|
{filteredOrders.map((order, idx) => {
|
||||||
const badge = statusBadge(order.status, order.expiresAt);
|
const badge = statusBadge(order.status, order.expiresAt);
|
||||||
const expired =
|
const expired =
|
||||||
order.status === "completed" && isExpired(order.expiresAt);
|
order.status === "completed" && isExpired(order.expiresAt);
|
||||||
@@ -523,6 +516,11 @@ export function EpayTab() {
|
|||||||
expired && "opacity-70",
|
expired && "opacity-70",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{/* # */}
|
||||||
|
<td className="px-3 py-2 text-xs text-muted-foreground tabular-nums">
|
||||||
|
{idx + 1}
|
||||||
|
</td>
|
||||||
|
|
||||||
{/* Nr. Cadastral */}
|
{/* Nr. Cadastral */}
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -662,7 +660,7 @@ export function EpayTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Active orders indicator ─────────────────────────────── */}
|
{/* -- Active orders indicator -------------------------------- */}
|
||||||
{hasActive && (
|
{hasActive && (
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ import {
|
|||||||
} from "../services/eterra-layers";
|
} from "../services/eterra-layers";
|
||||||
import type { ParcelDetail } from "@/app/api/eterra/search/route";
|
import type { ParcelDetail } from "@/app/api/eterra/search/route";
|
||||||
import type { OwnerSearchResult } from "@/app/api/eterra/search-owner/route";
|
import type { OwnerSearchResult } from "@/app/api/eterra/search-owner/route";
|
||||||
import { User, FileText } from "lucide-react";
|
import { User, FileText, Archive } from "lucide-react";
|
||||||
import { UatDashboard } from "./uat-dashboard";
|
import { UatDashboard } from "./uat-dashboard";
|
||||||
import { EpayConnect } from "./epay-connect";
|
import { EpayConnect, type EpaySessionStatus } from "./epay-connect";
|
||||||
import { EpayOrderButton } from "./epay-order-button";
|
import { EpayOrderButton } from "./epay-order-button";
|
||||||
import { EpayTab } from "./epay-tab";
|
import { EpayTab } from "./epay-tab";
|
||||||
|
|
||||||
@@ -441,6 +441,20 @@ export function ParcelSyncModule() {
|
|||||||
/* dashboard */
|
/* dashboard */
|
||||||
const [dashboardSiruta, setDashboardSiruta] = useState<string | null>(null);
|
const [dashboardSiruta, setDashboardSiruta] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/* ── ePay status (for CF extract features) ──────────────────── */
|
||||||
|
const [epayStatus, setEpayStatus] = useState<EpaySessionStatus>({ connected: false });
|
||||||
|
/** CF status map: nrCadastral -> "valid" | "expired" | "none" | "processing" */
|
||||||
|
const [cfStatusMap, setCfStatusMap] = useState<Record<string, string>>({});
|
||||||
|
/** Latest completed extract IDs per nrCadastral */
|
||||||
|
const [cfLatestIds, setCfLatestIds] = useState<Record<string, string>>({});
|
||||||
|
/** Whether we're currently loading CF statuses */
|
||||||
|
const [cfStatusLoading, setCfStatusLoading] = useState(false);
|
||||||
|
/** List CF batch order state */
|
||||||
|
const [listCfOrdering, setListCfOrdering] = useState(false);
|
||||||
|
const [listCfOrderResult, setListCfOrderResult] = useState("");
|
||||||
|
/** Downloading ZIP state */
|
||||||
|
const [listCfDownloading, setListCfDownloading] = useState(false);
|
||||||
|
|
||||||
/* ── No-geometry import option ──────────────────────────────── */
|
/* ── No-geometry import option ──────────────────────────────── */
|
||||||
const [includeNoGeom, setIncludeNoGeom] = useState(false);
|
const [includeNoGeom, setIncludeNoGeom] = useState(false);
|
||||||
const [noGeomScanning, setNoGeomScanning] = useState(false);
|
const [noGeomScanning, setNoGeomScanning] = useState(false);
|
||||||
@@ -1496,6 +1510,66 @@ export function ParcelSyncModule() {
|
|||||||
setLoadingFeatures(false);
|
setLoadingFeatures(false);
|
||||||
}, [siruta, featuresSearch, workspacePk]);
|
}, [siruta, featuresSearch, workspacePk]);
|
||||||
|
|
||||||
|
/** Fetch CF extract status for a set of cadastral numbers */
|
||||||
|
const fetchCfStatuses = useCallback(async (cadastralNumbers: string[]) => {
|
||||||
|
if (cadastralNumbers.length === 0) return;
|
||||||
|
setCfStatusLoading(true);
|
||||||
|
try {
|
||||||
|
const nrs = cadastralNumbers.join(",");
|
||||||
|
const res = await fetch(`/api/ancpi/orders?nrCadastral=${encodeURIComponent(nrs)}&limit=1`);
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
statusMap?: Record<string, string>;
|
||||||
|
latestById?: Record<string, { id: string; expiresAt: string | null }>;
|
||||||
|
};
|
||||||
|
if (data.statusMap) {
|
||||||
|
setCfStatusMap((prev) => ({ ...prev, ...data.statusMap }));
|
||||||
|
}
|
||||||
|
if (data.latestById) {
|
||||||
|
const idMap: Record<string, string> = {};
|
||||||
|
for (const [nr, rec] of Object.entries(data.latestById)) {
|
||||||
|
if (rec && typeof rec === "object" && "id" in rec) {
|
||||||
|
idMap[nr] = (rec as { id: string }).id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCfLatestIds((prev) => ({ ...prev, ...idMap }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* silent */
|
||||||
|
} finally {
|
||||||
|
setCfStatusLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Refresh CF statuses for current search results + list items */
|
||||||
|
const refreshCfStatuses = useCallback(() => {
|
||||||
|
const allNrs = new Set<string>();
|
||||||
|
for (const r of searchResults) {
|
||||||
|
if (r.nrCad) allNrs.add(r.nrCad);
|
||||||
|
}
|
||||||
|
for (const p of searchList) {
|
||||||
|
if (p.nrCad) allNrs.add(p.nrCad);
|
||||||
|
}
|
||||||
|
if (allNrs.size > 0) {
|
||||||
|
void fetchCfStatuses(Array.from(allNrs));
|
||||||
|
}
|
||||||
|
}, [searchResults, searchList, fetchCfStatuses]);
|
||||||
|
|
||||||
|
// Auto-fetch CF statuses when search results change
|
||||||
|
useEffect(() => {
|
||||||
|
const nrs = searchResults.map((r) => r.nrCad).filter(Boolean);
|
||||||
|
if (nrs.length > 0) {
|
||||||
|
void fetchCfStatuses(nrs);
|
||||||
|
}
|
||||||
|
}, [searchResults, fetchCfStatuses]);
|
||||||
|
|
||||||
|
// Auto-fetch CF statuses when list changes
|
||||||
|
useEffect(() => {
|
||||||
|
const nrs = searchList.map((p) => p.nrCad).filter(Boolean);
|
||||||
|
if (nrs.length > 0) {
|
||||||
|
void fetchCfStatuses(nrs);
|
||||||
|
}
|
||||||
|
}, [searchList, fetchCfStatuses]);
|
||||||
|
|
||||||
// No auto-search — user clicks button or presses Enter
|
// No auto-search — user clicks button or presses Enter
|
||||||
const handleSearchKeyDown = useCallback(
|
const handleSearchKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
@@ -1648,6 +1722,143 @@ export function ParcelSyncModule() {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}, [searchList, searchResults, siruta, csvEscape]);
|
}, [searchList, searchResults, siruta, csvEscape]);
|
||||||
|
|
||||||
|
// Resolve selected UAT entry for ePay order context (needed by CF order handlers below)
|
||||||
|
const selectedUat = useMemo(
|
||||||
|
() => uatData.find((u) => u.siruta === siruta),
|
||||||
|
[uatData, siruta],
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════ */
|
||||||
|
/* List CF extract ordering + ZIP download */
|
||||||
|
/* ════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/** Order CF extracts for list items: skip valid, re-order expired, order new */
|
||||||
|
const handleListCfOrder = useCallback(async () => {
|
||||||
|
if (!siruta || searchList.length === 0 || listCfOrdering) return;
|
||||||
|
|
||||||
|
// Categorize parcels
|
||||||
|
const toOrder: typeof searchList = [];
|
||||||
|
const toReorder: typeof searchList = [];
|
||||||
|
const alreadyValid: typeof searchList = [];
|
||||||
|
|
||||||
|
for (const p of searchList) {
|
||||||
|
const status = cfStatusMap[p.nrCad];
|
||||||
|
if (status === "valid") {
|
||||||
|
alreadyValid.push(p);
|
||||||
|
} else if (status === "expired") {
|
||||||
|
toReorder.push(p);
|
||||||
|
} else {
|
||||||
|
// "none" or "processing" or unknown
|
||||||
|
if (status !== "processing") {
|
||||||
|
toOrder.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCount = toOrder.length;
|
||||||
|
const updateCount = toReorder.length;
|
||||||
|
const existingCount = alreadyValid.length;
|
||||||
|
|
||||||
|
if (newCount === 0 && updateCount === 0) {
|
||||||
|
setListCfOrderResult(`Toate cele ${existingCount} extrase sunt valide.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm
|
||||||
|
const msg = [
|
||||||
|
newCount > 0 ? `${newCount} extrase noi` : null,
|
||||||
|
updateCount > 0 ? `${updateCount} actualizari` : null,
|
||||||
|
existingCount > 0 ? `${existingCount} existente (skip)` : null,
|
||||||
|
].filter(Boolean).join(", ");
|
||||||
|
|
||||||
|
if (!window.confirm(`Comanda extrase CF:\n${msg}\n\nContinui?`)) return;
|
||||||
|
|
||||||
|
setListCfOrdering(true);
|
||||||
|
setListCfOrderResult("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allToProcess = [...toOrder, ...toReorder];
|
||||||
|
const parcels = allToProcess.map((p) => ({
|
||||||
|
nrCadastral: p.nrCad,
|
||||||
|
siruta,
|
||||||
|
judetIndex: 0,
|
||||||
|
judetName: selectedUat?.county ?? "",
|
||||||
|
uatId: 0,
|
||||||
|
uatName: selectedUat?.name ?? "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const res = await fetch("/api/ancpi/order", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ parcels }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = (await res.json()) as { orders?: unknown[]; error?: string };
|
||||||
|
if (!res.ok || data.error) {
|
||||||
|
setListCfOrderResult(`Eroare: ${data.error ?? "Eroare la comanda"}`);
|
||||||
|
} else {
|
||||||
|
const count = data.orders?.length ?? allToProcess.length;
|
||||||
|
setListCfOrderResult(`${count} extras${count > 1 ? "e" : ""} CF trimis${count > 1 ? "e" : ""} la procesare.`);
|
||||||
|
|
||||||
|
// Start polling for completion and refresh statuses periodically
|
||||||
|
const pollInterval = setInterval(() => {
|
||||||
|
void refreshCfStatuses();
|
||||||
|
}, 10_000);
|
||||||
|
|
||||||
|
// Stop after 5 minutes
|
||||||
|
setTimeout(() => clearInterval(pollInterval), 5 * 60 * 1000);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setListCfOrderResult("Eroare retea.");
|
||||||
|
} finally {
|
||||||
|
setListCfOrdering(false);
|
||||||
|
}
|
||||||
|
}, [siruta, searchList, listCfOrdering, cfStatusMap, selectedUat, refreshCfStatuses]);
|
||||||
|
|
||||||
|
/** Download all valid CF extracts from list as ZIP */
|
||||||
|
const handleListCfDownloadZip = useCallback(async () => {
|
||||||
|
if (searchList.length === 0 || listCfDownloading) return;
|
||||||
|
|
||||||
|
// Collect valid extract IDs in list order
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const p of searchList) {
|
||||||
|
const status = cfStatusMap[p.nrCad];
|
||||||
|
const extractId = cfLatestIds[p.nrCad];
|
||||||
|
if (status === "valid" && extractId) {
|
||||||
|
ids.push(extractId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
setListCfOrderResult("Niciun extras CF valid in lista.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setListCfDownloading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/ancpi/download-zip?ids=${ids.join(",")}`);
|
||||||
|
if (!res.ok) throw new Error("Eroare descarcare ZIP");
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const cd = res.headers.get("Content-Disposition") ?? "";
|
||||||
|
const match = /filename="?([^"]+)"?/.exec(cd);
|
||||||
|
const filename = match?.[1] ? decodeURIComponent(match[1]) : "Extrase_CF_lista.zip";
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
setListCfOrderResult("Eroare la descarcarea ZIP.");
|
||||||
|
} finally {
|
||||||
|
setListCfDownloading(false);
|
||||||
|
}
|
||||||
|
}, [searchList, cfStatusMap, cfLatestIds, listCfDownloading]);
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════════════ */
|
/* ════════════════════════════════════════════════════════════ */
|
||||||
/* Derived data */
|
/* Derived data */
|
||||||
/* ════════════════════════════════════════════════════════════ */
|
/* ════════════════════════════════════════════════════════════ */
|
||||||
@@ -1663,12 +1874,6 @@ export function ParcelSyncModule() {
|
|||||||
|
|
||||||
const sirutaValid = siruta.length > 0 && /^\d+$/.test(siruta);
|
const sirutaValid = siruta.length > 0 && /^\d+$/.test(siruta);
|
||||||
|
|
||||||
// Resolve selected UAT entry for ePay order context
|
|
||||||
const selectedUat = useMemo(
|
|
||||||
() => uatData.find((u) => u.siruta === siruta),
|
|
||||||
[uatData, siruta],
|
|
||||||
);
|
|
||||||
|
|
||||||
const progressPct =
|
const progressPct =
|
||||||
exportProgress?.total && exportProgress.total > 0
|
exportProgress?.total && exportProgress.total > 0
|
||||||
? Math.round((exportProgress.downloaded / exportProgress.total) * 100)
|
? Math.round((exportProgress.downloaded / exportProgress.total) * 100)
|
||||||
@@ -1798,7 +2003,10 @@ export function ParcelSyncModule() {
|
|||||||
|
|
||||||
{/* Connection pills */}
|
{/* Connection pills */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<EpayConnect />
|
<EpayConnect
|
||||||
|
triggerConnect={sirutaValid}
|
||||||
|
onStatusChange={setEpayStatus}
|
||||||
|
/>
|
||||||
<ConnectionPill
|
<ConnectionPill
|
||||||
session={session}
|
session={session}
|
||||||
connecting={connecting}
|
connecting={connecting}
|
||||||
@@ -2059,23 +2267,23 @@ export function ParcelSyncModule() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-7 w-7 p-0"
|
className="h-7 w-7 p-0"
|
||||||
title="Copiază detalii"
|
title="Copiaza detalii"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const text = [
|
const text = [
|
||||||
`Nr. Cad: ${p.nrCad}`,
|
`Nr. Cad: ${p.nrCad}`,
|
||||||
`Nr. CF: ${p.nrCF || "—"}`,
|
`Nr. CF: ${p.nrCF || "\u2014"}`,
|
||||||
p.nrCFVechi
|
p.nrCFVechi
|
||||||
? `CF vechi: ${p.nrCFVechi}`
|
? `CF vechi: ${p.nrCFVechi}`
|
||||||
: null,
|
: null,
|
||||||
p.nrTopo ? `Nr. Topo: ${p.nrTopo}` : null,
|
p.nrTopo ? `Nr. Topo: ${p.nrTopo}` : null,
|
||||||
p.suprafata != null
|
p.suprafata != null
|
||||||
? `Suprafață: ${p.suprafata.toLocaleString("ro-RO")} mp`
|
? `Suprafata: ${p.suprafata.toLocaleString("ro-RO")} mp`
|
||||||
: null,
|
: null,
|
||||||
`Intravilan: ${p.intravilan || "—"}`,
|
`Intravilan: ${p.intravilan || "\u2014"}`,
|
||||||
p.categorieFolosinta
|
p.categorieFolosinta
|
||||||
? `Categorie: ${p.categorieFolosinta}`
|
? `Categorie: ${p.categorieFolosinta}`
|
||||||
: null,
|
: null,
|
||||||
p.adresa ? `Adresă: ${p.adresa}` : null,
|
p.adresa ? `Adresa: ${p.adresa}` : null,
|
||||||
p.proprietariActuali
|
p.proprietariActuali
|
||||||
? `Proprietari actuali: ${p.proprietariActuali}`
|
? `Proprietari actuali: ${p.proprietariActuali}`
|
||||||
: null,
|
: null,
|
||||||
@@ -2098,14 +2306,77 @@ export function ParcelSyncModule() {
|
|||||||
>
|
>
|
||||||
<ClipboardCopy className="h-3.5 w-3.5" />
|
<ClipboardCopy className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
{p.immovablePk && sirutaValid && (
|
{/* CF Extract status + actions */}
|
||||||
|
{p.immovablePk && sirutaValid && (() => {
|
||||||
|
const cfStatus = cfStatusMap[p.nrCad];
|
||||||
|
const extractId = cfLatestIds[p.nrCad];
|
||||||
|
if (cfStatus === "valid") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-400"
|
||||||
|
>
|
||||||
|
Extras CF valid
|
||||||
|
</Badge>
|
||||||
|
{extractId && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 p-0 text-emerald-600"
|
||||||
|
title="Descarca extras CF"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`/api/ancpi/download?id=${extractId}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cfStatus === "expired") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] border-orange-200 text-orange-600 dark:border-orange-800 dark:text-orange-400"
|
||||||
|
>
|
||||||
|
Extras CF expirat
|
||||||
|
</Badge>
|
||||||
<EpayOrderButton
|
<EpayOrderButton
|
||||||
nrCadastral={p.nrCad}
|
nrCadastral={p.nrCad}
|
||||||
siruta={siruta}
|
siruta={siruta}
|
||||||
judetName={selectedUat?.county ?? ""}
|
judetName={selectedUat?.county ?? ""}
|
||||||
uatName={selectedUat?.name ?? ""}
|
uatName={selectedUat?.name ?? ""}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cfStatus === "processing") {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] border-yellow-200 text-yellow-600 dark:border-yellow-800 dark:text-yellow-400 animate-pulse"
|
||||||
|
>
|
||||||
|
Se proceseaza...
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// "none" or unknown
|
||||||
|
return (
|
||||||
|
<EpayOrderButton
|
||||||
|
nrCadastral={p.nrCad}
|
||||||
|
siruta={siruta}
|
||||||
|
judetName={selectedUat?.county ?? ""}
|
||||||
|
uatName={selectedUat?.name ?? ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2333,7 +2604,7 @@ export function ParcelSyncModule() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-7 w-7 p-0"
|
className="h-7 w-7 p-0"
|
||||||
title="Copiază detalii"
|
title="Copiaza detalii"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const text = [
|
const text = [
|
||||||
`Nr. Cad: ${r.nrCad}`,
|
`Nr. Cad: ${r.nrCad}`,
|
||||||
@@ -2344,9 +2615,9 @@ export function ParcelSyncModule() {
|
|||||||
r.proprietariVechi
|
r.proprietariVechi
|
||||||
? `Proprietari vechi: ${r.proprietariVechi}`
|
? `Proprietari vechi: ${r.proprietariVechi}`
|
||||||
: null,
|
: null,
|
||||||
r.adresa ? `Adresă: ${r.adresa}` : null,
|
r.adresa ? `Adresa: ${r.adresa}` : null,
|
||||||
r.suprafata
|
r.suprafata
|
||||||
? `Suprafață: ${r.suprafata} mp`
|
? `Suprafata: ${r.suprafata} mp`
|
||||||
: null,
|
: null,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -2356,14 +2627,76 @@ export function ParcelSyncModule() {
|
|||||||
>
|
>
|
||||||
<ClipboardCopy className="h-3.5 w-3.5" />
|
<ClipboardCopy className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
{r.immovablePk && sirutaValid && (
|
{/* CF Extract status + actions */}
|
||||||
|
{r.immovablePk && sirutaValid && (() => {
|
||||||
|
const cfStatus = cfStatusMap[r.nrCad];
|
||||||
|
const extractId = cfLatestIds[r.nrCad];
|
||||||
|
if (cfStatus === "valid") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-400"
|
||||||
|
>
|
||||||
|
Extras CF valid
|
||||||
|
</Badge>
|
||||||
|
{extractId && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 p-0 text-emerald-600"
|
||||||
|
title="Descarca extras CF"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`/api/ancpi/download?id=${extractId}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cfStatus === "expired") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] border-orange-200 text-orange-600 dark:border-orange-800 dark:text-orange-400"
|
||||||
|
>
|
||||||
|
Extras CF expirat
|
||||||
|
</Badge>
|
||||||
<EpayOrderButton
|
<EpayOrderButton
|
||||||
nrCadastral={r.nrCad}
|
nrCadastral={r.nrCad}
|
||||||
siruta={siruta}
|
siruta={siruta}
|
||||||
judetName={selectedUat?.county ?? ""}
|
judetName={selectedUat?.county ?? ""}
|
||||||
uatName={selectedUat?.name ?? ""}
|
uatName={selectedUat?.name ?? ""}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cfStatus === "processing") {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] border-yellow-200 text-yellow-600 dark:border-yellow-800 dark:text-yellow-400 animate-pulse"
|
||||||
|
>
|
||||||
|
Se proceseaza...
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<EpayOrderButton
|
||||||
|
nrCadastral={r.nrCad}
|
||||||
|
siruta={siruta}
|
||||||
|
judetName={selectedUat?.county ?? ""}
|
||||||
|
uatName={selectedUat?.name ?? ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2474,29 +2807,80 @@ export function ParcelSyncModule() {
|
|||||||
{searchList.length > 0 && (
|
{searchList.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||||
<h3 className="text-sm font-medium">
|
<h3 className="text-sm font-medium">
|
||||||
Lista mea ({searchList.length} parcele)
|
Lista mea ({searchList.length} parcele)
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setSearchList([])}
|
onClick={() => {
|
||||||
|
setSearchList([]);
|
||||||
|
setListCfOrderResult("");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||||
Golește
|
Goleste
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={downloadCSV}>
|
<Button size="sm" variant="outline" onClick={downloadCSV}>
|
||||||
<FileDown className="mr-1 h-3.5 w-3.5" />
|
<FileDown className="mr-1 h-3.5 w-3.5" />
|
||||||
CSV din listă
|
CSV din lista
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Download all valid CF extracts as ZIP */}
|
||||||
|
{searchList.some((p) => cfStatusMap[p.nrCad] === "valid") && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-400"
|
||||||
|
disabled={listCfDownloading}
|
||||||
|
onClick={() => void handleListCfDownloadZip()}
|
||||||
|
>
|
||||||
|
{listCfDownloading ? (
|
||||||
|
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Archive className="mr-1 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Descarca Extrase CF
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{/* Order CF extracts for list */}
|
||||||
|
{epayStatus.connected && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={listCfOrdering}
|
||||||
|
onClick={() => void handleListCfOrder()}
|
||||||
|
>
|
||||||
|
{listCfOrdering ? (
|
||||||
|
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<FileText className="mr-1 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Scoate Extrase CF
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Order result message */}
|
||||||
|
{listCfOrderResult && (
|
||||||
|
<p className={cn(
|
||||||
|
"text-xs mb-2",
|
||||||
|
listCfOrderResult.startsWith("Eroare")
|
||||||
|
? "text-destructive"
|
||||||
|
: "text-emerald-600 dark:text-emerald-400",
|
||||||
|
)}>
|
||||||
|
{listCfOrderResult}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/40">
|
<tr className="border-b bg-muted/40">
|
||||||
|
<th className="px-2 py-2 text-center font-medium w-8 text-muted-foreground">
|
||||||
|
#
|
||||||
|
</th>
|
||||||
<th className="px-3 py-2 text-left font-medium">
|
<th className="px-3 py-2 text-left font-medium">
|
||||||
Nr. Cad
|
Nr. Cad
|
||||||
</th>
|
</th>
|
||||||
@@ -2504,33 +2888,60 @@ export function ParcelSyncModule() {
|
|||||||
Nr. CF
|
Nr. CF
|
||||||
</th>
|
</th>
|
||||||
<th className="px-3 py-2 text-right font-medium hidden sm:table-cell">
|
<th className="px-3 py-2 text-right font-medium hidden sm:table-cell">
|
||||||
Suprafață
|
Suprafata
|
||||||
</th>
|
</th>
|
||||||
<th className="px-3 py-2 text-left font-medium hidden md:table-cell">
|
<th className="px-3 py-2 text-left font-medium hidden md:table-cell">
|
||||||
Proprietari
|
Proprietari
|
||||||
</th>
|
</th>
|
||||||
|
<th className="px-3 py-2 text-center font-medium">
|
||||||
|
Extras CF
|
||||||
|
</th>
|
||||||
<th className="px-3 py-2 w-8"></th>
|
<th className="px-3 py-2 w-8"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{searchList.map((p) => (
|
{searchList.map((p, idx) => {
|
||||||
|
const cfStatus = cfStatusMap[p.nrCad];
|
||||||
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={`list-${p.nrCad}-${p.immovablePk}`}
|
key={`list-${p.nrCad}-${p.immovablePk}`}
|
||||||
className="border-b hover:bg-muted/30 transition-colors"
|
className="border-b hover:bg-muted/30 transition-colors"
|
||||||
>
|
>
|
||||||
|
<td className="px-2 py-2 text-center text-xs text-muted-foreground tabular-nums">
|
||||||
|
{idx + 1}
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2 font-mono text-xs font-medium">
|
<td className="px-3 py-2 font-mono text-xs font-medium">
|
||||||
{p.nrCad}
|
{p.nrCad}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-xs">
|
<td className="px-3 py-2 text-xs">
|
||||||
{p.nrCF || "—"}
|
{p.nrCF || "\u2014"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right hidden sm:table-cell tabular-nums text-xs">
|
<td className="px-3 py-2 text-right hidden sm:table-cell tabular-nums text-xs">
|
||||||
{p.suprafata != null
|
{p.suprafata != null
|
||||||
? formatArea(p.suprafata)
|
? formatArea(p.suprafata)
|
||||||
: "—"}
|
: "\u2014"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 hidden md:table-cell text-xs truncate max-w-[300px]">
|
<td className="px-3 py-2 hidden md:table-cell text-xs truncate max-w-[300px]">
|
||||||
{p.proprietari || "—"}
|
{p.proprietari || "\u2014"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
{cfStatus === "valid" ? (
|
||||||
|
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-400 dark:border-emerald-800">
|
||||||
|
Valid
|
||||||
|
</span>
|
||||||
|
) : cfStatus === "expired" ? (
|
||||||
|
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950/40 dark:text-orange-400 dark:border-orange-800">
|
||||||
|
Expirat
|
||||||
|
</span>
|
||||||
|
) : cfStatus === "processing" ? (
|
||||||
|
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-yellow-100 text-yellow-700 border-yellow-200 dark:bg-yellow-950/40 dark:text-yellow-400 dark:border-yellow-800 animate-pulse">
|
||||||
|
Procesare
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-muted text-muted-foreground border-muted-foreground/20">
|
||||||
|
Lipsa
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -2543,7 +2954,8 @@ export function ParcelSyncModule() {
|
|||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user