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
+114
View File
@@ -0,0 +1,114 @@
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
import { getCfExtractStream } from "@/modules/parcel-sync/services/epay-storage";
import JSZip from "jszip";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/ancpi/download-zip?ids=id1,id2,id3
*
* Streams a ZIP file containing all requested CF extract PDFs.
* Files named: {index:02d}_Extras CF_{nrCadastral} - {DD-MM-YYYY}.pdf
* Index = position in the ids array (preserves list order).
*/
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const idsParam = url.searchParams.get("ids");
if (!idsParam) {
return NextResponse.json(
{ error: "Parametru 'ids' lipsa." },
{ status: 400 },
);
}
const ids = idsParam.split(",").map((s) => s.trim()).filter(Boolean);
if (ids.length === 0) {
return NextResponse.json(
{ error: "Lista de id-uri goala." },
{ status: 400 },
);
}
// Fetch all extract records
const extracts = await prisma.cfExtract.findMany({
where: { id: { in: ids } },
select: {
id: true,
nrCadastral: true,
minioPath: true,
documentDate: true,
completedAt: true,
},
});
// Build a map for ordering
const extractMap = new Map(extracts.map((e) => [e.id, e]));
const zip = new JSZip();
let filesAdded = 0;
for (let i = 0; i < ids.length; i++) {
const id = ids[i]!;
const extract = extractMap.get(id);
if (!extract?.minioPath) continue;
const dateForName = extract.documentDate ?? extract.completedAt ?? new Date();
const d = new Date(dateForName);
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
const idx = String(i + 1).padStart(2, "0");
const fileName = `${idx}_Extras CF_${extract.nrCadastral} - ${dd}-${mm}-${yyyy}.pdf`;
try {
const stream = await getCfExtractStream(extract.minioPath);
// Collect stream into buffer
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
}
const buffer = Buffer.concat(chunks);
zip.file(fileName, buffer);
filesAdded++;
} catch (err) {
console.error(`[download-zip] Failed to fetch ${extract.minioPath}:`, err);
// Skip this file but continue
}
}
if (filesAdded === 0) {
return NextResponse.json(
{ error: "Niciun fisier PDF gasit." },
{ status: 404 },
);
}
const zipBuffer = await zip.generateAsync({
type: "nodebuffer",
compression: "DEFLATE",
compressionOptions: { level: 6 },
});
const today = new Date();
const todayStr = `${String(today.getDate()).padStart(2, "0")}-${String(today.getMonth() + 1).padStart(2, "0")}-${today.getFullYear()}`;
const archiveName = `Extrase_CF_${filesAdded}_${todayStr}.zip`;
return new Response(new Uint8Array(zipBuffer), {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${encodeURIComponent(archiveName)}"`,
"Content-Length": String(zipBuffer.length),
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+85 -3
View File
@@ -7,18 +7,38 @@ export const dynamic = "force-dynamic";
/**
* GET /api/ancpi/orders — list all CF extract orders.
*
* Query params: ?nrCadastral=&status=&limit=50&offset=0
* Query params:
* ?nrCadastral=123 — single cadastral number
* ?nrCadastral=123,456 — comma-separated for batch status check
* ?status=completed — filter by status
* ?limit=50&offset=0 — pagination
*
* When nrCadastral contains commas, returns an extra `statusMap` field:
* { orders, total, statusMap: { "123": "valid", "456": "expired", "789": "none" } }
* - "valid" = completed + expiresAt > now
* - "expired" = completed + expiresAt <= now
* - "none" = no completed record
*/
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const nrCadastral = url.searchParams.get("nrCadastral") || undefined;
const nrCadastralParam = url.searchParams.get("nrCadastral") || undefined;
const status = url.searchParams.get("status") || undefined;
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "50"), 200);
const offset = parseInt(url.searchParams.get("offset") ?? "0");
// Check if multi-cadastral query
const cadastralNumbers = nrCadastralParam
? nrCadastralParam.split(",").map((s) => s.trim()).filter(Boolean)
: [];
const isMulti = cadastralNumbers.length > 1;
const where: Record<string, unknown> = {};
if (nrCadastral) where.nrCadastral = nrCadastral;
if (cadastralNumbers.length === 1) {
where.nrCadastral = cadastralNumbers[0];
} else if (isMulti) {
where.nrCadastral = { in: cadastralNumbers };
}
if (status) where.status = status;
const [orders, total] = await Promise.all([
@@ -31,6 +51,68 @@ export async function GET(req: Request) {
prisma.cfExtract.count({ where }),
]);
// Build statusMap for multi-cadastral queries (or single if requested)
if (cadastralNumbers.length > 0) {
const now = new Date();
// For status map, we need completed records for each cadastral number
const completedRecords = await prisma.cfExtract.findMany({
where: {
nrCadastral: { in: cadastralNumbers },
status: "completed",
},
orderBy: { createdAt: "desc" },
select: {
id: true,
nrCadastral: true,
expiresAt: true,
completedAt: true,
minioPath: true,
},
});
const statusMap: Record<string, string> = {};
const latestById: Record<string, typeof completedRecords[number]> = {};
// Find latest completed record per cadastral number
for (const rec of completedRecords) {
const existing = latestById[rec.nrCadastral];
if (!existing) {
latestById[rec.nrCadastral] = rec;
}
}
for (const nr of cadastralNumbers) {
const rec = latestById[nr];
if (!rec) {
statusMap[nr] = "none";
} else if (rec.expiresAt && rec.expiresAt <= now) {
statusMap[nr] = "expired";
} else {
statusMap[nr] = "valid";
}
}
// Also check for active (in-progress) orders
const activeRecords = await prisma.cfExtract.findMany({
where: {
nrCadastral: { in: cadastralNumbers },
status: {
in: ["pending", "queued", "cart", "searching", "ordering", "polling", "downloading"],
},
},
select: { nrCadastral: true },
});
for (const rec of activeRecords) {
// If there's an active order, mark as "processing" (takes priority over "none")
if (statusMap[rec.nrCadastral] === "none") {
statusMap[rec.nrCadastral] = "processing";
}
}
return NextResponse.json({ orders, total, statusMap, latestById });
}
return NextResponse.json({ orders, total });
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
@@ -3,13 +3,17 @@
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 {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/lib/utils";
/* ------------------------------------------------------------------ */
@@ -30,8 +34,11 @@ export type EpaySessionStatus = {
export function EpayConnect({
onStatusChange,
triggerConnect,
}: {
onStatusChange?: (status: EpaySessionStatus) => void;
/** When set to true, triggers auto-connect (e.g. when user types UAT) */
triggerConnect?: boolean;
}) {
const [status, setStatus] = useState<EpaySessionStatus>({ connected: false });
const [connecting, setConnecting] = useState(false);
@@ -39,6 +46,7 @@ export function EpayConnect({
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const cbRef = useRef(onStatusChange);
cbRef.current = onStatusChange;
const autoConnectAttempted = useRef(false);
const fetchStatus = useCallback(async () => {
try {
@@ -61,7 +69,8 @@ export function EpayConnect({
};
}, [fetchStatus]);
const connect = async () => {
const connect = useCallback(async () => {
if (connecting || status.connected) return;
setConnecting(true);
setError("");
try {
@@ -77,11 +86,19 @@ export function EpayConnect({
await fetchStatus();
}
} catch {
setError("Eroare rețea");
setError("Eroare retea");
} finally {
setConnecting(false);
}
};
}, [connecting, status.connected, fetchStatus]);
// Auto-connect when triggerConnect becomes true
useEffect(() => {
if (triggerConnect && !status.connected && !connecting && !autoConnectAttempted.current) {
autoConnectAttempted.current = true;
void connect();
}
}, [triggerConnect, status.connected, connecting, connect]);
const disconnect = async () => {
try {
@@ -90,6 +107,7 @@ export function EpayConnect({
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "disconnect" }),
});
autoConnectAttempted.current = false;
await fetchStatus();
} catch {
/* silent */
@@ -97,74 +115,68 @@ export function EpayConnect({
};
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{/* Status pill */}
<div
className={cn(
"flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium",
status.connected
? "border-emerald-200 bg-emerald-50/80 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-400"
: error
? "border-rose-200 bg-rose-50/80 text-rose-600 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-400"
: "border-muted-foreground/20 bg-muted/50 text-muted-foreground",
)}
>
{connecting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : status.connected ? (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
</span>
) : error ? (
<WifiOff className="h-3 w-3" />
) : (
<Wifi className="h-3 w-3 opacity-50" />
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium",
status.connected
? "border-emerald-200 bg-emerald-50/80 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-400"
: error
? "border-rose-200 bg-rose-50/80 text-rose-600 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-400"
: connecting
? "border-muted-foreground/20 bg-muted/50 text-muted-foreground"
: "border-muted-foreground/20 bg-muted/50 text-muted-foreground",
)}
>
{connecting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : status.connected ? (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
</span>
) : null}
<span className="hidden sm:inline">ePay</span>
<span className="hidden sm:inline">ePay</span>
{status.connected && status.credits != null && (
<Badge
variant="secondary"
className="ml-0.5 h-4 px-1.5 text-[10px] font-semibold"
>
<CreditCard className="mr-0.5 h-2.5 w-2.5" />
{status.credits}
</Badge>
)}
</div>
{status.connected && status.credits != null && (
<Badge
variant="secondary"
className="ml-0.5 h-4 px-1.5 text-[10px] font-semibold"
>
<CreditCard className="mr-0.5 h-2.5 w-2.5" />
{status.credits}
</Badge>
)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{status.connected
? `${status.credits ?? "?"} credite ePay disponibile`
: error
? error
: connecting
? "Se conecteaza..."
: "Se conecteaza automat la selectia UAT"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Action button */}
{status.connected ? (
{/* Logout button — only when connected */}
{status.connected && (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[10px]"
onClick={() => void disconnect()}
title="Deconectare ePay"
>
<LogOut className="h-3 w-3" />
</Button>
) : (
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-[10px]"
disabled={connecting}
onClick={() => void connect()}
>
{connecting ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
) : null}
Conectare
</Button>
)}
{/* Error tooltip */}
{error && !status.connected && (
<span className="text-[10px] text-rose-500 max-w-40 truncate" title={error}>
{error}
</span>
)}
</div>
);
+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" />
@@ -55,9 +55,9 @@ 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, FileText } from "lucide-react";
import { User, FileText, Archive } from "lucide-react";
import { UatDashboard } from "./uat-dashboard";
import { EpayConnect } from "./epay-connect";
import { EpayConnect, type EpaySessionStatus } from "./epay-connect";
import { EpayOrderButton } from "./epay-order-button";
import { EpayTab } from "./epay-tab";
@@ -441,6 +441,20 @@ export function ParcelSyncModule() {
/* dashboard */
const [dashboardSiruta, setDashboardSiruta] = useState<string | null>(null);
/* ── ePay status (for CF extract features) ──────────────────── */
const [epayStatus, setEpayStatus] = useState<EpaySessionStatus>({ connected: false });
/** CF status map: nrCadastral -> "valid" | "expired" | "none" | "processing" */
const [cfStatusMap, setCfStatusMap] = useState<Record<string, string>>({});
/** Latest completed extract IDs per nrCadastral */
const [cfLatestIds, setCfLatestIds] = useState<Record<string, string>>({});
/** Whether we're currently loading CF statuses */
const [cfStatusLoading, setCfStatusLoading] = useState(false);
/** List CF batch order state */
const [listCfOrdering, setListCfOrdering] = useState(false);
const [listCfOrderResult, setListCfOrderResult] = useState("");
/** Downloading ZIP state */
const [listCfDownloading, setListCfDownloading] = useState(false);
/* ── No-geometry import option ──────────────────────────────── */
const [includeNoGeom, setIncludeNoGeom] = useState(false);
const [noGeomScanning, setNoGeomScanning] = useState(false);
@@ -1496,6 +1510,66 @@ export function ParcelSyncModule() {
setLoadingFeatures(false);
}, [siruta, featuresSearch, workspacePk]);
/** Fetch CF extract status for a set of cadastral numbers */
const fetchCfStatuses = useCallback(async (cadastralNumbers: string[]) => {
if (cadastralNumbers.length === 0) return;
setCfStatusLoading(true);
try {
const nrs = cadastralNumbers.join(",");
const res = await fetch(`/api/ancpi/orders?nrCadastral=${encodeURIComponent(nrs)}&limit=1`);
const data = (await res.json()) as {
statusMap?: Record<string, string>;
latestById?: Record<string, { id: string; expiresAt: string | null }>;
};
if (data.statusMap) {
setCfStatusMap((prev) => ({ ...prev, ...data.statusMap }));
}
if (data.latestById) {
const idMap: Record<string, string> = {};
for (const [nr, rec] of Object.entries(data.latestById)) {
if (rec && typeof rec === "object" && "id" in rec) {
idMap[nr] = (rec as { id: string }).id;
}
}
setCfLatestIds((prev) => ({ ...prev, ...idMap }));
}
} catch {
/* silent */
} finally {
setCfStatusLoading(false);
}
}, []);
/** Refresh CF statuses for current search results + list items */
const refreshCfStatuses = useCallback(() => {
const allNrs = new Set<string>();
for (const r of searchResults) {
if (r.nrCad) allNrs.add(r.nrCad);
}
for (const p of searchList) {
if (p.nrCad) allNrs.add(p.nrCad);
}
if (allNrs.size > 0) {
void fetchCfStatuses(Array.from(allNrs));
}
}, [searchResults, searchList, fetchCfStatuses]);
// Auto-fetch CF statuses when search results change
useEffect(() => {
const nrs = searchResults.map((r) => r.nrCad).filter(Boolean);
if (nrs.length > 0) {
void fetchCfStatuses(nrs);
}
}, [searchResults, fetchCfStatuses]);
// Auto-fetch CF statuses when list changes
useEffect(() => {
const nrs = searchList.map((p) => p.nrCad).filter(Boolean);
if (nrs.length > 0) {
void fetchCfStatuses(nrs);
}
}, [searchList, fetchCfStatuses]);
// No auto-search — user clicks button or presses Enter
const handleSearchKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -1648,6 +1722,143 @@ export function ParcelSyncModule() {
URL.revokeObjectURL(url);
}, [searchList, searchResults, siruta, csvEscape]);
// Resolve selected UAT entry for ePay order context (needed by CF order handlers below)
const selectedUat = useMemo(
() => uatData.find((u) => u.siruta === siruta),
[uatData, siruta],
);
/* ════════════════════════════════════════════════════════════ */
/* List CF extract ordering + ZIP download */
/* ════════════════════════════════════════════════════════════ */
/** Order CF extracts for list items: skip valid, re-order expired, order new */
const handleListCfOrder = useCallback(async () => {
if (!siruta || searchList.length === 0 || listCfOrdering) return;
// Categorize parcels
const toOrder: typeof searchList = [];
const toReorder: typeof searchList = [];
const alreadyValid: typeof searchList = [];
for (const p of searchList) {
const status = cfStatusMap[p.nrCad];
if (status === "valid") {
alreadyValid.push(p);
} else if (status === "expired") {
toReorder.push(p);
} else {
// "none" or "processing" or unknown
if (status !== "processing") {
toOrder.push(p);
}
}
}
const newCount = toOrder.length;
const updateCount = toReorder.length;
const existingCount = alreadyValid.length;
if (newCount === 0 && updateCount === 0) {
setListCfOrderResult(`Toate cele ${existingCount} extrase sunt valide.`);
return;
}
// Confirm
const msg = [
newCount > 0 ? `${newCount} extrase noi` : null,
updateCount > 0 ? `${updateCount} actualizari` : null,
existingCount > 0 ? `${existingCount} existente (skip)` : null,
].filter(Boolean).join(", ");
if (!window.confirm(`Comanda extrase CF:\n${msg}\n\nContinui?`)) return;
setListCfOrdering(true);
setListCfOrderResult("");
try {
const allToProcess = [...toOrder, ...toReorder];
const parcels = allToProcess.map((p) => ({
nrCadastral: p.nrCad,
siruta,
judetIndex: 0,
judetName: selectedUat?.county ?? "",
uatId: 0,
uatName: selectedUat?.name ?? "",
}));
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) {
setListCfOrderResult(`Eroare: ${data.error ?? "Eroare la comanda"}`);
} else {
const count = data.orders?.length ?? allToProcess.length;
setListCfOrderResult(`${count} extras${count > 1 ? "e" : ""} CF trimis${count > 1 ? "e" : ""} la procesare.`);
// Start polling for completion and refresh statuses periodically
const pollInterval = setInterval(() => {
void refreshCfStatuses();
}, 10_000);
// Stop after 5 minutes
setTimeout(() => clearInterval(pollInterval), 5 * 60 * 1000);
}
} catch {
setListCfOrderResult("Eroare retea.");
} finally {
setListCfOrdering(false);
}
}, [siruta, searchList, listCfOrdering, cfStatusMap, selectedUat, refreshCfStatuses]);
/** Download all valid CF extracts from list as ZIP */
const handleListCfDownloadZip = useCallback(async () => {
if (searchList.length === 0 || listCfDownloading) return;
// Collect valid extract IDs in list order
const ids: string[] = [];
for (const p of searchList) {
const status = cfStatusMap[p.nrCad];
const extractId = cfLatestIds[p.nrCad];
if (status === "valid" && extractId) {
ids.push(extractId);
}
}
if (ids.length === 0) {
setListCfOrderResult("Niciun extras CF valid in lista.");
return;
}
setListCfDownloading(true);
try {
const res = await fetch(`/api/ancpi/download-zip?ids=${ids.join(",")}`);
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_lista.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 {
setListCfOrderResult("Eroare la descarcarea ZIP.");
} finally {
setListCfDownloading(false);
}
}, [searchList, cfStatusMap, cfLatestIds, listCfDownloading]);
/* ════════════════════════════════════════════════════════════ */
/* Derived data */
/* ════════════════════════════════════════════════════════════ */
@@ -1663,12 +1874,6 @@ 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)
@@ -1798,7 +2003,10 @@ export function ParcelSyncModule() {
{/* Connection pills */}
<div className="flex items-center gap-2">
<EpayConnect />
<EpayConnect
triggerConnect={sirutaValid}
onStatusChange={setEpayStatus}
/>
<ConnectionPill
session={session}
connecting={connecting}
@@ -2059,23 +2267,23 @@ export function ParcelSyncModule() {
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
title="Copiază detalii"
title="Copiaza detalii"
onClick={() => {
const text = [
`Nr. Cad: ${p.nrCad}`,
`Nr. CF: ${p.nrCF || ""}`,
`Nr. CF: ${p.nrCF || "\u2014"}`,
p.nrCFVechi
? `CF vechi: ${p.nrCFVechi}`
: null,
p.nrTopo ? `Nr. Topo: ${p.nrTopo}` : null,
p.suprafata != null
? `Suprafață: ${p.suprafata.toLocaleString("ro-RO")} mp`
? `Suprafata: ${p.suprafata.toLocaleString("ro-RO")} mp`
: null,
`Intravilan: ${p.intravilan || ""}`,
`Intravilan: ${p.intravilan || "\u2014"}`,
p.categorieFolosinta
? `Categorie: ${p.categorieFolosinta}`
: null,
p.adresa ? `Adresă: ${p.adresa}` : null,
p.adresa ? `Adresa: ${p.adresa}` : null,
p.proprietariActuali
? `Proprietari actuali: ${p.proprietariActuali}`
: null,
@@ -2098,14 +2306,77 @@ export function ParcelSyncModule() {
>
<ClipboardCopy className="h-3.5 w-3.5" />
</Button>
{p.immovablePk && sirutaValid && (
<EpayOrderButton
nrCadastral={p.nrCad}
siruta={siruta}
judetName={selectedUat?.county ?? ""}
uatName={selectedUat?.name ?? ""}
/>
)}
{/* CF Extract status + actions */}
{p.immovablePk && sirutaValid && (() => {
const cfStatus = cfStatusMap[p.nrCad];
const extractId = cfLatestIds[p.nrCad];
if (cfStatus === "valid") {
return (
<div className="flex items-center gap-1">
<Badge
variant="outline"
className="text-[10px] border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-400"
>
Extras CF valid
</Badge>
{extractId && (
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-emerald-600"
title="Descarca extras CF"
asChild
>
<a
href={`/api/ancpi/download?id=${extractId}`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-3.5 w-3.5" />
</a>
</Button>
)}
</div>
);
}
if (cfStatus === "expired") {
return (
<div className="flex items-center gap-1">
<Badge
variant="outline"
className="text-[10px] border-orange-200 text-orange-600 dark:border-orange-800 dark:text-orange-400"
>
Extras CF expirat
</Badge>
<EpayOrderButton
nrCadastral={p.nrCad}
siruta={siruta}
judetName={selectedUat?.county ?? ""}
uatName={selectedUat?.name ?? ""}
/>
</div>
);
}
if (cfStatus === "processing") {
return (
<Badge
variant="outline"
className="text-[10px] border-yellow-200 text-yellow-600 dark:border-yellow-800 dark:text-yellow-400 animate-pulse"
>
Se proceseaza...
</Badge>
);
}
// "none" or unknown
return (
<EpayOrderButton
nrCadastral={p.nrCad}
siruta={siruta}
judetName={selectedUat?.county ?? ""}
uatName={selectedUat?.name ?? ""}
/>
);
})()}
</div>
</div>
@@ -2333,7 +2604,7 @@ export function ParcelSyncModule() {
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
title="Copiază detalii"
title="Copiaza detalii"
onClick={() => {
const text = [
`Nr. Cad: ${r.nrCad}`,
@@ -2344,9 +2615,9 @@ export function ParcelSyncModule() {
r.proprietariVechi
? `Proprietari vechi: ${r.proprietariVechi}`
: null,
r.adresa ? `Adresă: ${r.adresa}` : null,
r.adresa ? `Adresa: ${r.adresa}` : null,
r.suprafata
? `Suprafață: ${r.suprafata} mp`
? `Suprafata: ${r.suprafata} mp`
: null,
]
.filter(Boolean)
@@ -2356,14 +2627,76 @@ export function ParcelSyncModule() {
>
<ClipboardCopy className="h-3.5 w-3.5" />
</Button>
{r.immovablePk && sirutaValid && (
<EpayOrderButton
nrCadastral={r.nrCad}
siruta={siruta}
judetName={selectedUat?.county ?? ""}
uatName={selectedUat?.name ?? ""}
/>
)}
{/* CF Extract status + actions */}
{r.immovablePk && sirutaValid && (() => {
const cfStatus = cfStatusMap[r.nrCad];
const extractId = cfLatestIds[r.nrCad];
if (cfStatus === "valid") {
return (
<div className="flex items-center gap-1">
<Badge
variant="outline"
className="text-[10px] border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-400"
>
Extras CF valid
</Badge>
{extractId && (
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-emerald-600"
title="Descarca extras CF"
asChild
>
<a
href={`/api/ancpi/download?id=${extractId}`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-3.5 w-3.5" />
</a>
</Button>
)}
</div>
);
}
if (cfStatus === "expired") {
return (
<div className="flex items-center gap-1">
<Badge
variant="outline"
className="text-[10px] border-orange-200 text-orange-600 dark:border-orange-800 dark:text-orange-400"
>
Extras CF expirat
</Badge>
<EpayOrderButton
nrCadastral={r.nrCad}
siruta={siruta}
judetName={selectedUat?.county ?? ""}
uatName={selectedUat?.name ?? ""}
/>
</div>
);
}
if (cfStatus === "processing") {
return (
<Badge
variant="outline"
className="text-[10px] border-yellow-200 text-yellow-600 dark:border-yellow-800 dark:text-yellow-400 animate-pulse"
>
Se proceseaza...
</Badge>
);
}
return (
<EpayOrderButton
nrCadastral={r.nrCad}
siruta={siruta}
judetName={selectedUat?.county ?? ""}
uatName={selectedUat?.name ?? ""}
/>
);
})()}
</div>
</div>
@@ -2474,29 +2807,80 @@ export function ParcelSyncModule() {
{searchList.length > 0 && (
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<h3 className="text-sm font-medium">
Lista mea ({searchList.length} parcele)
</h3>
<div className="flex gap-2">
<div className="flex gap-2 flex-wrap">
<Button
size="sm"
variant="outline"
onClick={() => setSearchList([])}
onClick={() => {
setSearchList([]);
setListCfOrderResult("");
}}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
Golește
Goleste
</Button>
<Button size="sm" onClick={downloadCSV}>
<Button size="sm" variant="outline" onClick={downloadCSV}>
<FileDown className="mr-1 h-3.5 w-3.5" />
CSV din listă
CSV din lista
</Button>
{/* Download all valid CF extracts as ZIP */}
{searchList.some((p) => cfStatusMap[p.nrCad] === "valid") && (
<Button
size="sm"
variant="outline"
className="border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-400"
disabled={listCfDownloading}
onClick={() => void handleListCfDownloadZip()}
>
{listCfDownloading ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Archive className="mr-1 h-3.5 w-3.5" />
)}
Descarca Extrase CF
</Button>
)}
{/* Order CF extracts for list */}
{epayStatus.connected && (
<Button
size="sm"
disabled={listCfOrdering}
onClick={() => void handleListCfOrder()}
>
{listCfOrdering ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<FileText className="mr-1 h-3.5 w-3.5" />
)}
Scoate Extrase CF
</Button>
)}
</div>
</div>
{/* Order result message */}
{listCfOrderResult && (
<p className={cn(
"text-xs mb-2",
listCfOrderResult.startsWith("Eroare")
? "text-destructive"
: "text-emerald-600 dark:text-emerald-400",
)}>
{listCfOrderResult}
</p>
)}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40">
<th className="px-2 py-2 text-center font-medium w-8 text-muted-foreground">
#
</th>
<th className="px-3 py-2 text-left font-medium">
Nr. Cad
</th>
@@ -2504,46 +2888,74 @@ export function ParcelSyncModule() {
Nr. CF
</th>
<th className="px-3 py-2 text-right font-medium hidden sm:table-cell">
Suprafață
Suprafata
</th>
<th className="px-3 py-2 text-left font-medium hidden md:table-cell">
Proprietari
</th>
<th className="px-3 py-2 text-center font-medium">
Extras CF
</th>
<th className="px-3 py-2 w-8"></th>
</tr>
</thead>
<tbody>
{searchList.map((p) => (
<tr
key={`list-${p.nrCad}-${p.immovablePk}`}
className="border-b hover:bg-muted/30 transition-colors"
>
<td className="px-3 py-2 font-mono text-xs font-medium">
{p.nrCad}
</td>
<td className="px-3 py-2 text-xs">
{p.nrCF || "—"}
</td>
<td className="px-3 py-2 text-right hidden sm:table-cell tabular-nums text-xs">
{p.suprafata != null
? formatArea(p.suprafata)
: "—"}
</td>
<td className="px-3 py-2 hidden md:table-cell text-xs truncate max-w-[300px]">
{p.proprietari || "—"}
</td>
<td className="px-3 py-2">
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => removeFromList(p.nrCad)}
>
<XCircle className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
))}
{searchList.map((p, idx) => {
const cfStatus = cfStatusMap[p.nrCad];
return (
<tr
key={`list-${p.nrCad}-${p.immovablePk}`}
className="border-b hover:bg-muted/30 transition-colors"
>
<td className="px-2 py-2 text-center text-xs text-muted-foreground tabular-nums">
{idx + 1}
</td>
<td className="px-3 py-2 font-mono text-xs font-medium">
{p.nrCad}
</td>
<td className="px-3 py-2 text-xs">
{p.nrCF || "\u2014"}
</td>
<td className="px-3 py-2 text-right hidden sm:table-cell tabular-nums text-xs">
{p.suprafata != null
? formatArea(p.suprafata)
: "\u2014"}
</td>
<td className="px-3 py-2 hidden md:table-cell text-xs truncate max-w-[300px]">
{p.proprietari || "\u2014"}
</td>
<td className="px-3 py-2 text-center">
{cfStatus === "valid" ? (
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-400 dark:border-emerald-800">
Valid
</span>
) : cfStatus === "expired" ? (
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950/40 dark:text-orange-400 dark:border-orange-800">
Expirat
</span>
) : cfStatus === "processing" ? (
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-yellow-100 text-yellow-700 border-yellow-200 dark:bg-yellow-950/40 dark:text-yellow-400 dark:border-yellow-800 animate-pulse">
Procesare
</span>
) : (
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-muted text-muted-foreground border-muted-foreground/20">
Lipsa
</span>
)}
</td>
<td className="px-3 py-2">
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => removeFromList(p.nrCad)}
>
<XCircle className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>