b62132ab9e
Adversarial review (9 agents) of f7f7c59..28c870f found 4 confirmed bugs in the hardening itself; all fixed: 1. Parallel-download index race: two items with the SAME nrCadastral in one batch both scanned MinIO, both computed index 1, the second putObject silently overwrote the first paid extract. Pre-allocate per-cadastral indices sequentially before the parallel block; storeCfExtract takes an explicit index (epay-queue.ts, epay-storage.ts). 2. Metadata-fail orphan charge: on saveMetadata failure the row was popped from cleanup tracking even when deleteCartItem was NOT confirmed, leaving an undeletable metadata-less row in the global cart that submitOrder would check out and charge. Now: pop only on confirmed delete; if unconfirmed, mark cartDirty and ABORT before submit (epay-queue.ts). 3. Recover vs live queue race: the widened recover WHERE (orderId:null + cart/ordering/... states) could scoop a concurrently-processing batch's rows and re-stamp them with the wrong orderId. Block recover while getQueueStatus().processing (recover/route.ts). 4. 'review' status leaked as 'done' in the geoportal CF-order modal (minioPath short-circuit) — handed an unverified PDF as a finished extract. Check review/failed BEFORE the minioPath fallback (cf-order-modal.tsx). Plus 2 nits: download-zip excludes 'review' rows server-side; retry button surfaces recover errors/results instead of swallowing them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
958 lines
36 KiB
TypeScript
958 lines
36 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import { useSession } from "next-auth/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";
|
|
import {
|
|
cfDownloadUrl,
|
|
fetchCfOrdersList,
|
|
placeCfOrder,
|
|
type CfExtractRecord,
|
|
} from "./cf-api-base";
|
|
|
|
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 "review":
|
|
return {
|
|
label: "De verificat",
|
|
className:
|
|
"bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950/40 dark:text-amber-400 dark:border-amber-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() {
|
|
/* -- Cutover flag (Plan 003, Faza F) ----------------------------- */
|
|
const { data: session } = useSession();
|
|
const useGisAc = Boolean(
|
|
(session as { useGisAc?: boolean } | null)?.useGisAc,
|
|
);
|
|
|
|
/* -- 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 {
|
|
// ZIP endpoint only exists on the legacy backend today. For pilot
|
|
// users on the gis-ac path we fall back to triggering individual
|
|
// PDF downloads (one-by-one) until gis-api ships a batch endpoint.
|
|
if (useGisAc) {
|
|
for (const id of selectedIds) {
|
|
const a = document.createElement("a");
|
|
a.href = cfDownloadUrl(true, id);
|
|
a.target = "_blank";
|
|
a.rel = "noopener noreferrer";
|
|
a.click();
|
|
}
|
|
} else {
|
|
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 data = await fetchCfOrdersList(useGisAc, { limit: 200 });
|
|
setOrders(data.orders);
|
|
setTotal(data.total);
|
|
} catch {
|
|
/* silent */
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
}
|
|
},
|
|
[useGisAc],
|
|
);
|
|
|
|
/* -- 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) => {
|
|
const result = await placeCfOrder(useGisAc, {
|
|
nrCadastral: order.nrCadastral,
|
|
nrCF: order.nrCF,
|
|
siruta: order.siruta,
|
|
judetName: order.judetName,
|
|
uatName: order.uatName,
|
|
});
|
|
if (result.ok) {
|
|
void fetchOrders(true);
|
|
void fetchEpayStatus();
|
|
}
|
|
/* errors surfaced inline via downstream polling later */
|
|
};
|
|
|
|
/* -- Retry download (QW3) — re-runs poll+download for an already-paid
|
|
* order, no new charge. For rows that failed at the download/poll
|
|
* stage (the order exists at ANCPI but we never stored the PDF). -- */
|
|
const [retryingId, setRetryingId] = useState<string | null>(null);
|
|
const [retryNotice, setRetryNotice] = useState<string | null>(null);
|
|
const handleRetryDownload = async (order: CfExtractRecord) => {
|
|
setRetryingId(order.id);
|
|
setRetryNotice(null);
|
|
try {
|
|
const res = await fetch(
|
|
`/api/ancpi/recover?extractId=${encodeURIComponent(order.id)}`,
|
|
);
|
|
const data = (await res.json().catch(() => ({}))) as {
|
|
error?: string;
|
|
completed?: number;
|
|
attempted?: number;
|
|
};
|
|
if (!res.ok) {
|
|
// 409 (queue busy / no orderId yet), 404, 500 — tell the user.
|
|
setRetryNotice(data.error ?? `Reîncercare eșuată (${res.status}).`);
|
|
} else if ((data.completed ?? 0) > 0) {
|
|
setRetryNotice(
|
|
`Recuperat: ${data.completed}/${data.attempted ?? data.completed} extrase.`,
|
|
);
|
|
} else {
|
|
setRetryNotice(
|
|
"Nimic de recuperat — comanda nu există la ANCPI sau e deja finalizată.",
|
|
);
|
|
}
|
|
void fetchOrders(true);
|
|
void fetchEpayStatus();
|
|
} catch {
|
|
setRetryNotice("Eroare rețea la reîncercare.");
|
|
} finally {
|
|
setRetryingId(null);
|
|
}
|
|
};
|
|
|
|
/* -- 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 {
|
|
if (useGisAc) {
|
|
// No bulk-zip endpoint on api.gis.ac yet — trigger individual
|
|
// PDF downloads. Browser dedup will handle these as separate tabs.
|
|
for (const o of validOrders) {
|
|
const a = document.createElement("a");
|
|
a.href = cfDownloadUrl(true, o.id);
|
|
a.target = "_blank";
|
|
a.rel = "noopener noreferrer";
|
|
a.click();
|
|
}
|
|
} else {
|
|
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">
|
|
<div className="flex flex-wrap items-center gap-1">
|
|
<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>
|
|
<span
|
|
className={cn(
|
|
"inline-flex items-center rounded-full border px-1.5 py-0.5 text-[9px] font-medium",
|
|
order.type === "intern"
|
|
? "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300"
|
|
: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
|
)}
|
|
title={
|
|
order.type === "intern"
|
|
? "CF intern (gratuit, copycf)"
|
|
: "Extras CF ePay (1 credit ANCPI)"
|
|
}
|
|
>
|
|
{order.type === "intern" ? "intern" : "ePay"}
|
|
</span>
|
|
</div>
|
|
{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={cfDownloadUrl(order)}
|
|
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={cfDownloadUrl(order)}
|
|
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>
|
|
)}
|
|
{/* Review row — PDF exists but match was ambiguous;
|
|
let the operator download + verify (QW3/R4). */}
|
|
{order.status === "review" && order.minioPath && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 px-2 text-xs text-amber-700 dark:text-amber-400"
|
|
asChild
|
|
>
|
|
<a
|
|
href={cfDownloadUrl(order)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
<Download className="h-3 w-3 mr-1" />
|
|
Verifica
|
|
</a>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
Descarca PDF pentru verificare manuala
|
|
(potrivire ambigua)
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
{/* Failed-with-order row — retry poll+download, no
|
|
new charge (QW3). */}
|
|
{(order.status === "failed" ||
|
|
order.status === "review") && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 px-2 text-xs"
|
|
disabled={
|
|
!epayStatus.connected ||
|
|
retryingId === order.id
|
|
}
|
|
onClick={() =>
|
|
void handleRetryDownload(order)
|
|
}
|
|
>
|
|
{retryingId === order.id ? (
|
|
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="h-3 w-3 mr-1" />
|
|
)}
|
|
Reincearca
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
Reia descarcarea (fara cost nou) daca comanda
|
|
exista la ANCPI
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* -- Retry notice ------------------------------------------- */}
|
|
{retryNotice && (
|
|
<div className="flex items-center justify-between gap-2 rounded-md border bg-muted/40 px-3 py-2 text-xs">
|
|
<span>{retryNotice}</span>
|
|
<button
|
|
type="button"
|
|
className="text-muted-foreground hover:text-foreground"
|
|
onClick={() => setRetryNotice(null)}
|
|
>
|
|
✕
|
|
</button>
|
|
</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>
|
|
);
|
|
}
|