From a59d9bc9239a74884fed75b9fbc9e1945bcea970 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Mon, 23 Mar 2026 09:13:51 +0200 Subject: [PATCH] feat(ancpi): complete ePay UI redesign + ZIP download + smart batch ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/api/ancpi/download-zip/route.ts | 114 ++++ src/app/api/ancpi/orders/route.ts | 88 ++- .../parcel-sync/components/epay-connect.tsx | 134 +++-- .../parcel-sync/components/epay-tab.tsx | 402 +++++++------ .../components/parcel-sync-module.tsx | 554 +++++++++++++++--- 5 files changed, 955 insertions(+), 337 deletions(-) create mode 100644 src/app/api/ancpi/download-zip/route.ts diff --git a/src/app/api/ancpi/download-zip/route.ts b/src/app/api/ancpi/download-zip/route.ts new file mode 100644 index 0000000..bd54544 --- /dev/null +++ b/src/app/api/ancpi/download-zip/route.ts @@ -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 }); + } +} diff --git a/src/app/api/ancpi/orders/route.ts b/src/app/api/ancpi/orders/route.ts index 29cb6ee..5f4dff9 100644 --- a/src/app/api/ancpi/orders/route.ts +++ b/src/app/api/ancpi/orders/route.ts @@ -7,18 +7,38 @@ export const dynamic = "force-dynamic"; /** * 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) { try { 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 limit = Math.min(parseInt(url.searchParams.get("limit") ?? "50"), 200); 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 = {}; - 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; const [orders, total] = await Promise.all([ @@ -31,6 +51,68 @@ export async function GET(req: Request) { 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 = {}; + const latestById: Record = {}; + + // 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 }); } catch (error) { const message = error instanceof Error ? error.message : "Eroare server"; diff --git a/src/modules/parcel-sync/components/epay-connect.tsx b/src/modules/parcel-sync/components/epay-connect.tsx index acadcd9..ddf74a0 100644 --- a/src/modules/parcel-sync/components/epay-connect.tsx +++ b/src/modules/parcel-sync/components/epay-connect.tsx @@ -3,13 +3,17 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { Loader2, - Wifi, - WifiOff, LogOut, CreditCard, } from "lucide-react"; import { Button } from "@/shared/components/ui/button"; import { Badge } from "@/shared/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/shared/components/ui/tooltip"; import { cn } from "@/shared/lib/utils"; /* ------------------------------------------------------------------ */ @@ -30,8 +34,11 @@ export type EpaySessionStatus = { export function EpayConnect({ onStatusChange, + triggerConnect, }: { onStatusChange?: (status: EpaySessionStatus) => void; + /** When set to true, triggers auto-connect (e.g. when user types UAT) */ + triggerConnect?: boolean; }) { const [status, setStatus] = useState({ connected: false }); const [connecting, setConnecting] = useState(false); @@ -39,6 +46,7 @@ export function EpayConnect({ const pollRef = useRef | null>(null); const cbRef = useRef(onStatusChange); cbRef.current = onStatusChange; + const autoConnectAttempted = useRef(false); const fetchStatus = useCallback(async () => { try { @@ -61,7 +69,8 @@ export function EpayConnect({ }; }, [fetchStatus]); - const connect = async () => { + const connect = useCallback(async () => { + if (connecting || status.connected) return; setConnecting(true); setError(""); try { @@ -77,11 +86,19 @@ export function EpayConnect({ await fetchStatus(); } } catch { - setError("Eroare rețea"); + setError("Eroare retea"); } finally { 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 () => { try { @@ -90,6 +107,7 @@ export function EpayConnect({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "disconnect" }), }); + autoConnectAttempted.current = false; await fetchStatus(); } catch { /* silent */ @@ -97,74 +115,68 @@ export function EpayConnect({ }; return ( -
+
{/* Status pill */} -
- {connecting ? ( - - ) : status.connected ? ( - - - - - ) : error ? ( - - ) : ( - - )} + + + +
+ {connecting ? ( + + ) : status.connected ? ( + + + + + ) : null} - ePay + ePay - {status.connected && status.credits != null && ( - - - {status.credits} - - )} -
+ {status.connected && status.credits != null && ( + + + {status.credits} + + )} +
+ + + {status.connected + ? `${status.credits ?? "?"} credite ePay disponibile` + : error + ? error + : connecting + ? "Se conecteaza..." + : "Se conecteaza automat la selectia UAT"} + + + - {/* Action button */} - {status.connected ? ( + {/* Logout button — only when connected */} + {status.connected && ( - ) : ( - - )} - - {/* Error tooltip */} - {error && !status.connected && ( - - {error} - )}
); diff --git a/src/modules/parcel-sync/components/epay-tab.tsx b/src/modules/parcel-sync/components/epay-tab.tsx index 00e455e..1ef666e 100644 --- a/src/modules/parcel-sync/components/epay-tab.tsx +++ b/src/modules/parcel-sync/components/epay-tab.tsx @@ -6,11 +6,9 @@ import { Download, RefreshCw, Loader2, - AlertCircle, - CreditCard, - Plus, - Trash2, Clock, + Archive, + Search, } from "lucide-react"; import { Button } from "@/shared/components/ui/button"; import { Input } from "@/shared/components/ui/input"; @@ -44,6 +42,12 @@ type CfExtractRecord = { completedAt: string | null; }; +type GisUatResult = { + siruta: string; + name: string; + county: string | null; +}; + /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ @@ -111,7 +115,7 @@ function statusBadge(status: string, expiresAt: string | null): StatusStyle { }; case "completed": return { - label: "Finalizat", + label: "Valid", className: "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 */ /* ------------------------------------------------------------------ */ export function EpayTab() { - /* ── ePay session ──────────────────────────────────────────────── */ + /* -- ePay session ------------------------------------------------ */ const [epayStatus, setEpayStatus] = useState({ connected: false, }); - /* ── Orders list ───────────────────────────────────────────────── */ + /* -- Orders list ------------------------------------------------- */ const [orders, setOrders] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); - /* ── Manual order input ────────────────────────────────────────── */ - const [manualInput, setManualInput] = useState(""); - 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 ------------------------------------------------------ */ + const [filterTab, setFilterTab] = useState("all"); - /* ── Filter ────────────────────────────────────────────────────── */ - const [filterStatus, setFilterStatus] = useState("all"); + /* -- Search ------------------------------------------------------ */ + const [searchQuery, setSearchQuery] = useState(""); - /* ── Polling ───────────────────────────────────────────────────── */ + /* -- SIRUTA autocomplete ----------------------------------------- */ + const [sirutaSearch, setSirutaSearch] = useState(""); + const [sirutaResults, setSirutaResults] = useState([]); + const [showSirutaResults, setShowSirutaResults] = useState(false); + + /* -- Downloading all --------------------------------------------- */ + const [downloadingAll, setDownloadingAll] = useState(false); + + /* -- Polling ----------------------------------------------------- */ const pollRef = useRef | null>(null); const hasActive = orders.some((o) => isActiveStatus(o.status)); - /* ── Fetch session status ──────────────────────────────────────── */ + /* -- Fetch session status ---------------------------------------- */ const fetchEpayStatus = useCallback(async () => { try { const res = await fetch("/api/ancpi/session"); @@ -179,15 +198,12 @@ export function EpayTab() { } }, []); - /* ── Fetch orders ──────────────────────────────────────────────── */ + /* -- Fetch orders ------------------------------------------------ */ const fetchOrders = useCallback( async (showRefreshing = false) => { if (showRefreshing) setRefreshing(true); try { - const params = new URLSearchParams({ limit: "100" }); - if (filterStatus !== "all") params.set("status", filterStatus); - - const res = await fetch(`/api/ancpi/orders?${params.toString()}`); + const res = await fetch("/api/ancpi/orders?limit=200"); const data = (await res.json()) as { orders: CfExtractRecord[]; total: number; @@ -201,16 +217,16 @@ export function EpayTab() { setRefreshing(false); } }, - [filterStatus], + [], ); - /* ── Initial load ──────────────────────────────────────────────── */ + /* -- Initial load ------------------------------------------------ */ useEffect(() => { void fetchEpayStatus(); void fetchOrders(); }, [fetchEpayStatus, fetchOrders]); - /* ── Auto-refresh when active orders exist ─────────────────────── */ + /* -- Auto-refresh when active orders exist ----------------------- */ useEffect(() => { if (pollRef.current) clearInterval(pollRef.current); @@ -226,59 +242,39 @@ export function EpayTab() { }; }, [hasActive, fetchOrders, fetchEpayStatus]); - /* ── Submit manual order ───────────────────────────────────────── */ - const submitManualOrder = async () => { - setOrderSubmitting(true); - setOrderError(""); - setOrderSuccess(""); - - const cadNumbers = manualInput - .split(/[,\n;]+/) - .map((s) => s.trim()) - .filter(Boolean); - - if (cadNumbers.length === 0) { - setOrderError("Introdu cel putin un numar cadastral."); - setOrderSubmitting(false); + /* -- SIRUTA autocomplete ----------------------------------------- */ + useEffect(() => { + const raw = sirutaSearch.trim(); + if (raw.length < 2) { + setSirutaResults([]); + return; + } + // Only search if it looks like a SIRUTA code (digits) + if (!/^\d+$/.test(raw)) { + setSirutaResults([]); return; } - try { - const parcels = cadNumbers.map((nr) => ({ - nrCadastral: nr, - siruta: manualSiruta || undefined, - judetIndex: 0, - judetName: manualCounty || "N/A", - uatId: 0, - uatName: manualUat || "N/A", - })); - - 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(); + const controller = new AbortController(); + void (async () => { + try { + const res = await fetch("/api/eterra/uats", { signal: controller.signal }); + const data = (await res.json()) as { uats?: GisUatResult[] }; + if (data.uats) { + const matches = data.uats + .filter((u) => u.siruta.startsWith(raw)) + .slice(0, 8); + setSirutaResults(matches); + } + } catch { + /* silent */ } - } catch { - setOrderError("Eroare retea."); - } finally { - setOrderSubmitting(false); - } - }; + })(); - /* ── Re-order (for expired extracts) ───────────────────────────── */ + return () => controller.abort(); + }, [sirutaSearch]); + + /* -- Re-order (for expired extracts) ----------------------------- */ const handleReorder = async (order: CfExtractRecord) => { try { 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 = [ - { value: "all", label: "Toate" }, - { value: "queued", label: "In coada" }, - { value: "polling", label: "In procesare" }, - { value: "completed", label: "Finalizate" }, - { value: "failed", label: "Erori" }, - ]; + setDownloadingAll(true); + try { + const ids = validOrders.map((o) => o.id).join(","); + const res = await fetch(`/api/ancpi/download-zip?ids=${ids}`); + 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.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 (
- {/* ── Header: ePay status + credits ───────────────────────── */} -
+ {/* -- Header ------------------------------------------------- */} +

Extrase CF

- {epayStatus.connected ? ( - - - {epayStatus.credits ?? "?"} credite - - ) : ( - - ePay neconectat - - )} -
-
{total} extras{total !== 1 ? "e" : ""} +
+
+ {/* Download all valid */} + {validCount > 0 && ( + + )}
- {/* ── Manual order section ────────────────────────────────── */} - {epayStatus.connected && ( - - -
- - Comanda noua -
+ {/* -- Search bar --------------------------------------------- */} +
+
+ + setSearchQuery(e.target.value)} + className="h-8 pl-8 text-xs" + /> +
+
-
-
- - { - setManualInput(e.target.value); - setOrderError(""); - setOrderSuccess(""); - }} - className="text-sm" - /> -
-
-
- - setManualSiruta(e.target.value)} - className="text-sm" - /> -
-
- - setManualCounty(e.target.value)} - className="text-sm" - /> -
-
- - setManualUat(e.target.value)} - className="text-sm" - /> -
-
-
- -
- - {orderError && ( - - - {orderError} - - )} - {orderSuccess && ( - - {orderSuccess} - - )} -
-
-
- )} - - {/* ── Filter bar ──────────────────────────────────────────── */} + {/* -- Filter tabs -------------------------------------------- */}
- {statusOptions.map((opt) => ( + {FILTER_OPTIONS.map((opt) => ( ))}
- {/* ── Orders table ────────────────────────────────────────── */} + {/* -- Orders table ------------------------------------------- */} {loading ? ( @@ -480,15 +468,19 @@ export function EpayTab() {

Se incarca extrasele...

- ) : orders.length === 0 ? ( + ) : filteredOrders.length === 0 ? ( -

Niciun extras CF

+

+ {orders.length === 0 + ? "Niciun extras CF" + : "Niciun rezultat pentru filtrele selectate"} +

- {epayStatus.connected - ? "Foloseste sectiunea de mai sus sau butonul de pe fiecare parcela." - : "Conecteaza-te la ePay ANCPI pentru a comanda extrase CF."} + {orders.length === 0 + ? "Foloseste butonul de pe fiecare parcela din tab-ul Cautare sau Lista mea." + : "Incearca sa schimbi filtrul sau sa stergi cautarea."}

@@ -497,6 +489,7 @@ export function EpayTab() { + @@ -510,7 +503,7 @@ export function EpayTab() { - {orders.map((order) => { + {filteredOrders.map((order, idx) => { const badge = statusBadge(order.status, order.expiresAt); const expired = order.status === "completed" && isExpired(order.expiresAt); @@ -523,6 +516,11 @@ export function EpayTab() { expired && "opacity-70", )} > + {/* # */} + + {/* Nr. Cadastral */}
# Nr. Cadastral
+ {idx + 1} +
@@ -662,7 +660,7 @@ export function EpayTab() {
)} - {/* ── Active orders indicator ─────────────────────────────── */} + {/* -- Active orders indicator -------------------------------- */} {hasActive && (
diff --git a/src/modules/parcel-sync/components/parcel-sync-module.tsx b/src/modules/parcel-sync/components/parcel-sync-module.tsx index 7784117..73a7d68 100644 --- a/src/modules/parcel-sync/components/parcel-sync-module.tsx +++ b/src/modules/parcel-sync/components/parcel-sync-module.tsx @@ -55,9 +55,9 @@ import { } from "../services/eterra-layers"; import type { ParcelDetail } from "@/app/api/eterra/search/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 { EpayConnect } from "./epay-connect"; +import { EpayConnect, type EpaySessionStatus } from "./epay-connect"; import { EpayOrderButton } from "./epay-order-button"; import { EpayTab } from "./epay-tab"; @@ -441,6 +441,20 @@ export function ParcelSyncModule() { /* dashboard */ const [dashboardSiruta, setDashboardSiruta] = useState(null); + /* ── ePay status (for CF extract features) ──────────────────── */ + const [epayStatus, setEpayStatus] = useState({ connected: false }); + /** CF status map: nrCadastral -> "valid" | "expired" | "none" | "processing" */ + const [cfStatusMap, setCfStatusMap] = useState>({}); + /** Latest completed extract IDs per nrCadastral */ + const [cfLatestIds, setCfLatestIds] = useState>({}); + /** 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 ──────────────────────────────── */ const [includeNoGeom, setIncludeNoGeom] = useState(false); const [noGeomScanning, setNoGeomScanning] = useState(false); @@ -1496,6 +1510,66 @@ export function ParcelSyncModule() { setLoadingFeatures(false); }, [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; + latestById?: Record; + }; + if (data.statusMap) { + setCfStatusMap((prev) => ({ ...prev, ...data.statusMap })); + } + if (data.latestById) { + const idMap: Record = {}; + 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(); + 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 const handleSearchKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -1648,6 +1722,143 @@ export function ParcelSyncModule() { URL.revokeObjectURL(url); }, [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 */ /* ════════════════════════════════════════════════════════════ */ @@ -1663,12 +1874,6 @@ export function ParcelSyncModule() { 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 = exportProgress?.total && exportProgress.total > 0 ? Math.round((exportProgress.downloaded / exportProgress.total) * 100) @@ -1798,7 +2003,10 @@ export function ParcelSyncModule() { {/* Connection pills */}
- + { const text = [ `Nr. Cad: ${p.nrCad}`, - `Nr. CF: ${p.nrCF || "—"}`, + `Nr. CF: ${p.nrCF || "\u2014"}`, p.nrCFVechi ? `CF vechi: ${p.nrCFVechi}` : null, p.nrTopo ? `Nr. Topo: ${p.nrTopo}` : null, p.suprafata != null - ? `Suprafață: ${p.suprafata.toLocaleString("ro-RO")} mp` + ? `Suprafata: ${p.suprafata.toLocaleString("ro-RO")} mp` : null, - `Intravilan: ${p.intravilan || "—"}`, + `Intravilan: ${p.intravilan || "\u2014"}`, p.categorieFolosinta ? `Categorie: ${p.categorieFolosinta}` : null, - p.adresa ? `Adresă: ${p.adresa}` : null, + p.adresa ? `Adresa: ${p.adresa}` : null, p.proprietariActuali ? `Proprietari actuali: ${p.proprietariActuali}` : null, @@ -2098,14 +2306,77 @@ export function ParcelSyncModule() { > - {p.immovablePk && sirutaValid && ( - - )} + {/* CF Extract status + actions */} + {p.immovablePk && sirutaValid && (() => { + const cfStatus = cfStatusMap[p.nrCad]; + const extractId = cfLatestIds[p.nrCad]; + if (cfStatus === "valid") { + return ( +
+ + Extras CF valid + + {extractId && ( + + )} +
+ ); + } + if (cfStatus === "expired") { + return ( +
+ + Extras CF expirat + + +
+ ); + } + if (cfStatus === "processing") { + return ( + + Se proceseaza... + + ); + } + // "none" or unknown + return ( + + ); + })()}
@@ -2333,7 +2604,7 @@ export function ParcelSyncModule() { size="sm" variant="ghost" className="h-7 w-7 p-0" - title="Copiază detalii" + title="Copiaza detalii" onClick={() => { const text = [ `Nr. Cad: ${r.nrCad}`, @@ -2344,9 +2615,9 @@ export function ParcelSyncModule() { r.proprietariVechi ? `Proprietari vechi: ${r.proprietariVechi}` : null, - r.adresa ? `Adresă: ${r.adresa}` : null, + r.adresa ? `Adresa: ${r.adresa}` : null, r.suprafata - ? `Suprafață: ${r.suprafata} mp` + ? `Suprafata: ${r.suprafata} mp` : null, ] .filter(Boolean) @@ -2356,14 +2627,76 @@ export function ParcelSyncModule() { > - {r.immovablePk && sirutaValid && ( - - )} + {/* CF Extract status + actions */} + {r.immovablePk && sirutaValid && (() => { + const cfStatus = cfStatusMap[r.nrCad]; + const extractId = cfLatestIds[r.nrCad]; + if (cfStatus === "valid") { + return ( +
+ + Extras CF valid + + {extractId && ( + + )} +
+ ); + } + if (cfStatus === "expired") { + return ( +
+ + Extras CF expirat + + +
+ ); + } + if (cfStatus === "processing") { + return ( + + Se proceseaza... + + ); + } + return ( + + ); + })()} @@ -2474,29 +2807,80 @@ export function ParcelSyncModule() { {searchList.length > 0 && ( -
+

Lista mea ({searchList.length} parcele)

-
+
- + {/* Download all valid CF extracts as ZIP */} + {searchList.some((p) => cfStatusMap[p.nrCad] === "valid") && ( + + )} + {/* Order CF extracts for list */} + {epayStatus.connected && ( + + )}
+ + {/* Order result message */} + {listCfOrderResult && ( +

+ {listCfOrderResult} +

+ )} +
+ @@ -2504,46 +2888,74 @@ export function ParcelSyncModule() { Nr. CF + - {searchList.map((p) => ( - - - - - - - - ))} + {searchList.map((p, idx) => { + const cfStatus = cfStatusMap[p.nrCad]; + return ( + + + + + + + + + + ); + })}
+ # + Nr. Cad - Suprafață + Suprafata Proprietari + Extras CF +
- {p.nrCad} - - {p.nrCF || "—"} - - {p.suprafata != null - ? formatArea(p.suprafata) - : "—"} - - {p.proprietari || "—"} - - -
+ {idx + 1} + + {p.nrCad} + + {p.nrCF || "\u2014"} + + {p.suprafata != null + ? formatArea(p.suprafata) + : "\u2014"} + + {p.proprietari || "\u2014"} + + {cfStatus === "valid" ? ( + + Valid + + ) : cfStatus === "expired" ? ( + + Expirat + + ) : cfStatus === "processing" ? ( + + Procesare + + ) : ( + + Lipsa + + )} + + +