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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user