Files
ArchiTools/src/modules/parcel-sync/components/epay-connect.tsx
T
AI Assistant b87c908415 fix(parcel-sync): static connection dots, legend position, mismatch labels
- ePay + eTerra pills: removed animate-ping, now show static green dot
  when connected (no more spinning appearance)
- Legend moved to top-left, hides when FeatureInfoPanel is open
  (no more overlap)
- Boundary mismatch parcels now show cadastral numbers as labels
  (orange for foreign, purple for edge parcels)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:39:01 +02:00

251 lines
7.8 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import {
Loader2,
LogOut,
CreditCard,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Badge } from "@/shared/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
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,
triggerConnect,
}: {
onStatusChange?: (status: EpaySessionStatus) => void;
/** When set to true, triggers auto-connect (e.g. when user types UAT) */
triggerConnect?: boolean;
}) {
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 autoConnectAttempted = useRef(false);
const autoConnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
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("");
return data;
} catch {
/* silent */
return null;
}
}, []);
// Poll every 30s — detect disconnection and allow re-connect
useEffect(() => {
void fetchStatus();
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);
};
}, [fetchStatus]);
const connect = useCallback(async () => {
if (connecting || status.connected) return;
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 retea");
} finally {
setConnecting(false);
}
}, [connecting, status.connected, fetchStatus]);
// Auto-connect when triggerConnect becomes true, with retry on failure
useEffect(() => {
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 {
await fetch("/api/ancpi/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "disconnect" }),
});
autoConnectAttempted.current = false;
if (autoConnectTimerRef.current) {
clearTimeout(autoConnectTimerRef.current);
autoConnectTimerRef.current = null;
}
await fetchStatus();
} catch {
/* silent */
}
};
return (
<div className="flex items-center gap-1">
{/* Status pill */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<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"
: connecting
? "border-muted-foreground/20 bg-muted/50 text-muted-foreground"
: "border-muted-foreground/20 bg-muted/50 text-muted-foreground",
)}
>
{connecting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : status.connected ? (
<span className="inline-flex h-2 w-2 rounded-full bg-emerald-500" />
) : null}
<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>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{status.connected
? `${status.credits ?? "?"} credite ePay disponibile`
: error
? error
: connecting
? "Se conecteaza..."
: "Se conecteaza automat la selectia UAT"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Logout button — only when connected */}
{status.connected && (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[10px]"
onClick={() => void disconnect()}
title="Deconectare ePay"
>
<LogOut className="h-3 w-3" />
</Button>
)}
</div>
);
}