feat(ancpi): complete ePay UI + dedup protection

UI Components (Phase 4):
- epay-connect.tsx: connection widget with credit badge, auto-connect
- epay-order-button.tsx: per-parcel "Extras CF" button with status
- epay-tab.tsx: full "Extrase CF" tab with orders table, filters,
  download/refresh actions, new order form
- Minimal changes to parcel-sync-module.tsx: 5th tab + button on
  search results + ePay connect widget

Dedup Protection:
- epay-queue.ts: batch-level dedup (60s window, canonical key from
  sorted cadastral numbers)
- order/route.ts: request nonce idempotency (60s cache)
- test/route.ts: refresh protection (30s cache)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-23 04:19:19 +02:00
parent fcc6f8cc20
commit c9ecd284c7
7 changed files with 1221 additions and 26 deletions
+69 -9
View File
@@ -9,11 +9,41 @@ import type { CfExtractCreateInput } from "@/modules/parcel-sync/services/epay-t
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/* ------------------------------------------------------------------ */
/* Nonce-based idempotency cache */
/* ------------------------------------------------------------------ */
type NonceEntry = {
timestamp: number;
response: { orders: Array<{ id: string; nrCadastral: string; status: string }> };
};
const NONCE_TTL_MS = 60_000; // 60 seconds
const gNonce = globalThis as {
__orderNonceMap?: Map<string, NonceEntry>;
};
if (!gNonce.__orderNonceMap) gNonce.__orderNonceMap = new Map();
function cleanupNonceMap(): void {
const now = Date.now();
const map = gNonce.__orderNonceMap!;
for (const [key, entry] of map) {
if (now - entry.timestamp > NONCE_TTL_MS) {
map.delete(key);
}
}
}
/**
* POST /api/ancpi/order — create one or more CF extract orders.
*
* Body: { parcels: CfExtractCreateInput[] }
* Returns: { orders: [{ id, nrCadastral, status }] }
* Body: { parcels: CfExtractCreateInput[], nonce?: string }
*
* If a `nonce` is provided and was already seen within the last 60 seconds,
* the previous response is returned instead of creating duplicate orders.
*
* Returns: { orders: [{ id, nrCadastral, status }], deduplicated?: boolean }
*/
export async function POST(req: Request) {
try {
@@ -27,6 +57,7 @@ export async function POST(req: Request) {
const body = (await req.json()) as {
parcels?: CfExtractCreateInput[];
nonce?: string;
};
const parcels = body.parcels ?? [];
@@ -37,6 +68,21 @@ export async function POST(req: Request) {
);
}
// ── Nonce idempotency check ──
cleanupNonceMap();
if (body.nonce) {
const cached = gNonce.__orderNonceMap!.get(body.nonce);
if (cached && Date.now() - cached.timestamp < NONCE_TTL_MS) {
console.log(
`[ancpi/order] Nonce dedup hit: "${body.nonce}" — returning cached response`,
);
return NextResponse.json({
...cached.response,
deduplicated: true,
});
}
}
// Validate required fields
for (const p of parcels) {
if (!p.nrCadastral || p.judetIndex == null || p.uatId == null) {
@@ -49,9 +95,13 @@ export async function POST(req: Request) {
}
}
let responseBody: {
orders: Array<{ id: string; nrCadastral: string; status: string }>;
};
if (parcels.length === 1) {
const id = await enqueueOrder(parcels[0]!);
return NextResponse.json({
responseBody = {
orders: [
{
id,
@@ -59,17 +109,27 @@ export async function POST(req: Request) {
status: "queued",
},
],
});
}
};
} else {
const ids = await enqueueBatch(parcels);
const orders = ids.map((id, i) => ({
responseBody = {
orders: ids.map((id, i) => ({
id,
nrCadastral: parcels[i]?.nrCadastral ?? "",
status: "queued",
}));
})),
};
}
return NextResponse.json({ orders });
// ── Cache response for nonce ──
if (body.nonce) {
gNonce.__orderNonceMap!.set(body.nonce, {
timestamp: Date.now(),
response: responseBody,
});
}
return NextResponse.json(responseBody);
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
+50 -6
View File
@@ -11,6 +11,23 @@ import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/* ------------------------------------------------------------------ */
/* Dedup for test order step (prevents re-enqueue on page refresh) */
/* ------------------------------------------------------------------ */
type TestOrderEntry = {
timestamp: number;
extractIds: string[];
parcels: Array<{ nrCadastral: string; uatName: string; siruta: string; extractId: string | undefined }>;
};
const TEST_ORDER_DEDUP_TTL_MS = 30_000; // 30 seconds
const gTestDedup = globalThis as {
__testOrderDedup?: TestOrderEntry | null;
};
if (gTestDedup.__testOrderDedup === undefined) gTestDedup.__testOrderDedup = null;
/**
* GET /api/ancpi/test?step=login|uats|order|download
*
@@ -296,6 +313,24 @@ export async function GET(req: Request) {
// ── order ── Batch order test (USES 2 CREDITS!)
// Uses enqueueBatch to create ONE ePay order for all parcels
if (step === "order") {
// ── Dedup check: prevent re-enqueue on page refresh ──
const prevEntry = gTestDedup.__testOrderDedup;
if (
prevEntry &&
Date.now() - prevEntry.timestamp < TEST_ORDER_DEDUP_TTL_MS
) {
console.log(
`[ancpi-test] Order dedup hit: returning cached response (${Math.round((Date.now() - prevEntry.timestamp) / 1000)}s ago)`,
);
return NextResponse.json({
step: "order",
deduplicated: true,
message: `Dedup: batch was enqueued ${Math.round((Date.now() - prevEntry.timestamp) / 1000)}s ago — returning cached IDs.`,
extractIds: prevEntry.extractIds,
parcels: prevEntry.parcels,
});
}
if (!getEpayCredentials()) {
createEpaySession(username, password, 0);
}
@@ -332,17 +367,26 @@ export async function GET(req: Request) {
// Use enqueueBatch — ONE order for all parcels
const ids = await enqueueBatch(parcels);
const parcelResults = parcels.map((p, i) => ({
nrCadastral: p.nrCadastral,
uatName: p.uatName,
siruta: p.siruta,
extractId: ids[i],
}));
// ── Store in dedup cache ──
gTestDedup.__testOrderDedup = {
timestamp: Date.now(),
extractIds: ids,
parcels: parcelResults,
};
return NextResponse.json({
step: "order",
credits,
message: `Enqueued batch of ${ids.length} parcels as ONE order.`,
extractIds: ids,
parcels: parcels.map((p, i) => ({
nrCadastral: p.nrCadastral,
uatName: p.uatName,
siruta: p.siruta,
extractId: ids[i],
})),
parcels: parcelResults,
});
}
@@ -0,0 +1,171 @@
"use client";
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 { cn } from "@/shared/lib/utils";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type EpaySessionStatus = {
connected: boolean;
username?: string;
connectedAt?: string;
credits?: number;
creditsCheckedAt?: string;
};
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function EpayConnect({
onStatusChange,
}: {
onStatusChange?: (status: EpaySessionStatus) => void;
}) {
const [status, setStatus] = useState<EpaySessionStatus>({ connected: false });
const [connecting, setConnecting] = useState(false);
const [error, setError] = useState("");
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const cbRef = useRef(onStatusChange);
cbRef.current = onStatusChange;
const fetchStatus = useCallback(async () => {
try {
const res = await fetch("/api/ancpi/session");
const data = (await res.json()) as EpaySessionStatus;
setStatus(data);
cbRef.current?.(data);
if (data.connected) setError("");
} catch {
/* silent */
}
}, []);
// Poll every 30s
useEffect(() => {
void fetchStatus();
pollRef.current = setInterval(() => void fetchStatus(), 30_000);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, [fetchStatus]);
const connect = async () => {
setConnecting(true);
setError("");
try {
const res = await fetch("/api/ancpi/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const data = (await res.json()) as { success?: boolean; credits?: number; error?: string };
if (!res.ok || data.error) {
setError(data.error ?? "Eroare conectare ePay");
} else {
await fetchStatus();
}
} catch {
setError("Eroare rețea");
} finally {
setConnecting(false);
}
};
const disconnect = async () => {
try {
await fetch("/api/ancpi/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "disconnect" }),
});
await fetchStatus();
} catch {
/* silent */
}
};
return (
<div className="flex items-center gap-2">
{/* 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" />
)}
<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>
{/* Action button */}
{status.connected ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[10px]"
onClick={() => void disconnect()}
>
<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>
);
}
@@ -0,0 +1,153 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { FileText, Loader2, Check } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { cn } from "@/shared/lib/utils";
import type { EpaySessionStatus } from "./epay-connect";
/* ------------------------------------------------------------------ */
/* Props */
/* ------------------------------------------------------------------ */
type Props = {
nrCadastral: string;
siruta: string;
judetName: string;
uatName: string;
};
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function EpayOrderButton({
nrCadastral,
siruta,
judetName,
uatName,
}: Props) {
const [ordering, setOrdering] = useState(false);
const [ordered, setOrdered] = useState(false);
const [error, setError] = useState("");
const [epayStatus, setEpayStatus] = useState<EpaySessionStatus>({
connected: false,
});
const mountedRef = useRef(true);
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
// Check ePay status and whether an extract exists
useEffect(() => {
let cancelled = false;
const check = async () => {
try {
// Check session
const sRes = await fetch("/api/ancpi/session");
const sData = (await sRes.json()) as EpaySessionStatus;
if (!cancelled) setEpayStatus(sData);
// Check if a completed extract already exists
const oRes = await fetch(
`/api/ancpi/orders?nrCadastral=${encodeURIComponent(nrCadastral)}&status=completed&limit=1`,
);
const oData = (await oRes.json()) as { orders?: unknown[]; total?: number };
if (!cancelled && oData.total && oData.total > 0) {
setOrdered(true);
}
} catch {
/* silent */
}
};
void check();
return () => {
cancelled = true;
};
}, [nrCadastral]);
const handleOrder = useCallback(async () => {
setOrdering(true);
setError("");
try {
const res = await fetch("/api/ancpi/order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
parcels: [
{
nrCadastral,
siruta,
judetIndex: 0, // server resolves from SIRUTA
judetName,
uatId: 0, // server resolves from SIRUTA
uatName,
},
],
}),
});
const data = (await res.json()) as { orders?: unknown[]; error?: string };
if (!res.ok || data.error) {
if (mountedRef.current) setError(data.error ?? "Eroare comandă");
} else {
if (mountedRef.current) setOrdered(true);
}
} catch {
if (mountedRef.current) setError("Eroare rețea");
} finally {
if (mountedRef.current) setOrdering(false);
}
}, [nrCadastral, siruta, judetName, uatName]);
const disabled =
!epayStatus.connected ||
(epayStatus.credits != null && epayStatus.credits < 1) ||
ordering;
if (ordered) {
return (
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-emerald-600 dark:text-emerald-400"
title="Extras CF comandat"
disabled
>
<Check className="h-3.5 w-3.5" />
</Button>
);
}
return (
<Button
size="sm"
variant="ghost"
className={cn(
"h-7 w-7 p-0",
error && "text-destructive",
)}
title={
error
? error
: !epayStatus.connected
? "ePay neconectat"
: epayStatus.credits != null && epayStatus.credits < 1
? "Credite insuficiente"
: "Extras CF"
}
disabled={disabled}
onClick={() => void handleOrder()}
>
{ordering ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<FileText className="h-3.5 w-3.5" />
)}
</Button>
);
}
@@ -0,0 +1,676 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import {
FileText,
Download,
RefreshCw,
Loader2,
AlertCircle,
CreditCard,
Plus,
Trash2,
Clock,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Badge } from "@/shared/components/ui/badge";
import { Card, CardContent } from "@/shared/components/ui/card";
import { cn } from "@/shared/lib/utils";
import type { EpaySessionStatus } from "./epay-connect";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type CfExtractRecord = {
id: string;
orderId: string | null;
nrCadastral: string;
nrCF: string | null;
siruta: string | null;
judetName: string;
uatName: string;
status: string;
epayStatus: string | null;
documentName: string | null;
documentDate: string | null;
minioPath: string | null;
expiresAt: string | null;
errorMessage: string | null;
version: number;
creditsUsed: number;
createdAt: string;
completedAt: string | null;
};
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function formatDate(iso?: string | null) {
if (!iso) return "\u2014";
return new Date(iso).toLocaleDateString("ro-RO", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function formatShortDate(iso?: string | null) {
if (!iso) return "\u2014";
return new Date(iso).toLocaleDateString("ro-RO", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
function isExpired(expiresAt: string | null): boolean {
if (!expiresAt) return false;
return new Date(expiresAt) < new Date();
}
function isActiveStatus(status: string): boolean {
return ["pending", "queued", "cart", "searching", "ordering", "polling", "downloading"].includes(
status,
);
}
type StatusStyle = { label: string; className: string; pulse?: boolean };
function statusBadge(status: string, expiresAt: string | null): StatusStyle {
if (status === "completed" && isExpired(expiresAt)) {
return {
label: "Expirat",
className:
"bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950/40 dark:text-orange-400 dark:border-orange-800",
};
}
switch (status) {
case "pending":
case "queued":
return {
label: "In coada",
className:
"bg-muted text-muted-foreground border-muted-foreground/20",
};
case "cart":
case "searching":
case "ordering":
case "polling":
case "downloading":
return {
label: "Se proceseaza",
className:
"bg-yellow-100 text-yellow-700 border-yellow-200 dark:bg-yellow-950/40 dark:text-yellow-400 dark:border-yellow-800",
pulse: true,
};
case "completed":
return {
label: "Finalizat",
className:
"bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-400 dark:border-emerald-800",
};
case "failed":
return {
label: "Eroare",
className:
"bg-rose-100 text-rose-700 border-rose-200 dark:bg-rose-950/40 dark:text-rose-400 dark:border-rose-800",
};
case "cancelled":
return {
label: "Anulat",
className:
"bg-muted text-muted-foreground border-muted-foreground/20",
};
default:
return {
label: status,
className:
"bg-muted text-muted-foreground border-muted-foreground/20",
};
}
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function EpayTab() {
/* ── ePay session ──────────────────────────────────────────────── */
const [epayStatus, setEpayStatus] = useState<EpaySessionStatus>({
connected: false,
});
/* ── Orders list ───────────────────────────────────────────────── */
const [orders, setOrders] = useState<CfExtractRecord[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
/* ── 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 [filterStatus, setFilterStatus] = useState<string>("all");
/* ── Polling ───────────────────────────────────────────────────── */
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const hasActive = orders.some((o) => isActiveStatus(o.status));
/* ── Fetch session status ──────────────────────────────────────── */
const fetchEpayStatus = useCallback(async () => {
try {
const res = await fetch("/api/ancpi/session");
const data = (await res.json()) as EpaySessionStatus;
setEpayStatus(data);
} catch {
/* silent */
}
}, []);
/* ── Fetch orders ──────────────────────────────────────────────── */
const fetchOrders = useCallback(
async (showRefreshing = false) => {
if (showRefreshing) setRefreshing(true);
try {
const params = new URLSearchParams({ limit: "100" });
if (filterStatus !== "all") params.set("status", filterStatus);
const res = await fetch(`/api/ancpi/orders?${params.toString()}`);
const data = (await res.json()) as {
orders: CfExtractRecord[];
total: number;
};
setOrders(data.orders);
setTotal(data.total);
} catch {
/* silent */
} finally {
setLoading(false);
setRefreshing(false);
}
},
[filterStatus],
);
/* ── Initial load ──────────────────────────────────────────────── */
useEffect(() => {
void fetchEpayStatus();
void fetchOrders();
}, [fetchEpayStatus, fetchOrders]);
/* ── Auto-refresh when active orders exist ─────────────────────── */
useEffect(() => {
if (pollRef.current) clearInterval(pollRef.current);
if (hasActive) {
pollRef.current = setInterval(() => {
void fetchOrders();
void fetchEpayStatus();
}, 10_000);
}
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
}, [hasActive, fetchOrders, fetchEpayStatus]);
/* ── 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);
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();
}
} catch {
setOrderError("Eroare retea.");
} finally {
setOrderSubmitting(false);
}
};
/* ── Re-order (for expired extracts) ───────────────────────────── */
const handleReorder = async (order: CfExtractRecord) => {
try {
const res = await fetch("/api/ancpi/order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
parcels: [
{
nrCadastral: order.nrCadastral,
nrCF: order.nrCF,
siruta: order.siruta,
judetIndex: 0,
judetName: order.judetName,
uatId: 0,
uatName: order.uatName,
},
],
}),
});
const data = (await res.json()) as { error?: string };
if (!res.ok || data.error) {
/* show inline later */
} else {
void fetchOrders(true);
void fetchEpayStatus();
}
} catch {
/* silent */
}
};
/* ── Render ────────────────────────────────────────────────────── */
const statusOptions = [
{ value: "all", label: "Toate" },
{ value: "queued", label: "In coada" },
{ value: "polling", label: "In procesare" },
{ value: "completed", label: "Finalizate" },
{ value: "failed", label: "Erori" },
];
return (
<div className="space-y-4">
{/* ── Header: ePay status + credits ───────────────────────── */}
<div className="flex items-center justify-between">
<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>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={refreshing}
onClick={() => void fetchOrders(true)}
>
{refreshing ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<RefreshCw className="h-3 w-3 mr-1" />
)}
Reincarca
</Button>
</div>
</div>
{/* ── 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>
<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 ──────────────────────────────────────────── */}
<div className="flex gap-1 p-0.5 bg-muted rounded-md w-fit">
{statusOptions.map((opt) => (
<button
key={opt.value}
type="button"
className={cn(
"px-2.5 py-1 text-xs rounded-sm transition-colors",
filterStatus === opt.value
? "bg-background text-foreground shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => setFilterStatus(opt.value)}
>
{opt.label}
</button>
))}
</div>
{/* ── Orders table ────────────────────────────────────────── */}
{loading ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Loader2 className="h-8 w-8 mx-auto mb-3 animate-spin opacity-40" />
<p>Se incarca extrasele...</p>
</CardContent>
</Card>
) : orders.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="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."}
</p>
</CardContent>
</Card>
) : (
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40">
<th className="px-3 py-2 text-left font-medium">
Nr. Cadastral
</th>
<th className="px-3 py-2 text-left font-medium">UAT</th>
<th className="px-3 py-2 text-left font-medium">Status</th>
<th className="px-3 py-2 text-left font-medium">Data</th>
<th className="px-3 py-2 text-left font-medium">Expira</th>
<th className="px-3 py-2 text-right font-medium">
Actiuni
</th>
</tr>
</thead>
<tbody>
{orders.map((order) => {
const badge = statusBadge(order.status, order.expiresAt);
const expired =
order.status === "completed" && isExpired(order.expiresAt);
return (
<tr
key={order.id}
className={cn(
"border-b last:border-0 transition-colors hover:bg-muted/30",
expired && "opacity-70",
)}
>
{/* Nr. Cadastral */}
<td className="px-3 py-2">
<div className="flex flex-col">
<span className="font-medium tabular-nums">
{order.nrCadastral}
</span>
{order.nrCF &&
order.nrCF !== order.nrCadastral && (
<span className="text-[10px] text-muted-foreground">
CF: {order.nrCF}
</span>
)}
{order.version > 1 && (
<span className="text-[10px] text-muted-foreground">
v{order.version}
</span>
)}
</div>
</td>
{/* UAT */}
<td className="px-3 py-2">
<div className="flex flex-col">
<span>{order.uatName}</span>
<span className="text-[10px] text-muted-foreground">
jud. {order.judetName}
</span>
</div>
</td>
{/* Status */}
<td className="px-3 py-2">
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium",
badge.className,
badge.pulse && "animate-pulse",
)}
>
{badge.label}
</span>
{order.errorMessage && (
<p
className="text-[10px] text-destructive mt-0.5 max-w-48 truncate"
title={order.errorMessage}
>
{order.errorMessage}
</p>
)}
</td>
{/* Data */}
<td className="px-3 py-2 text-xs text-muted-foreground tabular-nums">
{formatDate(order.completedAt ?? order.createdAt)}
</td>
{/* Expira */}
<td className="px-3 py-2 text-xs tabular-nums">
{order.expiresAt ? (
<span
className={cn(
expired
? "text-orange-600 dark:text-orange-400"
: "text-muted-foreground",
)}
>
{expired && (
<Clock className="inline h-3 w-3 mr-0.5 -mt-0.5" />
)}
{formatShortDate(order.expiresAt)}
</span>
) : (
<span className="text-muted-foreground">{"\u2014"}</span>
)}
</td>
{/* Actiuni */}
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1">
{order.status === "completed" &&
order.minioPath &&
!expired && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
asChild
>
<a
href={`/api/ancpi/download?id=${order.id}`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-3 w-3 mr-1" />
Descarca
</a>
</Button>
)}
{expired && (
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-xs"
disabled={!epayStatus.connected}
onClick={() => void handleReorder(order)}
>
<RefreshCw className="h-3 w-3 mr-1" />
Actualizeaza
</Button>
)}
{order.status === "completed" &&
order.minioPath &&
expired && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
asChild
>
<a
href={`/api/ancpi/download?id=${order.id}`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-3 w-3 mr-1" />
PDF
</a>
</Button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* ── Active orders indicator ─────────────────────────────── */}
{hasActive && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span>
Se actualizeaza automat la fiecare 10 secunde...
</span>
</div>
)}
</div>
);
}
@@ -55,8 +55,11 @@ 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 } from "lucide-react";
import { User, FileText } from "lucide-react";
import { UatDashboard } from "./uat-dashboard";
import { EpayConnect } from "./epay-connect";
import { EpayOrderButton } from "./epay-order-button";
import { EpayTab } from "./epay-tab";
/* ------------------------------------------------------------------ */
/* Types */
@@ -1660,6 +1663,12 @@ 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)
@@ -1787,7 +1796,9 @@ export function ParcelSyncModule() {
)}
</div>
{/* Connection pill */}
{/* Connection pills */}
<div className="flex items-center gap-2">
<EpayConnect />
<ConnectionPill
session={session}
connecting={connecting}
@@ -1795,6 +1806,7 @@ export function ParcelSyncModule() {
onDisconnect={handleDisconnect}
/>
</div>
</div>
{/* Tab bar */}
<TabsList>
@@ -1814,6 +1826,10 @@ export function ParcelSyncModule() {
<Database className="h-4 w-4" />
Baza de Date
</TabsTrigger>
<TabsTrigger value="extracts" className="gap-1.5">
<FileText className="h-4 w-4" />
Extrase CF
</TabsTrigger>
</TabsList>
</div>
@@ -2082,6 +2098,14 @@ 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 ?? ""}
/>
)}
</div>
</div>
@@ -2332,6 +2356,14 @@ 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 ?? ""}
/>
)}
</div>
</div>
@@ -4133,6 +4165,13 @@ export function ParcelSyncModule() {
</>
)}
</TabsContent>
{/* ═══════════════════════════════════════════════════════ */}
{/* Tab 5: Extrase CF */}
{/* ═══════════════════════════════════════════════════════ */}
<TabsContent value="extracts" className="space-y-4">
<EpayTab />
</TabsContent>
</Tabs>
);
}
+53 -1
View File
@@ -40,8 +40,37 @@ type BatchJob = {
const g = globalThis as {
__epayBatchQueue?: BatchJob[];
__epayQueueProcessing?: boolean;
__epayDedupMap?: Map<string, { timestamp: number; extractIds: string[] }>;
};
if (!g.__epayBatchQueue) g.__epayBatchQueue = [];
if (!g.__epayDedupMap) g.__epayDedupMap = new Map();
/** TTL for dedup entries in milliseconds (60 seconds). */
const DEDUP_TTL_MS = 60_000;
/**
* Build a dedup key from a list of cadastral numbers.
* Sorted and joined so order doesn't matter.
*/
function batchDedupKey(inputs: CfExtractCreateInput[]): string {
return inputs
.map((i) => i.nrCadastral)
.sort()
.join(",");
}
/**
* Remove expired entries from the dedup map.
*/
function cleanupDedupMap(): void {
const now = Date.now();
const map = g.__epayDedupMap!;
for (const [key, entry] of map) {
if (now - entry.timestamp > DEDUP_TTL_MS) {
map.delete(key);
}
}
}
/* ------------------------------------------------------------------ */
/* Public API */
@@ -64,12 +93,27 @@ export async function enqueueOrder(
* Enqueue a batch of CF extract orders.
* Creates all DB records, then processes as ONE ePay order.
* Returns the CfExtract IDs immediately.
*
* Dedup protection: if the same set of cadastral numbers was enqueued
* within the last 60 seconds, returns the existing extract IDs instead
* of creating duplicate DB records and orders.
*/
export async function enqueueBatch(
inputs: CfExtractCreateInput[],
): Promise<string[]> {
if (inputs.length === 0) return [];
// ── Dedup check ──
cleanupDedupMap();
const dedupKey = batchDedupKey(inputs);
const existing = g.__epayDedupMap!.get(dedupKey);
if (existing && Date.now() - existing.timestamp < DEDUP_TTL_MS) {
console.log(
`[epay-queue] Dedup hit: batch [${dedupKey}] was enqueued ${Math.round((Date.now() - existing.timestamp) / 1000)}s ago — returning existing IDs`,
);
return existing.extractIds;
}
const items: QueueItem[] = [];
for (const input of inputs) {
@@ -99,6 +143,14 @@ export async function enqueueBatch(
items.push({ extractId: record.id, input });
}
const extractIds = items.map((i) => i.extractId);
// ── Record in dedup map ──
g.__epayDedupMap!.set(dedupKey, {
timestamp: Date.now(),
extractIds,
});
g.__epayBatchQueue!.push({ items });
console.log(
@@ -108,7 +160,7 @@ export async function enqueueBatch(
// Start processing if not already running
void processQueue();
return items.map((i) => i.extractId);
return extractIds;
}
/**