diff --git a/src/app/api/ancpi/order/route.ts b/src/app/api/ancpi/order/route.ts index 31bf8e5..77b6ae2 100644 --- a/src/app/api/ancpi/order/route.ts +++ b/src/app/api/ancpi/order/route.ts @@ -9,11 +9,41 @@ import type { CfExtractCreateInput } from "@/modules/parcel-sync/services/epay-t export const runtime = "nodejs"; export const dynamic = "force-dynamic"; +/* ------------------------------------------------------------------ */ +/* Nonce-based idempotency cache */ +/* ------------------------------------------------------------------ */ + +type NonceEntry = { + timestamp: number; + response: { orders: Array<{ id: string; nrCadastral: string; status: string }> }; +}; + +const NONCE_TTL_MS = 60_000; // 60 seconds + +const gNonce = globalThis as { + __orderNonceMap?: Map; +}; +if (!gNonce.__orderNonceMap) gNonce.__orderNonceMap = new Map(); + +function cleanupNonceMap(): void { + const now = Date.now(); + const map = gNonce.__orderNonceMap!; + for (const [key, entry] of map) { + if (now - entry.timestamp > NONCE_TTL_MS) { + map.delete(key); + } + } +} + /** * POST /api/ancpi/order — create one or more CF extract orders. * - * Body: { parcels: CfExtractCreateInput[] } - * Returns: { orders: [{ id, nrCadastral, status }] } + * Body: { parcels: CfExtractCreateInput[], nonce?: string } + * + * If a `nonce` is provided and was already seen within the last 60 seconds, + * the previous response is returned instead of creating duplicate orders. + * + * Returns: { orders: [{ id, nrCadastral, status }], deduplicated?: boolean } */ export async function POST(req: Request) { try { @@ -27,6 +57,7 @@ export async function POST(req: Request) { const body = (await req.json()) as { parcels?: CfExtractCreateInput[]; + nonce?: string; }; const parcels = body.parcels ?? []; @@ -37,6 +68,21 @@ export async function POST(req: Request) { ); } + // ── Nonce idempotency check ── + cleanupNonceMap(); + if (body.nonce) { + const cached = gNonce.__orderNonceMap!.get(body.nonce); + if (cached && Date.now() - cached.timestamp < NONCE_TTL_MS) { + console.log( + `[ancpi/order] Nonce dedup hit: "${body.nonce}" — returning cached response`, + ); + return NextResponse.json({ + ...cached.response, + deduplicated: true, + }); + } + } + // Validate required fields for (const p of parcels) { if (!p.nrCadastral || p.judetIndex == null || p.uatId == null) { @@ -49,9 +95,13 @@ export async function POST(req: Request) { } } + let responseBody: { + orders: Array<{ id: string; nrCadastral: string; status: string }>; + }; + if (parcels.length === 1) { const id = await enqueueOrder(parcels[0]!); - return NextResponse.json({ + responseBody = { orders: [ { id, @@ -59,17 +109,27 @@ export async function POST(req: Request) { status: "queued", }, ], + }; + } else { + const ids = await enqueueBatch(parcels); + responseBody = { + orders: ids.map((id, i) => ({ + id, + nrCadastral: parcels[i]?.nrCadastral ?? "", + status: "queued", + })), + }; + } + + // ── Cache response for nonce ── + if (body.nonce) { + gNonce.__orderNonceMap!.set(body.nonce, { + timestamp: Date.now(), + response: responseBody, }); } - const ids = await enqueueBatch(parcels); - const orders = ids.map((id, i) => ({ - id, - nrCadastral: parcels[i]?.nrCadastral ?? "", - status: "queued", - })); - - return NextResponse.json({ orders }); + return NextResponse.json(responseBody); } 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/test/route.ts b/src/app/api/ancpi/test/route.ts index 1bc27d7..4703165 100644 --- a/src/app/api/ancpi/test/route.ts +++ b/src/app/api/ancpi/test/route.ts @@ -11,6 +11,23 @@ import { prisma } from "@/core/storage/prisma"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; +/* ------------------------------------------------------------------ */ +/* Dedup for test order step (prevents re-enqueue on page refresh) */ +/* ------------------------------------------------------------------ */ + +type TestOrderEntry = { + timestamp: number; + extractIds: string[]; + parcels: Array<{ nrCadastral: string; uatName: string; siruta: string; extractId: string | undefined }>; +}; + +const TEST_ORDER_DEDUP_TTL_MS = 30_000; // 30 seconds + +const gTestDedup = globalThis as { + __testOrderDedup?: TestOrderEntry | null; +}; +if (gTestDedup.__testOrderDedup === undefined) gTestDedup.__testOrderDedup = null; + /** * GET /api/ancpi/test?step=login|uats|order|download * @@ -296,6 +313,24 @@ export async function GET(req: Request) { // ── order ── Batch order test (USES 2 CREDITS!) // Uses enqueueBatch to create ONE ePay order for all parcels if (step === "order") { + // ── Dedup check: prevent re-enqueue on page refresh ── + const prevEntry = gTestDedup.__testOrderDedup; + if ( + prevEntry && + Date.now() - prevEntry.timestamp < TEST_ORDER_DEDUP_TTL_MS + ) { + console.log( + `[ancpi-test] Order dedup hit: returning cached response (${Math.round((Date.now() - prevEntry.timestamp) / 1000)}s ago)`, + ); + return NextResponse.json({ + step: "order", + deduplicated: true, + message: `Dedup: batch was enqueued ${Math.round((Date.now() - prevEntry.timestamp) / 1000)}s ago — returning cached IDs.`, + extractIds: prevEntry.extractIds, + parcels: prevEntry.parcels, + }); + } + if (!getEpayCredentials()) { createEpaySession(username, password, 0); } @@ -332,17 +367,26 @@ export async function GET(req: Request) { // Use enqueueBatch — ONE order for all parcels const ids = await enqueueBatch(parcels); + const parcelResults = parcels.map((p, i) => ({ + nrCadastral: p.nrCadastral, + uatName: p.uatName, + siruta: p.siruta, + extractId: ids[i], + })); + + // ── Store in dedup cache ── + gTestDedup.__testOrderDedup = { + timestamp: Date.now(), + extractIds: ids, + parcels: parcelResults, + }; + return NextResponse.json({ step: "order", credits, message: `Enqueued batch of ${ids.length} parcels as ONE order.`, extractIds: ids, - parcels: parcels.map((p, i) => ({ - nrCadastral: p.nrCadastral, - uatName: p.uatName, - siruta: p.siruta, - extractId: ids[i], - })), + parcels: parcelResults, }); } diff --git a/src/modules/parcel-sync/components/epay-connect.tsx b/src/modules/parcel-sync/components/epay-connect.tsx new file mode 100644 index 0000000..acadcd9 --- /dev/null +++ b/src/modules/parcel-sync/components/epay-connect.tsx @@ -0,0 +1,171 @@ +"use client"; + +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 { cn } from "@/shared/lib/utils"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export type EpaySessionStatus = { + connected: boolean; + username?: string; + connectedAt?: string; + credits?: number; + creditsCheckedAt?: string; +}; + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function EpayConnect({ + onStatusChange, +}: { + onStatusChange?: (status: EpaySessionStatus) => void; +}) { + const [status, setStatus] = useState({ connected: false }); + const [connecting, setConnecting] = useState(false); + const [error, setError] = useState(""); + const pollRef = useRef | null>(null); + const cbRef = useRef(onStatusChange); + cbRef.current = onStatusChange; + + const fetchStatus = useCallback(async () => { + try { + const res = await fetch("/api/ancpi/session"); + const data = (await res.json()) as EpaySessionStatus; + setStatus(data); + cbRef.current?.(data); + if (data.connected) setError(""); + } catch { + /* silent */ + } + }, []); + + // Poll every 30s + useEffect(() => { + void fetchStatus(); + pollRef.current = setInterval(() => void fetchStatus(), 30_000); + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + }, [fetchStatus]); + + const connect = async () => { + setConnecting(true); + setError(""); + try { + const res = await fetch("/api/ancpi/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const data = (await res.json()) as { success?: boolean; credits?: number; error?: string }; + if (!res.ok || data.error) { + setError(data.error ?? "Eroare conectare ePay"); + } else { + await fetchStatus(); + } + } catch { + setError("Eroare rețea"); + } finally { + setConnecting(false); + } + }; + + const disconnect = async () => { + try { + await fetch("/api/ancpi/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "disconnect" }), + }); + await fetchStatus(); + } catch { + /* silent */ + } + }; + + return ( +
+ {/* Status pill */} +
+ {connecting ? ( + + ) : status.connected ? ( + + + + + ) : error ? ( + + ) : ( + + )} + + ePay + + {status.connected && status.credits != null && ( + + + {status.credits} + + )} +
+ + {/* Action button */} + {status.connected ? ( + + ) : ( + + )} + + {/* Error tooltip */} + {error && !status.connected && ( + + {error} + + )} +
+ ); +} diff --git a/src/modules/parcel-sync/components/epay-order-button.tsx b/src/modules/parcel-sync/components/epay-order-button.tsx new file mode 100644 index 0000000..5ff2a06 --- /dev/null +++ b/src/modules/parcel-sync/components/epay-order-button.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { FileText, Loader2, Check } from "lucide-react"; +import { Button } from "@/shared/components/ui/button"; +import { cn } from "@/shared/lib/utils"; +import type { EpaySessionStatus } from "./epay-connect"; + +/* ------------------------------------------------------------------ */ +/* Props */ +/* ------------------------------------------------------------------ */ + +type Props = { + nrCadastral: string; + siruta: string; + judetName: string; + uatName: string; +}; + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function EpayOrderButton({ + nrCadastral, + siruta, + judetName, + uatName, +}: Props) { + const [ordering, setOrdering] = useState(false); + const [ordered, setOrdered] = useState(false); + const [error, setError] = useState(""); + const [epayStatus, setEpayStatus] = useState({ + connected: false, + }); + const mountedRef = useRef(true); + + useEffect(() => { + return () => { + mountedRef.current = false; + }; + }, []); + + // Check ePay status and whether an extract exists + useEffect(() => { + let cancelled = false; + + const check = async () => { + try { + // Check session + const sRes = await fetch("/api/ancpi/session"); + const sData = (await sRes.json()) as EpaySessionStatus; + if (!cancelled) setEpayStatus(sData); + + // Check if a completed extract already exists + const oRes = await fetch( + `/api/ancpi/orders?nrCadastral=${encodeURIComponent(nrCadastral)}&status=completed&limit=1`, + ); + const oData = (await oRes.json()) as { orders?: unknown[]; total?: number }; + if (!cancelled && oData.total && oData.total > 0) { + setOrdered(true); + } + } catch { + /* silent */ + } + }; + void check(); + + return () => { + cancelled = true; + }; + }, [nrCadastral]); + + const handleOrder = useCallback(async () => { + setOrdering(true); + setError(""); + try { + const res = await fetch("/api/ancpi/order", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + parcels: [ + { + nrCadastral, + siruta, + judetIndex: 0, // server resolves from SIRUTA + judetName, + uatId: 0, // server resolves from SIRUTA + uatName, + }, + ], + }), + }); + const data = (await res.json()) as { orders?: unknown[]; error?: string }; + if (!res.ok || data.error) { + if (mountedRef.current) setError(data.error ?? "Eroare comandă"); + } else { + if (mountedRef.current) setOrdered(true); + } + } catch { + if (mountedRef.current) setError("Eroare rețea"); + } finally { + if (mountedRef.current) setOrdering(false); + } + }, [nrCadastral, siruta, judetName, uatName]); + + const disabled = + !epayStatus.connected || + (epayStatus.credits != null && epayStatus.credits < 1) || + ordering; + + if (ordered) { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/modules/parcel-sync/components/epay-tab.tsx b/src/modules/parcel-sync/components/epay-tab.tsx new file mode 100644 index 0000000..00e455e --- /dev/null +++ b/src/modules/parcel-sync/components/epay-tab.tsx @@ -0,0 +1,676 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { + FileText, + Download, + RefreshCw, + Loader2, + AlertCircle, + CreditCard, + Plus, + Trash2, + Clock, +} from "lucide-react"; +import { Button } from "@/shared/components/ui/button"; +import { Input } from "@/shared/components/ui/input"; +import { Badge } from "@/shared/components/ui/badge"; +import { Card, CardContent } from "@/shared/components/ui/card"; +import { cn } from "@/shared/lib/utils"; +import type { EpaySessionStatus } from "./epay-connect"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +type CfExtractRecord = { + id: string; + orderId: string | null; + nrCadastral: string; + nrCF: string | null; + siruta: string | null; + judetName: string; + uatName: string; + status: string; + epayStatus: string | null; + documentName: string | null; + documentDate: string | null; + minioPath: string | null; + expiresAt: string | null; + errorMessage: string | null; + version: number; + creditsUsed: number; + createdAt: string; + completedAt: string | null; +}; + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function formatDate(iso?: string | null) { + if (!iso) return "\u2014"; + return new Date(iso).toLocaleDateString("ro-RO", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatShortDate(iso?: string | null) { + if (!iso) return "\u2014"; + return new Date(iso).toLocaleDateString("ro-RO", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +} + +function isExpired(expiresAt: string | null): boolean { + if (!expiresAt) return false; + return new Date(expiresAt) < new Date(); +} + +function isActiveStatus(status: string): boolean { + return ["pending", "queued", "cart", "searching", "ordering", "polling", "downloading"].includes( + status, + ); +} + +type StatusStyle = { label: string; className: string; pulse?: boolean }; + +function statusBadge(status: string, expiresAt: string | null): StatusStyle { + if (status === "completed" && isExpired(expiresAt)) { + return { + label: "Expirat", + className: + "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950/40 dark:text-orange-400 dark:border-orange-800", + }; + } + + switch (status) { + case "pending": + case "queued": + return { + label: "In coada", + className: + "bg-muted text-muted-foreground border-muted-foreground/20", + }; + case "cart": + case "searching": + case "ordering": + case "polling": + case "downloading": + return { + label: "Se proceseaza", + className: + "bg-yellow-100 text-yellow-700 border-yellow-200 dark:bg-yellow-950/40 dark:text-yellow-400 dark:border-yellow-800", + pulse: true, + }; + case "completed": + return { + label: "Finalizat", + className: + "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-400 dark:border-emerald-800", + }; + case "failed": + return { + label: "Eroare", + className: + "bg-rose-100 text-rose-700 border-rose-200 dark:bg-rose-950/40 dark:text-rose-400 dark:border-rose-800", + }; + case "cancelled": + return { + label: "Anulat", + className: + "bg-muted text-muted-foreground border-muted-foreground/20", + }; + default: + return { + label: status, + className: + "bg-muted text-muted-foreground border-muted-foreground/20", + }; + } +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function EpayTab() { + /* ── ePay session ──────────────────────────────────────────────── */ + const [epayStatus, setEpayStatus] = useState({ + connected: false, + }); + + /* ── 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 [filterStatus, setFilterStatus] = useState("all"); + + /* ── Polling ───────────────────────────────────────────────────── */ + const pollRef = useRef | null>(null); + const hasActive = orders.some((o) => isActiveStatus(o.status)); + + /* ── Fetch session status ──────────────────────────────────────── */ + const fetchEpayStatus = useCallback(async () => { + try { + const res = await fetch("/api/ancpi/session"); + const data = (await res.json()) as EpaySessionStatus; + setEpayStatus(data); + } catch { + /* silent */ + } + }, []); + + /* ── 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 data = (await res.json()) as { + orders: CfExtractRecord[]; + total: number; + }; + setOrders(data.orders); + setTotal(data.total); + } catch { + /* silent */ + } finally { + setLoading(false); + setRefreshing(false); + } + }, + [filterStatus], + ); + + /* ── Initial load ──────────────────────────────────────────────── */ + useEffect(() => { + void fetchEpayStatus(); + void fetchOrders(); + }, [fetchEpayStatus, fetchOrders]); + + /* ── Auto-refresh when active orders exist ─────────────────────── */ + useEffect(() => { + if (pollRef.current) clearInterval(pollRef.current); + + if (hasActive) { + pollRef.current = setInterval(() => { + void fetchOrders(); + void fetchEpayStatus(); + }, 10_000); + } + + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + }, [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); + 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(); + } + } catch { + setOrderError("Eroare retea."); + } finally { + setOrderSubmitting(false); + } + }; + + /* ── Re-order (for expired extracts) ───────────────────────────── */ + const handleReorder = async (order: CfExtractRecord) => { + try { + const res = await fetch("/api/ancpi/order", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + parcels: [ + { + nrCadastral: order.nrCadastral, + nrCF: order.nrCF, + siruta: order.siruta, + judetIndex: 0, + judetName: order.judetName, + uatId: 0, + uatName: order.uatName, + }, + ], + }), + }); + const data = (await res.json()) as { error?: string }; + if (!res.ok || data.error) { + /* show inline later */ + } else { + void fetchOrders(true); + void fetchEpayStatus(); + } + } catch { + /* silent */ + } + }; + + /* ── Render ────────────────────────────────────────────────────── */ + + const statusOptions = [ + { value: "all", label: "Toate" }, + { value: "queued", label: "In coada" }, + { value: "polling", label: "In procesare" }, + { value: "completed", label: "Finalizate" }, + { value: "failed", label: "Erori" }, + ]; + + return ( +
+ {/* ── Header: ePay status + credits ───────────────────────── */} +
+
+ +

Extrase CF

+ {epayStatus.connected ? ( + + + {epayStatus.credits ?? "?"} credite + + ) : ( + + ePay neconectat + + )} +
+
+ + {total} extras{total !== 1 ? "e" : ""} + + +
+
+ + {/* ── Manual order section ────────────────────────────────── */} + {epayStatus.connected && ( + + +
+ + Comanda noua +
+ +
+
+ + { + 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 ──────────────────────────────────────────── */} +
+ {statusOptions.map((opt) => ( + + ))} +
+ + {/* ── Orders table ────────────────────────────────────────── */} + {loading ? ( + + + +

Se incarca extrasele...

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

Niciun extras CF

+

+ {epayStatus.connected + ? "Foloseste sectiunea de mai sus sau butonul de pe fiecare parcela." + : "Conecteaza-te la ePay ANCPI pentru a comanda extrase CF."} +

+
+
+ ) : ( +
+ + + + + + + + + + + + + {orders.map((order) => { + const badge = statusBadge(order.status, order.expiresAt); + const expired = + order.status === "completed" && isExpired(order.expiresAt); + + return ( + + {/* Nr. Cadastral */} + + + {/* UAT */} + + + {/* Status */} + + + {/* Data */} + + + {/* Expira */} + + + {/* Actiuni */} + + + ); + })} + +
+ Nr. Cadastral + UATStatusDataExpira + Actiuni +
+
+ + {order.nrCadastral} + + {order.nrCF && + order.nrCF !== order.nrCadastral && ( + + CF: {order.nrCF} + + )} + {order.version > 1 && ( + + v{order.version} + + )} +
+
+
+ {order.uatName} + + jud. {order.judetName} + +
+
+ + {badge.label} + + {order.errorMessage && ( +

+ {order.errorMessage} +

+ )} +
+ {formatDate(order.completedAt ?? order.createdAt)} + + {order.expiresAt ? ( + + {expired && ( + + )} + {formatShortDate(order.expiresAt)} + + ) : ( + {"\u2014"} + )} + +
+ {order.status === "completed" && + order.minioPath && + !expired && ( + + )} + {expired && ( + + )} + {order.status === "completed" && + order.minioPath && + expired && ( + + )} +
+
+
+ )} + + {/* ── Active orders indicator ─────────────────────────────── */} + {hasActive && ( +
+ + + Se actualizeaza automat la fiecare 10 secunde... + +
+ )} +
+ ); +} diff --git a/src/modules/parcel-sync/components/parcel-sync-module.tsx b/src/modules/parcel-sync/components/parcel-sync-module.tsx index 8101ef4..7784117 100644 --- a/src/modules/parcel-sync/components/parcel-sync-module.tsx +++ b/src/modules/parcel-sync/components/parcel-sync-module.tsx @@ -55,8 +55,11 @@ 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 } from "lucide-react"; +import { User, FileText } from "lucide-react"; import { UatDashboard } from "./uat-dashboard"; +import { EpayConnect } from "./epay-connect"; +import { EpayOrderButton } from "./epay-order-button"; +import { EpayTab } from "./epay-tab"; /* ------------------------------------------------------------------ */ /* Types */ @@ -1660,6 +1663,12 @@ 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) @@ -1787,13 +1796,16 @@ export function ParcelSyncModule() { )} - {/* Connection pill */} - + {/* Connection pills */} +
+ + +
{/* Tab bar */} @@ -1814,6 +1826,10 @@ export function ParcelSyncModule() { Baza de Date + + + Extrase CF + @@ -2082,6 +2098,14 @@ export function ParcelSyncModule() { > + {p.immovablePk && sirutaValid && ( + + )} @@ -2332,6 +2356,14 @@ export function ParcelSyncModule() { > + {r.immovablePk && sirutaValid && ( + + )} @@ -4133,6 +4165,13 @@ export function ParcelSyncModule() { )} + + {/* ═══════════════════════════════════════════════════════ */} + {/* Tab 5: Extrase CF */} + {/* ═══════════════════════════════════════════════════════ */} + + + ); } diff --git a/src/modules/parcel-sync/services/epay-queue.ts b/src/modules/parcel-sync/services/epay-queue.ts index 8e040fe..ca98c97 100644 --- a/src/modules/parcel-sync/services/epay-queue.ts +++ b/src/modules/parcel-sync/services/epay-queue.ts @@ -40,8 +40,37 @@ type BatchJob = { const g = globalThis as { __epayBatchQueue?: BatchJob[]; __epayQueueProcessing?: boolean; + __epayDedupMap?: Map; }; if (!g.__epayBatchQueue) g.__epayBatchQueue = []; +if (!g.__epayDedupMap) g.__epayDedupMap = new Map(); + +/** TTL for dedup entries in milliseconds (60 seconds). */ +const DEDUP_TTL_MS = 60_000; + +/** + * Build a dedup key from a list of cadastral numbers. + * Sorted and joined so order doesn't matter. + */ +function batchDedupKey(inputs: CfExtractCreateInput[]): string { + return inputs + .map((i) => i.nrCadastral) + .sort() + .join(","); +} + +/** + * Remove expired entries from the dedup map. + */ +function cleanupDedupMap(): void { + const now = Date.now(); + const map = g.__epayDedupMap!; + for (const [key, entry] of map) { + if (now - entry.timestamp > DEDUP_TTL_MS) { + map.delete(key); + } + } +} /* ------------------------------------------------------------------ */ /* Public API */ @@ -64,12 +93,27 @@ export async function enqueueOrder( * Enqueue a batch of CF extract orders. * Creates all DB records, then processes as ONE ePay order. * Returns the CfExtract IDs immediately. + * + * Dedup protection: if the same set of cadastral numbers was enqueued + * within the last 60 seconds, returns the existing extract IDs instead + * of creating duplicate DB records and orders. */ export async function enqueueBatch( inputs: CfExtractCreateInput[], ): Promise { if (inputs.length === 0) return []; + // ── Dedup check ── + cleanupDedupMap(); + const dedupKey = batchDedupKey(inputs); + const existing = g.__epayDedupMap!.get(dedupKey); + if (existing && Date.now() - existing.timestamp < DEDUP_TTL_MS) { + console.log( + `[epay-queue] Dedup hit: batch [${dedupKey}] was enqueued ${Math.round((Date.now() - existing.timestamp) / 1000)}s ago — returning existing IDs`, + ); + return existing.extractIds; + } + const items: QueueItem[] = []; for (const input of inputs) { @@ -99,6 +143,14 @@ export async function enqueueBatch( items.push({ extractId: record.id, input }); } + const extractIds = items.map((i) => i.extractId); + + // ── Record in dedup map ── + g.__epayDedupMap!.set(dedupKey, { + timestamp: Date.now(), + extractIds, + }); + g.__epayBatchQueue!.push({ items }); console.log( @@ -108,7 +160,7 @@ export async function enqueueBatch( // Start processing if not already running void processQueue(); - return items.map((i) => i.extractId); + return extractIds; } /**