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