Files
ArchiTools/src/modules/parcel-sync/components/epay-tab.tsx
T
AI Assistant 3da45a4cab feat(parcel-sync): sync button on empty Harta tab + intravilan in base sync
Map tab: when UAT has no local data, shows a "Sincronizează terenuri,
clădiri și intravilan" button that triggers background base sync.

Sync background (base mode): now also syncs LIMITE_INTRAV_DYNAMIC layer
(intravilan boundaries) alongside TERENURI_ACTIVE + CLADIRI_ACTIVE.
Non-critical — if intravilan fails, the rest continues.

Also fixed remaining \u2192 unicode escapes in export/layers/epay tabs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:04:09 +02:00

825 lines
30 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import {
FileText,
Download,
RefreshCw,
Loader2,
Clock,
Archive,
CreditCard,
Search,
} 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 {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
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;
};
type GisUatResult = {
siruta: string;
name: string;
county: string | null;
};
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function formatDate(iso?: string | null) {
if (!iso) return "—";
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 "—";
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: "Valid",
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",
};
}
}
/* ------------------------------------------------------------------ */
/* 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 ------------------------------------------------ */
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);
/* -- Filter ------------------------------------------------------ */
const [filterTab, setFilterTab] = useState<FilterValue>("all");
/* -- Selection (ordered — index = numbering in ZIP) -------------- */
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [downloadingSelection, setDownloadingSelection] = useState(false);
const toggleSelect = (id: string) => {
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
const handleDownloadSelection = async () => {
if (selectedIds.length === 0) return;
setDownloadingSelection(true);
try {
const ids = selectedIds.join(",");
const a = document.createElement("a");
a.href = `/api/ancpi/download-zip?ids=${encodeURIComponent(ids)}`;
a.download = `Extrase_CF_selectie_${selectedIds.length}.zip`;
a.click();
} finally {
setTimeout(() => setDownloadingSelection(false), 2000);
}
};
/* -- Search ------------------------------------------------------ */
const [searchQuery, setSearchQuery] = useState("");
/* -- 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 ---------------------------------------- */
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 res = await fetch("/api/ancpi/orders?limit=200");
const data = (await res.json()) as {
orders: CfExtractRecord[];
total: number;
};
setOrders(data.orders);
setTotal(data.total);
} catch {
/* silent */
} finally {
setLoading(false);
setRefreshing(false);
}
},
[],
);
/* -- 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]);
/* -- 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;
}
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 */
}
})();
return () => controller.abort();
}, [sirutaSearch]);
/* -- 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 */
}
};
/* -- 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;
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 ------------------------------------------------- */}
<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>
<span className="text-xs text-muted-foreground">
{total} extras{total !== 1 ? "e" : ""}
</span>
</div>
<div className="flex items-center gap-2">
{/* Download selection */}
{selectedIds.length > 0 && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="sm"
className="h-7 text-xs"
disabled={downloadingSelection}
onClick={() => void handleDownloadSelection()}
>
{downloadingSelection ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Download className="h-3 w-3 mr-1" />
)}
Descarca selectie ({selectedIds.length})
</Button>
</TooltipTrigger>
<TooltipContent>
ZIP cu {selectedIds.length} extrase numerotate in ordinea selectarii
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Download all valid */}
{validCount > 0 && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<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>
</TooltipTrigger>
<TooltipContent>
ZIP cu toate extrasele valabile
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<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>
{/* -- 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>
{/* -- Filter tabs -------------------------------------------- */}
<div className="flex gap-1 p-0.5 bg-muted rounded-md w-fit">
{FILTER_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
className={cn(
"px-2.5 py-1 text-xs rounded-sm transition-colors",
filterTab === opt.value
? "bg-background text-foreground shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground",
)}
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 ------------------------------------------- */}
{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>
) : 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">
{orders.length === 0
? "Niciun extras CF"
: "Niciun rezultat pentru filtrele selectate"}
</p>
<p className="text-xs mt-1">
{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>
) : (
<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-center font-medium w-8">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<input
type="checkbox"
className="rounded cursor-pointer"
checked={
filteredOrders.length > 0 &&
filteredOrders
.filter((o) => o.status === "completed" && o.minioPath)
.every((o) => selectedIds.includes(o.id))
}
onChange={() => {
const downloadable = filteredOrders.filter(
(o) => o.status === "completed" && o.minioPath,
);
const allSelected = downloadable.every((o) =>
selectedIds.includes(o.id),
);
if (allSelected) {
setSelectedIds([]);
} else {
setSelectedIds(downloadable.map((o) => o.id));
}
}}
/>
</TooltipTrigger>
<TooltipContent>Selecteaza/deselecteaza tot</TooltipContent>
</Tooltip>
</TooltipProvider>
</th>
<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>
<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>
{filteredOrders.map((order, idx) => {
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",
)}
>
{/* Checkbox */}
<td className="px-3 py-2 text-center">
{order.status === "completed" && order.minioPath ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<input
type="checkbox"
className="rounded cursor-pointer"
checked={selectedIds.includes(order.id)}
onChange={() => toggleSelect(order.id)}
/>
</TooltipTrigger>
<TooltipContent>
{selectedIds.includes(order.id)
? `#${selectedIds.indexOf(order.id) + 1} in ZIP`
: "Adauga in selectie"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="text-muted-foreground/30"></span>
)}
</td>
{/* # */}
<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">
<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">{"—"}</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 && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<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>
</TooltipTrigger>
<TooltipContent>
Descarca extras CF ({order.nrCadastral})
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{expired && order.status === "completed" && order.minioPath && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<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" />
Descarca
</a>
</Button>
</TooltipTrigger>
<TooltipContent>
{`Descarca versiunea expirata (${formatShortDate(order.expiresAt)})`}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{expired && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="sm"
className="h-7 px-3 text-xs bg-orange-600 hover:bg-orange-700 text-white"
disabled={!epayStatus.connected}
onClick={() => void handleReorder(order)}
>
<RefreshCw className="h-3 w-3 mr-1" />
Actualizeaza
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Comanda extras CF nou (1 credit)</p>
<p className="text-xs text-muted-foreground">
Extrasul actual a expirat
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</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>
);
}