fix(ancpi): UAT debounce + list tooltips + expired download + ePay retry

1. UAT search: 150ms debounce prevents slow re-renders on keystroke
2. Lista mea tooltips: "Scoate Extrase CF" shows exact credit cost,
   status badges show expiry dates and clear instructions
3. Expired extracts: both Descarcă (old version) + Actualizează shown
4. ePay auto-connect: retry 2x with 3s delay, check session before
   connect, re-attempt on disconnect detection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-23 12:14:34 +02:00
parent 5a6ab36aa7
commit 62777e9778
3 changed files with 216 additions and 92 deletions
@@ -47,6 +47,7 @@ export function EpayConnect({
const cbRef = useRef(onStatusChange);
cbRef.current = onStatusChange;
const autoConnectAttempted = useRef(false);
const autoConnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchStatus = useCallback(async () => {
try {
@@ -55,15 +56,23 @@ export function EpayConnect({
setStatus(data);
cbRef.current?.(data);
if (data.connected) setError("");
return data;
} catch {
/* silent */
return null;
}
}, []);
// Poll every 30s
// Poll every 30s — detect disconnection and allow re-connect
useEffect(() => {
void fetchStatus();
pollRef.current = setInterval(() => void fetchStatus(), 30_000);
pollRef.current = setInterval(() => {
void fetchStatus().then((data) => {
if (data && !data.connected && autoConnectAttempted.current) {
autoConnectAttempted.current = false;
}
});
}, 30_000);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
@@ -92,13 +101,70 @@ export function EpayConnect({
}
}, [connecting, status.connected, fetchStatus]);
// Auto-connect when triggerConnect becomes true
// Auto-connect when triggerConnect becomes true, with retry on failure
useEffect(() => {
if (triggerConnect && !status.connected && !connecting && !autoConnectAttempted.current) {
autoConnectAttempted.current = true;
void connect();
}
}, [triggerConnect, status.connected, connecting, connect]);
if (!triggerConnect || status.connected || connecting || autoConnectAttempted.current) return;
autoConnectAttempted.current = true;
let cancelled = false;
const maxRetries = 2;
const attemptConnect = async (attempt: number) => {
if (cancelled) return;
// On first attempt, check session to avoid unnecessary connect
if (attempt === 0) {
const current = await fetchStatus();
if (cancelled) return;
if (current?.connected) return;
}
setConnecting(true);
setError("");
let shouldRetry = false;
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 (cancelled) return;
if (!res.ok || data.error) {
setError(data.error ?? "Eroare conectare ePay");
shouldRetry = attempt < maxRetries;
} else {
await fetchStatus();
}
} catch {
if (cancelled) return;
setError("Eroare retea");
shouldRetry = attempt < maxRetries;
}
if (cancelled) return;
if (shouldRetry) {
// Keep connecting state true during retry wait
autoConnectTimerRef.current = setTimeout(() => {
void attemptConnect(attempt + 1);
}, 3000);
} else {
setConnecting(false);
}
};
void attemptConnect(0);
return () => {
cancelled = true;
if (autoConnectTimerRef.current) {
clearTimeout(autoConnectTimerRef.current);
autoConnectTimerRef.current = null;
}
};
}, [triggerConnect, status.connected, connecting, fetchStatus]);
const disconnect = async () => {
try {
@@ -108,6 +174,10 @@ export function EpayConnect({
body: JSON.stringify({ action: "disconnect" }),
});
autoConnectAttempted.current = false;
if (autoConnectTimerRef.current) {
clearTimeout(autoConnectTimerRef.current);
autoConnectTimerRef.current = null;
}
await fetchStatus();
} catch {
/* silent */
+26 -19
View File
@@ -750,6 +750,32 @@ export function EpayTab() {
</Tooltip>
</TooltipProvider>
)}
{expired && order.status === "completed" && order.minioPath && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<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" />
Descarca
</a>
</Button>
</TooltipTrigger>
<TooltipContent>
{`Descarca versiunea expirata (${formatShortDate(order.expiresAt)})`}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{expired && (
<TooltipProvider>
<Tooltip>
@@ -774,25 +800,6 @@ export function EpayTab() {
</Tooltip>
</TooltipProvider>
)}
{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>
@@ -621,27 +621,30 @@ export function ParcelSyncModule() {
setUatResults([]);
return;
}
const isDigit = /^\d+$/.test(raw);
const query = normalizeText(raw);
// Filter and sort: UAT name matches first, then county-only matches
const nameMatches: typeof uatData = [];
const countyOnlyMatches: typeof uatData = [];
const timer = setTimeout(() => {
const isDigit = /^\d+$/.test(raw);
const query = normalizeText(raw);
// Filter and sort: UAT name matches first, then county-only matches
const nameMatches: typeof uatData = [];
const countyOnlyMatches: typeof uatData = [];
for (const item of uatData) {
if (isDigit) {
if (item.siruta.startsWith(raw)) nameMatches.push(item);
} else {
const nameMatch = normalizeText(item.name).includes(query);
const countyMatch =
item.county && normalizeText(item.county).includes(query);
if (nameMatch) nameMatches.push(item);
else if (countyMatch) countyOnlyMatches.push(item);
for (const item of uatData) {
if (isDigit) {
if (item.siruta.startsWith(raw)) nameMatches.push(item);
} else {
const nameMatch = normalizeText(item.name).includes(query);
const countyMatch =
item.county && normalizeText(item.county).includes(query);
if (nameMatch) nameMatches.push(item);
else if (countyMatch) countyOnlyMatches.push(item);
}
}
}
// UAT name matches first (priority), then county-only matches
const results = [...nameMatches, ...countyOnlyMatches].slice(0, 12);
setUatResults(results);
// UAT name matches first (priority), then county-only matches
const results = [...nameMatches, ...countyOnlyMatches].slice(0, 12);
setUatResults(results);
}, 150);
return () => clearTimeout(timer);
}, [uatQuery, uatData]);
/* ════════════════════════════════════════════════════════════ */
@@ -2925,37 +2928,65 @@ export function ParcelSyncModule() {
CSV din lista
</Button>
{/* Download all valid CF extracts as ZIP */}
{searchList.some((p) => cfStatusMap[p.nrCad] === "valid") && (
<Button
size="sm"
variant="outline"
className="border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-400"
disabled={listCfDownloading}
onClick={() => void handleListCfDownloadZip()}
>
{listCfDownloading ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Archive className="mr-1 h-3.5 w-3.5" />
)}
Descarca Extrase CF
</Button>
)}
{searchList.some((p) => cfStatusMap[p.nrCad] === "valid") && (() => {
const validCount = searchList.filter((p) => cfStatusMap[p.nrCad] === "valid").length;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-400"
disabled={listCfDownloading}
onClick={() => void handleListCfDownloadZip()}
>
{listCfDownloading ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Archive className="mr-1 h-3.5 w-3.5" />
)}
Descarca Extrase CF
</Button>
</TooltipTrigger>
<TooltipContent>{`Descarca ZIP cu ${validCount} extrase valide din lista`}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
})()}
{/* Order CF extracts for list */}
{epayStatus.connected && (
<Button
size="sm"
disabled={listCfOrdering}
onClick={() => void handleListCfOrder()}
>
{listCfOrdering ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<FileText className="mr-1 h-3.5 w-3.5" />
)}
Scoate Extrase CF
</Button>
)}
{epayStatus.connected && (() => {
const newCount = searchList.filter((p) => {
const s = cfStatusMap[p.nrCad];
return s !== "valid" && s !== "expired" && s !== "processing";
}).length;
const updateCount = searchList.filter((p) => cfStatusMap[p.nrCad] === "expired").length;
const totalCredits = newCount + updateCount;
const validCount = searchList.filter((p) => cfStatusMap[p.nrCad] === "valid").length;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
disabled={listCfOrdering}
onClick={() => void handleListCfOrder()}
>
{listCfOrdering ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<FileText className="mr-1 h-3.5 w-3.5" />
)}
Scoate Extrase CF
</Button>
</TooltipTrigger>
<TooltipContent>
{`Comanda ${newCount} extrase noi + ${updateCount} actualizari = ${totalCredits} credite. ${validCount} existente valide raman.`}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
})()}
</div>
</div>
@@ -2999,6 +3030,7 @@ export function ParcelSyncModule() {
<tbody>
{searchList.map((p, idx) => {
const cfStatus = cfStatusMap[p.nrCad];
const cfExpiry = cfExpiryDates[p.nrCad];
return (
<tr
key={`list-${p.nrCad}-${p.immovablePk}`}
@@ -3022,23 +3054,38 @@ export function ParcelSyncModule() {
{p.proprietari || "\u2014"}
</td>
<td className="px-3 py-2 text-center">
{cfStatus === "valid" ? (
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-400 dark:border-emerald-800">
Valid
</span>
) : cfStatus === "expired" ? (
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950/40 dark:text-orange-400 dark:border-orange-800">
Expirat
</span>
) : cfStatus === "processing" ? (
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-yellow-100 text-yellow-700 border-yellow-200 dark:bg-yellow-950/40 dark:text-yellow-400 dark:border-yellow-800 animate-pulse">
Procesare
</span>
) : (
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-muted text-muted-foreground border-muted-foreground/20">
Lipsa
</span>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{cfStatus === "valid" ? (
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-400 dark:border-emerald-800 cursor-default">
Valid
</span>
) : cfStatus === "expired" ? (
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950/40 dark:text-orange-400 dark:border-orange-800 cursor-default">
Expirat
</span>
) : cfStatus === "processing" ? (
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-yellow-100 text-yellow-700 border-yellow-200 dark:bg-yellow-950/40 dark:text-yellow-400 dark:border-yellow-800 animate-pulse cursor-default">
Procesare
</span>
) : (
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-muted text-muted-foreground border-muted-foreground/20 cursor-default">
Lipsa
</span>
)}
</TooltipTrigger>
<TooltipContent>
{cfStatus === "valid"
? (cfExpiry ? `Extras CF valid pana la ${formatShortDate(cfExpiry)}` : "Extras CF valid")
: cfStatus === "expired"
? (cfExpiry ? `Extras CF expirat pe ${formatShortDate(cfExpiry)}. Va fi actualizat automat la 'Scoate Extrase CF'.` : "Extras CF expirat. Va fi actualizat automat la 'Scoate Extrase CF'.")
: cfStatus === "processing"
? "Comanda in curs de procesare"
: "Nu exista extras CF. Apasa 'Scoate Extrase CF' pentru a comanda."}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</td>
<td className="px-3 py-2">
<Button