2b8d144924
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>
453 lines
18 KiB
TypeScript
453 lines
18 KiB
TypeScript
"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>
|
||
);
|
||
}
|