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:
AI Assistant
2026-03-23 09:13:51 +02:00
parent b7302d274a
commit a59d9bc923
5 changed files with 955 additions and 337 deletions
+200 -202
View File
@@ -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<EpaySessionStatus>({
connected: false,
});
/* ── Orders list ───────────────────────────────────────────────── */
/* -- 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 [filterTab, setFilterTab] = useState<FilterValue>("all");
/* ── Filter ────────────────────────────────────────────────────── */
const [filterStatus, setFilterStatus] = useState<string>("all");
/* -- Search ------------------------------------------------------ */
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 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 (
<div className="space-y-4">
{/* ── Header: ePay status + credits ───────────────────────── */}
<div className="flex items-center justify-between">
{/* -- Header ------------------------------------------------- */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<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>
</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
variant="ghost"
size="sm"
@@ -362,117 +425,42 @@ export function EpayTab() {
</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>
{/* -- Search bar --------------------------------------------- */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input
placeholder="Cauta dupa nr. cadastral, UAT, judet..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 text-xs"
/>
</div>
</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 ──────────────────────────────────────────── */}
{/* -- Filter tabs -------------------------------------------- */}
<div className="flex gap-1 p-0.5 bg-muted rounded-md w-fit">
{statusOptions.map((opt) => (
{FILTER_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={cn(
"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"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => setFilterStatus(opt.value)}
onClick={() => setFilterTab(opt.value)}
>
{opt.label}
{opt.value === "active" && hasActive && (
<span className="ml-1 inline-flex h-1.5 w-1.5 rounded-full bg-yellow-500" />
)}
</button>
))}
</div>
{/* ── Orders table ────────────────────────────────────────── */}
{/* -- Orders table ------------------------------------------- */}
{loading ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
@@ -480,15 +468,19 @@ export function EpayTab() {
<p>Se incarca extrasele...</p>
</CardContent>
</Card>
) : orders.length === 0 ? (
) : filteredOrders.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="font-medium">
{orders.length === 0
? "Niciun extras CF"
: "Niciun rezultat pentru filtrele selectate"}
</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."}
{orders.length === 0
? "Foloseste butonul de pe fiecare parcela din tab-ul Cautare sau Lista mea."
: "Incearca sa schimbi filtrul sau sa stergi cautarea."}
</p>
</CardContent>
</Card>
@@ -497,6 +489,7 @@ export function EpayTab() {
<table className="w-full text-sm">
<thead>
<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">
Nr. Cadastral
</th>
@@ -510,7 +503,7 @@ export function EpayTab() {
</tr>
</thead>
<tbody>
{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",
)}
>
{/* # */}
<td className="px-3 py-2 text-xs text-muted-foreground tabular-nums">
{idx + 1}
</td>
{/* Nr. Cadastral */}
<td className="px-3 py-2">
<div className="flex flex-col">
@@ -662,7 +660,7 @@ export function EpayTab() {
</div>
)}
{/* ── Active orders indicator ─────────────────────────────── */}
{/* -- Active orders indicator -------------------------------- */}
{hasActive && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />