feat(ancpi): complete ePay UI + dedup protection

UI Components (Phase 4):
- epay-connect.tsx: connection widget with credit badge, auto-connect
- epay-order-button.tsx: per-parcel "Extras CF" button with status
- epay-tab.tsx: full "Extrase CF" tab with orders table, filters,
  download/refresh actions, new order form
- Minimal changes to parcel-sync-module.tsx: 5th tab + button on
  search results + ePay connect widget

Dedup Protection:
- epay-queue.ts: batch-level dedup (60s window, canonical key from
  sorted cadastral numbers)
- order/route.ts: request nonce idempotency (60s cache)
- test/route.ts: refresh protection (30s cache)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-23 04:19:19 +02:00
parent fcc6f8cc20
commit c9ecd284c7
7 changed files with 1221 additions and 26 deletions
@@ -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<EpaySessionStatus>({
connected: false,
});
/* ── Orders list ───────────────────────────────────────────────── */
const [orders, setOrders] = useState<CfExtractRecord[]>([]);
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<string>("all");
/* ── Polling ───────────────────────────────────────────────────── */
const pollRef = useRef<ReturnType<typeof setInterval> | 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 (
<div className="space-y-4">
{/* ── Header: ePay status + credits ───────────────────────── */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-muted-foreground" />
<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">
{total} extras{total !== 1 ? "e" : ""}
</span>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={refreshing}
onClick={() => void fetchOrders(true)}
>
{refreshing ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<RefreshCw className="h-3 w-3 mr-1" />
)}
Reincarca
</Button>
</div>
</div>
{/* ── Manual order section ────────────────────────────────── */}
{epayStatus.connected && (
<Card>
<CardContent className="pt-4 space-y-3">
<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
placeholder="ex: 50001, 50002, 50003"
value={manualInput}
onChange={(e) => {
setManualInput(e.target.value);
setOrderError("");
setOrderSuccess("");
}}
className="text-sm"
/>
</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 className="flex items-center gap-3">
<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">
{statusOptions.map((opt) => (
<button
key={opt.value}
type="button"
className={cn(
"px-2.5 py-1 text-xs rounded-sm transition-colors",
filterStatus === opt.value
? "bg-background text-foreground shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => setFilterStatus(opt.value)}
>
{opt.label}
</button>
))}
</div>
{/* ── Orders table ────────────────────────────────────────── */}
{loading ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Loader2 className="h-8 w-8 mx-auto mb-3 animate-spin opacity-40" />
<p>Se incarca extrasele...</p>
</CardContent>
</Card>
) : orders.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<FileText className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="font-medium">Niciun extras CF</p>
<p className="text-xs mt-1">
{epayStatus.connected
? "Foloseste sectiunea de mai sus sau butonul de pe fiecare parcela."
: "Conecteaza-te la ePay ANCPI pentru a comanda extrase CF."}
</p>
</CardContent>
</Card>
) : (
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40">
<th className="px-3 py-2 text-left font-medium">
Nr. Cadastral
</th>
<th className="px-3 py-2 text-left font-medium">UAT</th>
<th className="px-3 py-2 text-left font-medium">Status</th>
<th className="px-3 py-2 text-left font-medium">Data</th>
<th className="px-3 py-2 text-left font-medium">Expira</th>
<th className="px-3 py-2 text-right font-medium">
Actiuni
</th>
</tr>
</thead>
<tbody>
{orders.map((order) => {
const badge = statusBadge(order.status, order.expiresAt);
const expired =
order.status === "completed" && isExpired(order.expiresAt);
return (
<tr
key={order.id}
className={cn(
"border-b last:border-0 transition-colors hover:bg-muted/30",
expired && "opacity-70",
)}
>
{/* Nr. Cadastral */}
<td className="px-3 py-2">
<div className="flex flex-col">
<span className="font-medium tabular-nums">
{order.nrCadastral}
</span>
{order.nrCF &&
order.nrCF !== order.nrCadastral && (
<span className="text-[10px] text-muted-foreground">
CF: {order.nrCF}
</span>
)}
{order.version > 1 && (
<span className="text-[10px] text-muted-foreground">
v{order.version}
</span>
)}
</div>
</td>
{/* UAT */}
<td className="px-3 py-2">
<div className="flex flex-col">
<span>{order.uatName}</span>
<span className="text-[10px] text-muted-foreground">
jud. {order.judetName}
</span>
</div>
</td>
{/* Status */}
<td className="px-3 py-2">
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium",
badge.className,
badge.pulse && "animate-pulse",
)}
>
{badge.label}
</span>
{order.errorMessage && (
<p
className="text-[10px] text-destructive mt-0.5 max-w-48 truncate"
title={order.errorMessage}
>
{order.errorMessage}
</p>
)}
</td>
{/* Data */}
<td className="px-3 py-2 text-xs text-muted-foreground tabular-nums">
{formatDate(order.completedAt ?? order.createdAt)}
</td>
{/* Expira */}
<td className="px-3 py-2 text-xs tabular-nums">
{order.expiresAt ? (
<span
className={cn(
expired
? "text-orange-600 dark:text-orange-400"
: "text-muted-foreground",
)}
>
{expired && (
<Clock className="inline h-3 w-3 mr-0.5 -mt-0.5" />
)}
{formatShortDate(order.expiresAt)}
</span>
) : (
<span className="text-muted-foreground">{"\u2014"}</span>
)}
</td>
{/* Actiuni */}
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1">
{order.status === "completed" &&
order.minioPath &&
!expired && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
asChild
>
<a
href={`/api/ancpi/download?id=${order.id}`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-3 w-3 mr-1" />
Descarca
</a>
</Button>
)}
{expired && (
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-xs"
disabled={!epayStatus.connected}
onClick={() => void handleReorder(order)}
>
<RefreshCw className="h-3 w-3 mr-1" />
Actualizeaza
</Button>
)}
{order.status === "completed" &&
order.minioPath &&
expired && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
asChild
>
<a
href={`/api/ancpi/download?id=${order.id}`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-3 w-3 mr-1" />
PDF
</a>
</Button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* ── Active orders indicator ─────────────────────────────── */}
{hasActive && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span>
Se actualizeaza automat la fiecare 10 secunde...
</span>
</div>
)}
</div>
);
}