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 runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
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.
|
* POST /api/ancpi/order — create one or more CF extract orders.
|
||||||
*
|
*
|
||||||
* Body: { parcels: CfExtractCreateInput[] }
|
* Body: { parcels: CfExtractCreateInput[], nonce?: string }
|
||||||
* Returns: { orders: [{ id, nrCadastral, status }] }
|
*
|
||||||
|
* 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) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
@@ -27,6 +57,7 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const body = (await req.json()) as {
|
const body = (await req.json()) as {
|
||||||
parcels?: CfExtractCreateInput[];
|
parcels?: CfExtractCreateInput[];
|
||||||
|
nonce?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parcels = body.parcels ?? [];
|
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
|
// Validate required fields
|
||||||
for (const p of parcels) {
|
for (const p of parcels) {
|
||||||
if (!p.nrCadastral || p.judetIndex == null || p.uatId == null) {
|
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) {
|
if (parcels.length === 1) {
|
||||||
const id = await enqueueOrder(parcels[0]!);
|
const id = await enqueueOrder(parcels[0]!);
|
||||||
return NextResponse.json({
|
responseBody = {
|
||||||
orders: [
|
orders: [
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@@ -59,17 +109,27 @@ export async function POST(req: Request) {
|
|||||||
status: "queued",
|
status: "queued",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const ids = await enqueueBatch(parcels);
|
||||||
|
responseBody = {
|
||||||
|
orders: ids.map((id, i) => ({
|
||||||
|
id,
|
||||||
|
nrCadastral: parcels[i]?.nrCadastral ?? "",
|
||||||
|
status: "queued",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cache response for nonce ──
|
||||||
|
if (body.nonce) {
|
||||||
|
gNonce.__orderNonceMap!.set(body.nonce, {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
response: responseBody,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const ids = await enqueueBatch(parcels);
|
return NextResponse.json(responseBody);
|
||||||
const orders = ids.map((id, i) => ({
|
|
||||||
id,
|
|
||||||
nrCadastral: parcels[i]?.nrCadastral ?? "",
|
|
||||||
status: "queued",
|
|
||||||
}));
|
|
||||||
|
|
||||||
return NextResponse.json({ orders });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Eroare server";
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
|||||||
@@ -11,6 +11,23 @@ import { prisma } from "@/core/storage/prisma";
|
|||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
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
|
* 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!)
|
// ── order ── Batch order test (USES 2 CREDITS!)
|
||||||
// Uses enqueueBatch to create ONE ePay order for all parcels
|
// Uses enqueueBatch to create ONE ePay order for all parcels
|
||||||
if (step === "order") {
|
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()) {
|
if (!getEpayCredentials()) {
|
||||||
createEpaySession(username, password, 0);
|
createEpaySession(username, password, 0);
|
||||||
}
|
}
|
||||||
@@ -332,17 +367,26 @@ export async function GET(req: Request) {
|
|||||||
// Use enqueueBatch — ONE order for all parcels
|
// Use enqueueBatch — ONE order for all parcels
|
||||||
const ids = await enqueueBatch(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({
|
return NextResponse.json({
|
||||||
step: "order",
|
step: "order",
|
||||||
credits,
|
credits,
|
||||||
message: `Enqueued batch of ${ids.length} parcels as ONE order.`,
|
message: `Enqueued batch of ${ids.length} parcels as ONE order.`,
|
||||||
extractIds: ids,
|
extractIds: ids,
|
||||||
parcels: parcels.map((p, i) => ({
|
parcels: parcelResults,
|
||||||
nrCadastral: p.nrCadastral,
|
|
||||||
uatName: p.uatName,
|
|
||||||
siruta: p.siruta,
|
|
||||||
extractId: ids[i],
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
} from "../services/eterra-layers";
|
||||||
import type { ParcelDetail } from "@/app/api/eterra/search/route";
|
import type { ParcelDetail } from "@/app/api/eterra/search/route";
|
||||||
import type { OwnerSearchResult } from "@/app/api/eterra/search-owner/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 { UatDashboard } from "./uat-dashboard";
|
||||||
|
import { EpayConnect } from "./epay-connect";
|
||||||
|
import { EpayOrderButton } from "./epay-order-button";
|
||||||
|
import { EpayTab } from "./epay-tab";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Types */
|
/* Types */
|
||||||
@@ -1660,6 +1663,12 @@ export function ParcelSyncModule() {
|
|||||||
|
|
||||||
const sirutaValid = siruta.length > 0 && /^\d+$/.test(siruta);
|
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 =
|
const progressPct =
|
||||||
exportProgress?.total && exportProgress.total > 0
|
exportProgress?.total && exportProgress.total > 0
|
||||||
? Math.round((exportProgress.downloaded / exportProgress.total) * 100)
|
? Math.round((exportProgress.downloaded / exportProgress.total) * 100)
|
||||||
@@ -1787,13 +1796,16 @@ export function ParcelSyncModule() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Connection pill */}
|
{/* Connection pills */}
|
||||||
<ConnectionPill
|
<div className="flex items-center gap-2">
|
||||||
session={session}
|
<EpayConnect />
|
||||||
connecting={connecting}
|
<ConnectionPill
|
||||||
connectionError={connectionError}
|
session={session}
|
||||||
onDisconnect={handleDisconnect}
|
connecting={connecting}
|
||||||
/>
|
connectionError={connectionError}
|
||||||
|
onDisconnect={handleDisconnect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab bar */}
|
{/* Tab bar */}
|
||||||
@@ -1814,6 +1826,10 @@ export function ParcelSyncModule() {
|
|||||||
<Database className="h-4 w-4" />
|
<Database className="h-4 w-4" />
|
||||||
Baza de Date
|
Baza de Date
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="extracts" className="gap-1.5">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
Extrase CF
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2082,6 +2098,14 @@ export function ParcelSyncModule() {
|
|||||||
>
|
>
|
||||||
<ClipboardCopy className="h-3.5 w-3.5" />
|
<ClipboardCopy className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{p.immovablePk && sirutaValid && (
|
||||||
|
<EpayOrderButton
|
||||||
|
nrCadastral={p.nrCad}
|
||||||
|
siruta={siruta}
|
||||||
|
judetName={selectedUat?.county ?? ""}
|
||||||
|
uatName={selectedUat?.name ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2332,6 +2356,14 @@ export function ParcelSyncModule() {
|
|||||||
>
|
>
|
||||||
<ClipboardCopy className="h-3.5 w-3.5" />
|
<ClipboardCopy className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{r.immovablePk && sirutaValid && (
|
||||||
|
<EpayOrderButton
|
||||||
|
nrCadastral={r.nrCad}
|
||||||
|
siruta={siruta}
|
||||||
|
judetName={selectedUat?.county ?? ""}
|
||||||
|
uatName={selectedUat?.name ?? ""}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -4133,6 +4165,13 @@ export function ParcelSyncModule() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ═══════════════════════════════════════════════════════ */}
|
||||||
|
{/* Tab 5: Extrase CF */}
|
||||||
|
{/* ═══════════════════════════════════════════════════════ */}
|
||||||
|
<TabsContent value="extracts" className="space-y-4">
|
||||||
|
<EpayTab />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,8 +40,37 @@ type BatchJob = {
|
|||||||
const g = globalThis as {
|
const g = globalThis as {
|
||||||
__epayBatchQueue?: BatchJob[];
|
__epayBatchQueue?: BatchJob[];
|
||||||
__epayQueueProcessing?: boolean;
|
__epayQueueProcessing?: boolean;
|
||||||
|
__epayDedupMap?: Map<string, { timestamp: number; extractIds: string[] }>;
|
||||||
};
|
};
|
||||||
if (!g.__epayBatchQueue) g.__epayBatchQueue = [];
|
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 */
|
/* Public API */
|
||||||
@@ -64,12 +93,27 @@ export async function enqueueOrder(
|
|||||||
* Enqueue a batch of CF extract orders.
|
* Enqueue a batch of CF extract orders.
|
||||||
* Creates all DB records, then processes as ONE ePay order.
|
* Creates all DB records, then processes as ONE ePay order.
|
||||||
* Returns the CfExtract IDs immediately.
|
* 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(
|
export async function enqueueBatch(
|
||||||
inputs: CfExtractCreateInput[],
|
inputs: CfExtractCreateInput[],
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
if (inputs.length === 0) return [];
|
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[] = [];
|
const items: QueueItem[] = [];
|
||||||
|
|
||||||
for (const input of inputs) {
|
for (const input of inputs) {
|
||||||
@@ -99,6 +143,14 @@ export async function enqueueBatch(
|
|||||||
items.push({ extractId: record.id, input });
|
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 });
|
g.__epayBatchQueue!.push({ items });
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
@@ -108,7 +160,7 @@ export async function enqueueBatch(
|
|||||||
// Start processing if not already running
|
// Start processing if not already running
|
||||||
void processQueue();
|
void processQueue();
|
||||||
|
|
||||||
return items.map((i) => i.extractId);
|
return extractIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user