Files
ArchiTools/src/modules/parcel-sync/components/parcel-sync-module.tsx
T
AI Assistant 2b8d144924 fix(parcel-sync): replace Unicode escapes with actual Romanian diacritics
The \u0103, \u00ee etc. escape sequences were rendering literally in JSX
text nodes instead of displaying ă, î, ț, ș characters.

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

453 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useCallback, useRef, useDeferredValue } from "react";
import {
Search,
Download,
Layers,
MapPin,
Database,
FileText,
Map as MapIcon,
} from "lucide-react";
import { Input } from "@/shared/components/ui/input";
import { Badge } from "@/shared/components/ui/badge";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/shared/components/ui/tabs";
import type {
SessionStatus,
UatEntry,
SyncRunInfo,
DbSummary,
} from "./parcel-sync-types";
import { normalizeText } from "./parcel-sync-types";
import { ConnectionPill } from "./connection-pill";
import { EpayConnect, type EpaySessionStatus } from "./epay-connect";
import { SearchTab } from "./tabs/search-tab";
import { LayersTab } from "./tabs/layers-tab";
import { ExportTab } from "./tabs/export-tab";
import { DatabaseTab } from "./tabs/database-tab";
import { CfTab } from "./tabs/cf-tab";
import { MapTab } from "./tabs/map-tab";
/* ------------------------------------------------------------------ */
/* Main Component */
/* ------------------------------------------------------------------ */
export function ParcelSyncModule() {
/* ── Server session ─────────────────────────────────────────── */
const [session, setSession] = useState<SessionStatus>({
connected: false,
activeJobCount: 0,
});
const [connecting, setConnecting] = useState(false);
const [connectionError, setConnectionError] = useState("");
const autoConnectAttempted = useRef(false);
const sessionPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
/* ── UAT autocomplete ───────────────────────────────────────── */
const [uatData, setUatData] = useState<UatEntry[]>([]);
const [uatQuery, setUatQuery] = useState("");
const [uatResults, setUatResults] = useState<UatEntry[]>([]);
const [showUatResults, setShowUatResults] = useState(false);
const [siruta, setSiruta] = useState("");
const [workspacePk, setWorkspacePk] = useState<number | null>(null);
const uatRef = useRef<HTMLDivElement>(null);
/* ── Sync status (shared between layers + export tabs) ──────── */
const [syncLocalCounts, setSyncLocalCounts] = useState<Record<string, number>>({});
const [syncRuns, setSyncRuns] = useState<SyncRunInfo[]>([]);
const [syncingSiruta, setSyncingSiruta] = useState("");
/* ── Global DB summary ──────────────────────────────────────── */
const [dbSummary, setDbSummary] = useState<DbSummary | null>(null);
const [dbSummaryLoading, setDbSummaryLoading] = useState(false);
/* ── Export state (shared flag) ─────────────────────────────── */
const [exporting, setExporting] = useState(false);
/* ── ePay status ────────────────────────────────────────────── */
const [epayStatus, setEpayStatus] = useState<EpaySessionStatus>({ connected: false });
/* ── Derived ────────────────────────────────────────────────── */
const sirutaValid = siruta.length > 0 && /^\d+$/.test(siruta);
const selectedUat = uatData.find((u) => u.siruta === siruta);
/* ════════════════════════════════════════════════════════════ */
/* Session management */
/* ════════════════════════════════════════════════════════════ */
const fetchSession = useCallback(async () => {
try {
const res = await fetch("/api/eterra/session");
const data = (await res.json()) as SessionStatus;
setSession((prev) => {
if (prev.eterraMaintenance && data.eterraAvailable && !data.eterraMaintenance) {
autoConnectAttempted.current = false;
}
return data;
});
if (data.connected) setConnectionError("");
return data;
} catch {
return null;
}
}, []);
useEffect(() => {
// Load UATs from local DB
fetch("/api/eterra/uats")
.then((res) => res.json())
.then((data: { uats?: UatEntry[]; total?: number }) => {
if (data.uats && data.uats.length > 0) {
setUatData(data.uats);
} else {
fetch("/api/eterra/uats", { method: "POST" }).catch(() => {});
fetch("/uat.json")
.then((res) => res.json())
.then((fallback: UatEntry[]) => setUatData(fallback))
.catch(() => {});
}
})
.catch(() => {
fetch("/uat.json")
.then((res) => res.json())
.then((fallback: UatEntry[]) => setUatData(fallback))
.catch(() => {});
});
void fetchSession();
sessionPollRef.current = setInterval(() => void fetchSession(), 30_000);
return () => {
if (sessionPollRef.current) clearInterval(sessionPollRef.current);
};
}, [fetchSession]);
/* ── Reload UATs when session connects ─────────────────────── */
const prevConnected = useRef(false);
useEffect(() => {
if (session.connected && !prevConnected.current) {
const timer = setTimeout(() => {
fetch("/api/eterra/uats")
.then((res) => res.json())
.then((data: { uats?: UatEntry[] }) => {
if (data.uats && data.uats.length > 0) setUatData(data.uats);
})
.catch(() => {});
}, 5000);
return () => clearTimeout(timer);
}
prevConnected.current = session.connected;
}, [session.connected]);
/* ── UAT autocomplete filter ───────────────────────────────── */
const deferredUatQuery = useDeferredValue(uatQuery);
useEffect(() => {
const raw = deferredUatQuery.trim();
if (raw.length < 2) {
setUatResults([]);
return;
}
const isDigit = /^\d+$/.test(raw);
const query = normalizeText(raw);
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);
}
}
setUatResults([...nameMatches, ...countyOnlyMatches].slice(0, 12));
}, [deferredUatQuery, uatData]);
/* ── Auto-connect on first UAT keystroke ───────────────────── */
const triggerAutoConnect = useCallback(async () => {
if (session.connected || connecting || autoConnectAttempted.current) return;
if (session.eterraMaintenance) return;
autoConnectAttempted.current = true;
setConnecting(true);
setConnectionError("");
try {
const res = await fetch("/api/eterra/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "connect" }),
});
const data = (await res.json()) as {
success?: boolean;
error?: string;
maintenance?: boolean;
};
if (data.success) {
await fetchSession();
} else if (data.maintenance) {
setSession((prev) => ({
...prev,
eterraMaintenance: true,
eterraAvailable: false,
eterraHealthMessage: data.error ?? "eTerra în mentenanță",
}));
autoConnectAttempted.current = false;
} else {
setConnectionError(data.error ?? "Eroare conectare");
}
} catch {
setConnectionError("Eroare rețea");
}
setConnecting(false);
}, [session.connected, session.eterraMaintenance, connecting, fetchSession]);
/* ── Disconnect ────────────────────────────────────────────── */
const handleDisconnect = useCallback(async () => {
try {
const res = await fetch("/api/eterra/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "disconnect" }),
});
const data = (await res.json()) as { success?: boolean; error?: string };
if (data.success) {
setSession({ connected: false, activeJobCount: 0 });
autoConnectAttempted.current = false;
} else {
setConnectionError(data.error ?? "Nu se poate deconecta");
}
} catch {
setConnectionError("Eroare rețea");
}
}, []);
/* ── Sync status ───────────────────────────────────────────── */
const fetchSyncStatus = useCallback(async () => {
if (!siruta) return;
try {
const res = await fetch(`/api/eterra/sync-status?siruta=${siruta}`);
const data = (await res.json()) as {
localCounts?: Record<string, number>;
runs?: SyncRunInfo[];
};
if (data.localCounts) setSyncLocalCounts(data.localCounts);
if (data.runs) setSyncRuns(data.runs);
setSyncingSiruta(siruta);
} catch {
// silent
}
}, [siruta]);
useEffect(() => {
if (siruta && /^\d+$/.test(siruta)) {
void fetchSyncStatus();
}
}, [siruta, fetchSyncStatus]);
/* ── DB summary ────────────────────────────────────────────── */
const fetchDbSummary = useCallback(async () => {
setDbSummaryLoading(true);
try {
const res = await fetch("/api/eterra/db-summary");
const data = (await res.json()) as DbSummary;
if (data.uats) setDbSummary(data);
} catch {
// silent
}
setDbSummaryLoading(false);
}, []);
useEffect(() => {
void fetchDbSummary();
}, [fetchDbSummary]);
/* ════════════════════════════════════════════════════════════ */
/* Render */
/* ════════════════════════════════════════════════════════════ */
return (
<Tabs defaultValue="search" className="space-y-4">
{/* ═══════════════════════ Persistent header ═══════════════ */}
<div className="space-y-3">
{/* UAT + Connection row */}
<div className="flex items-start gap-3">
{/* UAT autocomplete */}
<div className="relative flex-1 min-w-0" ref={uatRef}>
<div className="relative">
<MapPin className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
id="uat-search"
placeholder="Selectează UAT — scrie nume sau cod SIRUTA…"
value={uatQuery}
onChange={(e) => {
setUatQuery(e.target.value);
setShowUatResults(true);
if (e.target.value.trim().length >= 1) {
void triggerAutoConnect();
}
}}
onFocus={() => setShowUatResults(true)}
onBlur={() => setTimeout(() => setShowUatResults(false), 150)}
className="pl-9 font-medium"
autoComplete="off"
/>
</div>
{sirutaValid && (
<div className="absolute right-2 top-1.5">
<Badge variant="outline" className="text-[10px] font-mono bg-background">
SIRUTA {siruta}
</Badge>
</div>
)}
{showUatResults && uatResults.length > 0 && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-60 overflow-auto">
{uatResults.map((item) => (
<button
key={item.siruta}
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
onMouseDown={(e) => {
e.preventDefault();
const label = item.county
? `${item.name} (${item.siruta}), jud. ${item.county}`
: `${item.name} (${item.siruta})`;
setUatQuery(label);
setSiruta(item.siruta);
setWorkspacePk(item.workspacePk ?? null);
setShowUatResults(false);
}}
>
<span className="flex items-center gap-1.5 min-w-0 flex-wrap">
<span className="font-medium">{item.name}</span>
<span className="text-muted-foreground">({item.siruta})</span>
{item.county && (
<span className="text-muted-foreground">
{" "}
<span className="font-medium text-foreground/70">
jud. {item.county}
</span>
</span>
)}
{(item.localFeatures ?? 0) > 0 && (
<span className="inline-flex items-center rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400 shrink-0">
{(item.localFeatures ?? 0).toLocaleString("ro")} local
</span>
)}
</span>
</button>
))}
</div>
)}
</div>
{/* Connection pills */}
<div className="flex items-center gap-2">
<EpayConnect triggerConnect={sirutaValid} onStatusChange={setEpayStatus} />
<ConnectionPill
session={session}
connecting={connecting}
connectionError={connectionError}
onDisconnect={handleDisconnect}
/>
</div>
</div>
{/* Tab bar */}
<TabsList>
<TabsTrigger value="search" className="gap-1.5">
<Search className="h-4 w-4" />
Căutare Parcele
</TabsTrigger>
<TabsTrigger value="layers" className="gap-1.5">
<Layers className="h-4 w-4" />
Catalog Layere
</TabsTrigger>
<TabsTrigger value="export" className="gap-1.5">
<Download className="h-4 w-4" />
Export
</TabsTrigger>
<TabsTrigger value="database" className="gap-1.5">
<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>
<TabsTrigger value="map" className="gap-1.5">
<MapIcon className="h-4 w-4" />
Harta
</TabsTrigger>
</TabsList>
</div>
{/* ═══════════════════════ Tab content ═════════════════════ */}
<TabsContent value="search" className="space-y-4">
<SearchTab
siruta={siruta}
workspacePk={workspacePk}
sirutaValid={sirutaValid}
session={session}
selectedUat={selectedUat}
epayStatus={epayStatus}
/>
</TabsContent>
<TabsContent value="layers" className="space-y-4">
<LayersTab
siruta={siruta}
sirutaValid={sirutaValid}
session={session}
syncLocalCounts={syncLocalCounts}
syncRuns={syncRuns}
syncingSiruta={syncingSiruta}
onSyncRefresh={() => void fetchSyncStatus()}
exporting={exporting}
/>
</TabsContent>
<TabsContent value="export" className="space-y-4">
<ExportTab
siruta={siruta}
workspacePk={workspacePk}
sirutaValid={sirutaValid}
session={session}
syncLocalCounts={syncLocalCounts}
syncRuns={syncRuns}
syncingSiruta={syncingSiruta}
onSyncRefresh={() => void fetchSyncStatus()}
onDbRefresh={() => void fetchDbSummary()}
exporting={exporting}
setExporting={setExporting}
/>
</TabsContent>
<TabsContent value="database" className="space-y-3">
<DatabaseTab
siruta={siruta}
sirutaValid={sirutaValid}
dbSummary={dbSummary}
dbSummaryLoading={dbSummaryLoading}
onRefresh={() => void fetchDbSummary()}
/>
</TabsContent>
<TabsContent value="extracts" className="space-y-4">
<CfTab />
</TabsContent>
<TabsContent value="map" className="space-y-4">
<MapTab siruta={siruta} sirutaValid={sirutaValid} />
</TabsContent>
</Tabs>
);
}