feat(ancpi): complete ePay UI + dedup protection

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-23 04:19:19 +02:00
parent fcc6f8cc20
commit c9ecd284c7
7 changed files with 1221 additions and 26 deletions
@@ -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>
);
}