"use client"; import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Search, Download, CheckCircle2, XCircle, Loader2, MapPin, Layers, Sparkles, ChevronDown, ChevronUp, FileDown, LogOut, Wifi, WifiOff, ClipboardCopy, Trash2, Plus, RefreshCw, Database, HardDrive, Clock, ArrowDownToLine, } from "lucide-react"; import { Button } from "@/shared/components/ui/button"; import { Input } from "@/shared/components/ui/input"; import { Label } from "@/shared/components/ui/label"; import { Badge } from "@/shared/components/ui/badge"; import { Card, CardContent } from "@/shared/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "@/shared/components/ui/tabs"; import { DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/components/ui/dropdown-menu"; import { cn } from "@/shared/lib/utils"; import { LAYER_CATALOG, LAYER_CATEGORY_LABELS, findLayerById, type LayerCategory, type LayerCatalogItem, } from "../services/eterra-layers"; import type { ParcelDetail } from "@/app/api/eterra/search/route"; /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ type UatEntry = { siruta: string; name: string; county?: string; workspacePk?: number; }; type SessionStatus = { connected: boolean; username?: string; connectedAt?: string; activeJobCount: number; activeJobPhase?: string; }; type ExportProgress = { jobId: string; downloaded: number; total?: number; status: "running" | "done" | "error" | "unknown"; phase?: string; message?: string; note?: string; phaseCurrent?: number; phaseTotal?: number; }; /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ const normalizeText = (text: string) => text .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase() .trim(); function formatDate(iso?: string | null) { if (!iso) return "—"; return new Date(iso).toLocaleDateString("ro-RO", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", }); } function formatArea(val?: number | null) { if (val == null) return "—"; return val.toLocaleString("ro-RO", { maximumFractionDigits: 2 }) + " mp"; } /* ------------------------------------------------------------------ */ /* Connection Status Pill */ /* ------------------------------------------------------------------ */ function ConnectionPill({ session, connecting, connectionError, onDisconnect, }: { session: SessionStatus; connecting: boolean; connectionError: string; onDisconnect: () => void; }) { const elapsed = session.connectedAt ? Math.floor( (Date.now() - new Date(session.connectedAt).getTime()) / 60_000, ) : 0; const elapsedLabel = elapsed < 1 ? "acum" : elapsed < 60 ? `${elapsed} min` : `${Math.floor(elapsed / 60)}h ${elapsed % 60}m`; return ( {/* Status header */}
Conexiune eTerra {session.connected && ( {elapsedLabel} )}
{session.connected && session.username && (

{session.username}

)} {connectionError && (

{connectionError}

)}
{/* Info when not connected */} {!session.connected && !connectionError && (

Conexiunea se face automat când începi să scrii un UAT.

Credențialele sunt preluate din configurarea serverului.

)} {/* Error detail */} {!session.connected && connectionError && (

Conexiunea automată a eșuat. Verifică credențialele din variabilele de mediu (ETERRA_USERNAME / ETERRA_PASSWORD).

)} {/* Connected — active jobs info + disconnect */} {session.connected && ( <> {session.activeJobCount > 0 && (

{session.activeJobCount} job {session.activeJobCount > 1 ? "-uri" : ""} activ {session.activeJobCount > 1 ? "e" : ""} {session.activeJobPhase && ( {" "} — {session.activeJobPhase} )}

)}
)}
); } /* ------------------------------------------------------------------ */ /* Main Component */ /* ------------------------------------------------------------------ */ export function ParcelSyncModule() { /* ── Server session ─────────────────────────────────────────── */ const [session, setSession] = useState({ connected: false, activeJobCount: 0, }); const [connecting, setConnecting] = useState(false); const [connectionError, setConnectionError] = useState(""); const autoConnectAttempted = useRef(false); const sessionPollRef = useRef | null>(null); /* ── UAT autocomplete ───────────────────────────────────────── */ const [uatData, setUatData] = useState([]); const [uatQuery, setUatQuery] = useState(""); const [uatResults, setUatResults] = useState([]); const [showUatResults, setShowUatResults] = useState(false); const [siruta, setSiruta] = useState(""); const [workspacePk, setWorkspacePk] = useState(null); const uatRef = useRef(null); /* ── Export state ────────────────────────────────────────────── */ const [exportJobId, setExportJobId] = useState(null); const [exportProgress, setExportProgress] = useState( null, ); const [phaseTrail, setPhaseTrail] = useState([]); const [exporting, setExporting] = useState(false); const pollingRef = useRef | null>(null); /* ── Layer catalog UI ───────────────────────────────────────── */ const [expandedCategories, setExpandedCategories] = useState< Record >({}); const [downloadingLayer, setDownloadingLayer] = useState(null); const [layerCounts, setLayerCounts] = useState< Record >({}); const [countingLayers, setCountingLayers] = useState(false); const [layerCountSiruta, setLayerCountSiruta] = useState(""); // siruta for which counts were fetched const [layerHistory, setLayerHistory] = useState< { layerId: string; label: string; count: number; time: string; siruta: string; }[] >([]); /* ── Sync status ────────────────────────────────────────────── */ type SyncRunInfo = { id: string; layerId: string; status: string; totalRemote: number; totalLocal: number; newFeatures: number; removedFeatures: number; startedAt: string; completedAt?: string; }; const [syncLocalCounts, setSyncLocalCounts] = useState< Record >({}); const [syncRuns, setSyncRuns] = useState([]); const [syncingSiruta, setSyncingSiruta] = useState(""); const [syncingLayer, setSyncingLayer] = useState(null); const [syncProgress, setSyncProgress] = useState(""); const [exportingLocal, setExportingLocal] = useState(false); const refreshSyncRef = useRef<(() => void) | null>(null); /* ── Global DB summary (all UATs) ────────────────────────────── */ type DbUatSummary = { siruta: string; uatName: string; county: string | null; layers: { layerId: string; count: number; enrichedCount: number; lastSynced: string | null; }[]; totalFeatures: number; totalEnriched: number; }; type DbSummary = { uats: DbUatSummary[]; totalFeatures: number; totalUats: number; }; const [dbSummary, setDbSummary] = useState(null); const [dbSummaryLoading, setDbSummaryLoading] = useState(false); /* ── PostGIS setup ───────────────────────────────────────────── */ const [postgisRunning, setPostgisRunning] = useState(false); const [postgisResult, setPostgisResult] = useState<{ success: boolean; message?: string; details?: Record; error?: string; } | null>(null); /* ── Parcel search tab ──────────────────────────────────────── */ const [searchResults, setSearchResults] = useState([]); const [searchList, setSearchList] = useState([]); const [featuresSearch, setFeaturesSearch] = useState(""); const [loadingFeatures, setLoadingFeatures] = useState(false); const [searchError, setSearchError] = useState(""); /* ════════════════════════════════════════════════════════════ */ /* Load UAT data + check server session on mount */ /* ════════════════════════════════════════════════════════════ */ const fetchSession = useCallback(async () => { try { const res = await fetch("/api/eterra/session"); const data = (await res.json()) as SessionStatus; setSession(data); if (data.connected) setConnectionError(""); return data; } catch { return null; } }, []); useEffect(() => { // Load UATs from local DB (fast — no eTerra needed) 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 { // DB empty — seed from uat.json via POST, then load from uat.json fetch("/api/eterra/uats", { method: "POST" }).catch(() => {}); fetch("/uat.json") .then((res) => res.json()) .then((fallback: UatEntry[]) => setUatData(fallback)) .catch(() => {}); } }) .catch(() => { // API failed — fall back to static uat.json fetch("/uat.json") .then((res) => res.json()) .then((fallback: UatEntry[]) => setUatData(fallback)) .catch(() => {}); }); // Check existing server session on mount void fetchSession(); // Poll session every 30s to stay in sync with other clients sessionPollRef.current = setInterval(() => void fetchSession(), 30_000); return () => { if (sessionPollRef.current) clearInterval(sessionPollRef.current); }; }, [fetchSession]); /* ── Fetch global 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]); /* ════════════════════════════════════════════════════════════ */ /* (Sync effect removed — POST seeds from uat.json, no */ /* eTerra nomenclature needed. Workspace resolved lazily.) */ /* ════════════════════════════════════════════════════════════ */ /* ════════════════════════════════════════════════════════════ */ /* UAT autocomplete filter */ /* ════════════════════════════════════════════════════════════ */ useEffect(() => { const raw = uatQuery.trim(); if (raw.length < 2) { setUatResults([]); return; } const isDigit = /^\d+$/.test(raw); const query = normalizeText(raw); const results = uatData .filter((item) => { if (isDigit) return item.siruta.startsWith(raw); // Match UAT name or county name if (normalizeText(item.name).includes(query)) return true; if (item.county && normalizeText(item.county).includes(query)) return true; return false; }) .slice(0, 12); setUatResults(results); }, [uatQuery, uatData]); /* ════════════════════════════════════════════════════════════ */ /* Auto-connect: trigger on first UAT keystroke */ /* ════════════════════════════════════════════════════════════ */ const triggerAutoConnect = useCallback(async () => { if (session.connected || connecting || autoConnectAttempted.current) 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; }; if (data.success) { await fetchSession(); } else { setConnectionError(data.error ?? "Eroare conectare"); } } catch { setConnectionError("Eroare rețea"); } setConnecting(false); }, [session.connected, 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 { // Jobs are running — show warning setConnectionError(data.error ?? "Nu se poate deconecta"); } } catch { setConnectionError("Eroare rețea"); } }, []); /* ════════════════════════════════════════════════════════════ */ /* Progress polling */ /* ════════════════════════════════════════════════════════════ */ const startPolling = useCallback((jid: string) => { if (pollingRef.current) clearInterval(pollingRef.current); pollingRef.current = setInterval(async () => { try { const res = await fetch( `/api/eterra/progress?jobId=${encodeURIComponent(jid)}`, ); const data = (await res.json()) as ExportProgress; setExportProgress(data); if (data.phase) { setPhaseTrail((prev) => { if (prev[prev.length - 1] === data.phase) return prev; return [...prev, data.phase!]; }); } if (data.status === "done" || data.status === "error") { if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } } } catch { /* ignore polling errors */ } }, 1000); }, []); useEffect(() => { return () => { if (pollingRef.current) clearInterval(pollingRef.current); }; }, []); /* ════════════════════════════════════════════════════════════ */ /* Export bundle (base / magic) */ /* ════════════════════════════════════════════════════════════ */ const handleExportBundle = useCallback( async (mode: "base" | "magic") => { if (!siruta || exporting) return; const jobId = crypto.randomUUID(); setExportJobId(jobId); setExportProgress(null); setPhaseTrail([]); setExporting(true); startPolling(jobId); try { const res = await fetch("/api/eterra/export-bundle", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ siruta, jobId, mode, }), }); if (!res.ok) { const err = (await res.json().catch(() => ({}))) as { error?: string; }; throw new Error(err.error ?? `HTTP ${res.status}`); } const blob = await res.blob(); const cd = res.headers.get("Content-Disposition") ?? ""; const match = /filename="?([^"]+)"?/.exec(cd); const filename = match?.[1] ?? (mode === "magic" ? `eterra_uat_${siruta}_magic.zip` : `eterra_uat_${siruta}_terenuri_cladiri.zip`); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); // Mark progress as done after successful download setExportProgress((prev) => prev ? { ...prev, status: "done", phase: "Finalizat", downloaded: prev.total ?? 100, total: prev.total ?? 100, message: `Descărcare completă — ${filename}`, note: undefined, } : null, ); } catch (error) { const msg = error instanceof Error ? error.message : "Eroare export"; setExportProgress((prev) => prev ? { ...prev, status: "error", message: msg } : { jobId, downloaded: 0, status: "error", message: msg, }, ); } if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } setExporting(false); // Refresh sync status — data was synced to DB refreshSyncRef.current?.(); }, [siruta, exporting, startPolling], ); /* ════════════════════════════════════════════════════════════ */ /* Layer feature counts */ /* ════════════════════════════════════════════════════════════ */ // Load history from localStorage on mount useEffect(() => { try { const raw = localStorage.getItem("parcel-sync:layer-history"); if (raw) { const parsed = JSON.parse(raw) as typeof layerHistory; // Only keep today's entries const today = new Date().toISOString().slice(0, 10); const todayEntries = parsed.filter( (e) => e.time.slice(0, 10) === today, ); setLayerHistory(todayEntries); } } catch { // ignore } }, []); const fetchLayerCounts = useCallback(async () => { if (!siruta || countingLayers) return; setCountingLayers(true); try { const res = await fetch("/api/eterra/layers/summary", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ siruta }), }); const data = (await res.json()) as { counts?: Record; error?: string; }; if (data.counts) { setLayerCounts(data.counts); setLayerCountSiruta(siruta); // Save non-zero counts to history const now = new Date().toISOString(); const today = now.slice(0, 10); const newEntries: typeof layerHistory = []; for (const [layerId, info] of Object.entries(data.counts)) { if (info.count > 0) { const layer = LAYER_CATALOG.find((l) => l.id === layerId); newEntries.push({ layerId, label: layer?.label ?? layerId, count: info.count, time: now, siruta, }); } } setLayerHistory((prev) => { // Keep today's entries only, add new batch const kept = prev.filter( (e) => e.time.slice(0, 10) === today && e.siruta !== siruta, ); const merged = [...kept, ...newEntries]; try { localStorage.setItem( "parcel-sync:layer-history", JSON.stringify(merged), ); } catch { // quota } return merged; }); } } catch { // silent } setCountingLayers(false); }, [siruta, countingLayers]); /* ════════════════════════════════════════════════════════════ */ /* Sync status — load local feature counts for current UAT */ /* ════════════════════════════════════════════════════════════ */ 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; runs?: SyncRunInfo[]; }; if (data.localCounts) setSyncLocalCounts(data.localCounts); if (data.runs) setSyncRuns(data.runs); setSyncingSiruta(siruta); } catch { // silent } }, [siruta]); // Keep ref in sync so callbacks defined earlier can trigger refresh refreshSyncRef.current = () => void fetchSyncStatus(); // Auto-fetch sync status when siruta changes useEffect(() => { if (siruta && /^\d+$/.test(siruta)) { void fetchSyncStatus(); } }, [siruta, fetchSyncStatus]); const handleSyncLayer = useCallback( async (layerId: string) => { if (!siruta || syncingLayer) return; setSyncingLayer(layerId); setSyncProgress("Sincronizare pornită…"); try { const res = await fetch("/api/eterra/sync", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ siruta, layerId, jobId: crypto.randomUUID(), }), }); const data = (await res.json()) as { status?: string; newFeatures?: number; removedFeatures?: number; totalLocal?: number; error?: string; }; if (data.error) { setSyncProgress(`Eroare: ${data.error}`); } else { setSyncProgress( `Finalizat — ${data.newFeatures ?? 0} noi, ${data.removedFeatures ?? 0} șterse, ${data.totalLocal ?? 0} total local`, ); // Refresh sync status await fetchSyncStatus(); } } catch { setSyncProgress("Eroare rețea"); } // Clear progress after 8s setTimeout(() => { setSyncingLayer(null); setSyncProgress(""); }, 8_000); }, [siruta, syncingLayer, fetchSyncStatus], ); const handleExportLocal = useCallback( async (layerIds?: string[]) => { if (!siruta || exportingLocal) return; setExportingLocal(true); try { const res = await fetch("/api/eterra/export-local", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ siruta, ...(layerIds ? { layerIds } : { allLayers: true }), }), }); if (!res.ok) { const err = (await res.json().catch(() => ({}))) as { error?: string; }; throw new Error(err.error ?? `HTTP ${res.status}`); } const blob = await res.blob(); const cd = res.headers.get("Content-Disposition") ?? ""; const match = /filename="?([^"]+)"?/.exec(cd); const filename = match?.[1] ?? `eterra_local_${siruta}.${layerIds?.length === 1 ? "gpkg" : "zip"}`; const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } catch (error) { const msg = error instanceof Error ? error.message : "Eroare export"; setSyncProgress(msg); setTimeout(() => setSyncProgress(""), 5_000); } setExportingLocal(false); }, [siruta, exportingLocal], ); // Sync multiple layers sequentially (for "sync all" / "sync category") const [syncQueue, setSyncQueue] = useState([]); const syncQueueRef = useRef([]); const handleSyncMultiple = useCallback( async (layerIds: string[]) => { if (!siruta || syncingLayer || syncQueue.length > 0) return; syncQueueRef.current = [...layerIds]; setSyncQueue([...layerIds]); for (const layerId of layerIds) { setSyncingLayer(layerId); setSyncProgress( `Sincronizare ${LAYER_CATALOG.find((l) => l.id === layerId)?.label ?? layerId}…`, ); try { const res = await fetch("/api/eterra/sync", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ siruta, layerId, jobId: crypto.randomUUID(), }), }); const data = (await res.json()) as { error?: string; newFeatures?: number; removedFeatures?: number; totalLocal?: number; }; if (data.error) { setSyncProgress(`Eroare: ${data.error}`); } } catch { setSyncProgress("Eroare rețea"); } // Remove from queue syncQueueRef.current = syncQueueRef.current.filter( (id) => id !== layerId, ); setSyncQueue([...syncQueueRef.current]); } // Done — refresh status await fetchSyncStatus(); setSyncingLayer(null); setSyncProgress(""); setSyncQueue([]); syncQueueRef.current = []; }, [siruta, syncingLayer, syncQueue.length, fetchSyncStatus], ); /* ════════════════════════════════════════════════════════════ */ /* PostGIS setup (one-time) */ /* ════════════════════════════════════════════════════════════ */ const handleSetupPostgis = useCallback(async () => { if (postgisRunning) return; setPostgisRunning(true); setPostgisResult(null); try { const res = await fetch("/api/eterra/setup-postgis", { method: "POST" }); const json = await res.json(); setPostgisResult(json as typeof postgisResult); } catch (error) { const msg = error instanceof Error ? error.message : "Eroare setup"; setPostgisResult({ success: false, error: msg }); } setPostgisRunning(false); }, [postgisRunning, postgisResult]); /* ════════════════════════════════════════════════════════════ */ /* Export individual layer */ /* ════════════════════════════════════════════════════════════ */ const handleExportLayer = useCallback( async (layerId: string) => { if (!siruta || downloadingLayer) return; setDownloadingLayer(layerId); const jobId = crypto.randomUUID(); setExportJobId(jobId); setExportProgress(null); setPhaseTrail([]); startPolling(jobId); try { const res = await fetch("/api/eterra/export-layer-gpkg", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ siruta, layerId, jobId, }), }); if (!res.ok) { const err = (await res.json().catch(() => ({}))) as { error?: string; }; throw new Error(err.error ?? `HTTP ${res.status}`); } const blob = await res.blob(); const cd = res.headers.get("Content-Disposition") ?? ""; const match = /filename="?([^"]+)"?/.exec(cd); const filename = match?.[1] ?? `eterra_uat_${siruta}_${layerId}.gpkg`; const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); // Mark progress as done after successful download setExportProgress((prev) => prev ? { ...prev, status: "done", phase: "Finalizat", downloaded: prev.total ?? 100, total: prev.total ?? 100, message: `Descărcare completă — ${filename}`, note: undefined, } : null, ); } catch (error) { const msg = error instanceof Error ? error.message : "Eroare export"; setExportProgress((prev) => prev ? { ...prev, status: "error", message: msg } : { jobId, downloaded: 0, status: "error", message: msg, }, ); } if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } setDownloadingLayer(null); // Refresh sync status — layer was synced to DB refreshSyncRef.current?.(); }, [siruta, downloadingLayer, startPolling], ); /* ════════════════════════════════════════════════════════════ */ /* Search parcels by cadastral number (eTerra app API) */ /* ════════════════════════════════════════════════════════════ */ const handleSearch = useCallback(async () => { if (!siruta || !/^\d+$/.test(siruta)) return; if (!featuresSearch.trim()) { setSearchResults([]); setSearchError(""); return; } setLoadingFeatures(true); setSearchError(""); try { const res = await fetch("/api/eterra/search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ siruta, search: featuresSearch.trim(), ...(workspacePk ? { workspacePk } : {}), }), }); const data = (await res.json()) as { results?: ParcelDetail[]; total?: number; error?: string; }; if (data.error) { setSearchResults([]); setSearchError(data.error); } else { setSearchResults(data.results ?? []); setSearchError(""); } } catch { setSearchError("Eroare de rețea."); } setLoadingFeatures(false); }, [siruta, featuresSearch, workspacePk]); // No auto-search — user clicks button or presses Enter const handleSearchKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); void handleSearch(); } }, [handleSearch], ); // Add result(s) to list for CSV export const addToList = useCallback((item: ParcelDetail) => { setSearchList((prev) => { if ( prev.some( (p) => p.nrCad === item.nrCad && p.immovablePk === item.immovablePk, ) ) return prev; return [...prev, item]; }); }, []); const removeFromList = useCallback((nrCad: string) => { setSearchList((prev) => prev.filter((p) => p.nrCad !== nrCad)); }, []); // CSV export — all fields quoted to handle commas in values (e.g. nrTopo) const csvEscape = useCallback((val: string | number | null | undefined) => { const s = val != null ? String(val) : ""; return `"${s.replace(/"/g, '""')}"`; }, []); const downloadCSV = useCallback(() => { const items = searchList.length > 0 ? searchList : searchResults; if (items.length === 0) return; const headers = [ "NR_CAD", "NR_CF", "NR_CF_VECHI", "NR_TOPO", "SUPRAFATA", "INTRAVILAN", "CATEGORIE_FOLOSINTA", "ADRESA", "PROPRIETARI_ACTUALI", "PROPRIETARI_VECHI", "SOLICITANT", ]; const rows = items.map((p) => [ csvEscape(p.nrCad), csvEscape(p.nrCF), csvEscape(p.nrCFVechi), csvEscape(p.nrTopo), csvEscape(p.suprafata), csvEscape(p.intravilan), csvEscape(p.categorieFolosinta), csvEscape(p.adresa), csvEscape(p.proprietariActuali ?? p.proprietari), csvEscape(p.proprietariVechi), csvEscape(p.solicitant), ]); const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n"); const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `parcele_${siruta}_${Date.now()}.csv`; a.click(); URL.revokeObjectURL(url); }, [searchList, searchResults, siruta, csvEscape]); /* ════════════════════════════════════════════════════════════ */ /* Derived data */ /* ════════════════════════════════════════════════════════════ */ const layersByCategory = useMemo(() => { const grouped: Record = {}; for (const layer of LAYER_CATALOG) { if (!grouped[layer.category]) grouped[layer.category] = []; grouped[layer.category]!.push(layer); } return grouped; }, []); const sirutaValid = siruta.length > 0 && /^\d+$/.test(siruta); const progressPct = exportProgress?.total && exportProgress.total > 0 ? Math.round((exportProgress.downloaded / exportProgress.total) * 100) : 0; // DB status: which layers have data for the current UAT const dbLayersSummary = useMemo(() => { if (!sirutaValid || syncingSiruta !== siruta) return []; return LAYER_CATALOG.filter((l) => (syncLocalCounts[l.id] ?? 0) > 0).map( (l) => { const count = syncLocalCounts[l.id] ?? 0; const lastRun = syncRuns.find( (r) => r.layerId === l.id && r.status === "done", ); const lastSynced = lastRun?.completedAt ? new Date(lastRun.completedAt) : null; const ageMs = lastSynced ? Date.now() - lastSynced.getTime() : null; const isFresh = ageMs !== null ? ageMs < 168 * 60 * 60 * 1000 : false; return { ...l, count, lastSynced, isFresh }; }, ); }, [sirutaValid, syncingSiruta, siruta, syncLocalCounts, syncRuns]); const dbTotalFeatures = dbLayersSummary.reduce((sum, l) => sum + l.count, 0); const relativeTime = (date: Date | null) => { if (!date) return "niciodată"; const mins = Math.floor((Date.now() - date.getTime()) / 60_000); if (mins < 1) return "acum"; if (mins < 60) return `acum ${mins} min`; const hours = Math.floor(mins / 60); if (hours < 24) return `acum ${hours}h`; const days = Math.floor(hours / 24); return `acum ${days}z`; }; /* ════════════════════════════════════════════════════════════ */ /* Render */ /* ════════════════════════════════════════════════════════════ */ return ( {/* ═══════════════════════ Persistent header ═══════════════ */}
{/* UAT + Connection row */}
{/* UAT autocomplete — always visible */}
{ setUatQuery(e.target.value); setShowUatResults(true); // Auto-connect on first keystroke if (e.target.value.trim().length >= 1) { void triggerAutoConnect(); } }} onFocus={() => setShowUatResults(true)} onBlur={() => setTimeout(() => setShowUatResults(false), 150)} className="pl-9 font-medium" autoComplete="off" />
{/* Selected indicator chip */} {sirutaValid && (
SIRUTA {siruta}
)} {/* Dropdown */} {showUatResults && uatResults.length > 0 && (
{uatResults.map((item) => ( ))}
)}
{/* Connection pill */}
{/* Tab bar */} Căutare Parcele Catalog Layere Export Baza de Date
{/* ═══════════════════════════════════════════════════════ */} {/* Tab 1: Parcel search */} {/* ═══════════════════════════════════════════════════════ */} {!sirutaValid || !session.connected ? (

{!session.connected ? "Conectează-te la eTerra și selectează un UAT." : "Selectează un UAT mai sus pentru a căuta parcele."}

) : ( <> {/* Search input */}
setFeaturesSearch(e.target.value)} onKeyDown={handleSearchKeyDown} />
{searchError && (

{searchError}

)}
{/* Results */} {loadingFeatures && searchResults.length === 0 && (

Se caută în eTerra...

Prima căutare pe un UAT nou poate dura ~10-30s (se încarcă lista de județe).

)} {searchResults.length > 0 && ( <> {/* Action bar */}
{searchResults.length} rezultat {searchResults.length > 1 ? "e" : ""} {searchList.length > 0 && ( · {searchList.length} în listă )}
{searchResults.length > 0 && ( )}
{/* Detail cards */}
{searchResults.map((p, idx) => (

Nr. Cad. {p.nrCad}

{!p.immovablePk && (

Parcela nu a fost găsită în eTerra.

)}
{p.immovablePk && (
Nr. CF {p.nrCF || "—"}
{p.nrCFVechi && (
CF vechi {p.nrCFVechi}
)}
Nr. Topo {p.nrTopo || "—"}
Suprafață {p.suprafata != null ? formatArea(p.suprafata) : "—"}
Intravilan {p.intravilan || "—"}
{p.categorieFolosinta && (
Categorii folosință {p.categorieFolosinta}
)} {p.adresa && (
Adresă {p.adresa}
)} {(p.proprietariActuali || p.proprietariVechi) && (
{p.proprietariActuali && (
Proprietari actuali {p.proprietariActuali}
)} {p.proprietariVechi && (
Proprietari anteriori {p.proprietariVechi}
)} {!p.proprietariActuali && !p.proprietariVechi && p.proprietari && (
Proprietari {p.proprietari}
)}
)} {p.solicitant && (
Solicitant {p.solicitant}
)}
)}
))}
)} {/* Empty state when no search has been done */} {searchResults.length === 0 && !loadingFeatures && !searchError && (

Introdu un număr cadastral și apasă Caută.

Poți căuta mai multe parcele simultan, separate prin virgulă.

)} {/* Saved list */} {searchList.length > 0 && (

Lista mea ({searchList.length} parcele)

{searchList.map((p) => ( ))}
Nr. Cad Nr. CF Suprafață Proprietari
{p.nrCad} {p.nrCF || "—"} {p.suprafata != null ? formatArea(p.suprafata) : "—"} {p.proprietari || "—"}
)} )}
{/* ═══════════════════════════════════════════════════════ */} {/* Tab 2: Layer catalog */} {/* ═══════════════════════════════════════════════════════ */} {!sirutaValid || !session.connected ? (

{!session.connected ? "Conectează-te la eTerra și selectează un UAT." : "Selectează un UAT pentru a vedea catalogul de layere."}

) : (
{/* Action bar */}

{layerCountSiruta === siruta && Object.keys(layerCounts).length > 0 ? `Număr features pentru SIRUTA ${siruta}` : "Apasă pentru a număra features-urile din fiecare layer."}

{/* Export from local DB */} {syncingSiruta === siruta && Object.values(syncLocalCounts).some((c) => c > 0) && ( )}
{/* Sync progress message */} {syncProgress && (
{syncingLayer ? ( ) : ( )} {syncProgress}
)} {(Object.keys(LAYER_CATEGORY_LABELS) as LayerCategory[]).map( (cat) => { const layers = layersByCategory[cat]; if (!layers?.length) return null; const isExpanded = expandedCategories[cat] ?? false; // Sum counts for category badge const catTotal = layerCountSiruta === siruta ? layers.reduce( (sum, l) => sum + (layerCounts[l.id]?.count ?? 0), 0, ) : null; // Sum local counts for category const catLocal = syncingSiruta === siruta ? layers.reduce( (sum, l) => sum + (syncLocalCounts[l.id] ?? 0), 0, ) : null; return ( {isExpanded && ( {layers.map((layer) => { const isDownloading = downloadingLayer === layer.id; const isSyncing = syncingLayer === layer.id; const lc = layerCountSiruta === siruta ? layerCounts[layer.id] : undefined; const localCount = syncingSiruta === siruta ? (syncLocalCounts[layer.id] ?? 0) : 0; // Find last sync run for this layer const lastRun = syncRuns.find( (r) => r.layerId === layer.id && r.status === "done", ); return (

{layer.label}

{lc != null && !lc.error && ( {lc.count.toLocaleString("ro-RO")} )} {lc?.error && ( eroare )} {localCount > 0 && ( {localCount.toLocaleString("ro-RO")} )}

{layer.id}

{lastRun && ( sync{" "} {new Date( lastRun.completedAt ?? lastRun.startedAt, ).toLocaleDateString("ro-RO", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", })} )}
{/* Sync to DB */} {/* GPKG (sync-first: syncs to DB if needed, then exports from DB) */}
); })}
)}
); }, )} {/* Drumul de azi — today's layer count history */} {layerHistory.length > 0 && (
Drumul de azi {layerHistory.length}
{/* Group by siruta */} {(() => { const grouped = new Map(); for (const e of layerHistory) { if (!grouped.has(e.siruta)) grouped.set(e.siruta, []); grouped.get(e.siruta)!.push(e); } return Array.from(grouped.entries()).map( ([sir, entries]) => (

SIRUTA {sir}{" "} —{" "} {new Date(entries[0]!.time).toLocaleTimeString( "ro-RO", { hour: "2-digit", minute: "2-digit" }, )}

{entries .sort((a, b) => b.count - a.count) .map((e) => (
{e.label} {e.count.toLocaleString("ro-RO")}
))}
), ); })()}
)} {/* PostGIS / QGIS setup */}
QGIS / PostGIS
{postgisResult ? ( postgisResult.success ? (
{postgisResult.message}
{postgisResult.details && (

Backfill:{" "} {String( ( postgisResult.details as { backfilledFeatures?: number; } ).backfilledFeatures ?? 0, )}{" "} features convertite

Total cu geometrie nativă:{" "} {String( ( postgisResult.details as { totalFeaturesWithGeom?: number; } ).totalFeaturesWithGeom ?? 0, )}

QGIS → PostgreSQL → 10.10.10.166:5432 / architools_db

View-uri: gis_terenuri, gis_cladiri, gis_documentatii, gis_administrativ

SRID: 3844

)}
) : (

PostGIS nu este instalat

Instalează PostGIS pe serverul PostgreSQL:

apt install postgresql-16-postgis-3
) ) : (

Creează coloana nativă PostGIS, trigger auto-conversie, index spațial GiST și view-uri QGIS-compatibile. Necesită PostGIS instalat pe server.

)}
)} {/* Progress bar for layer download */} {downloadingLayer && exportProgress && (

{exportProgress.phase} {exportProgress.phaseCurrent != null && exportProgress.phaseTotal ? ` — ${exportProgress.phaseCurrent} / ${exportProgress.phaseTotal}` : ""}

{progressPct}%
)} {/* ═══════════════════════════════════════════════════════ */} {/* Tab 3: Export */} {/* ═══════════════════════════════════════════════════════ */} {/* DB freshness status */} {sirutaValid && dbLayersSummary.length > 0 && (
{dbTotalFeatures.toLocaleString("ro-RO")} {" "} entități în DB din{" "} {dbLayersSummary.length} {" "} layere {(() => { const freshCount = dbLayersSummary.filter( (l) => l.isFresh, ).length; const staleCount = dbLayersSummary.length - freshCount; const oldestSync = dbLayersSummary.reduce( (oldest, l) => { if (!l.lastSynced) return oldest; if (!oldest || l.lastSynced < oldest) return l.lastSynced; return oldest; }, null as Date | null, ); return ( <> {staleCount === 0 ? ( Proaspete ) : ( {staleCount} vechi )} {oldestSync && ( Ultima sincronizare: {relativeTime(oldestSync)} )} ); })()}
)} {/* Hero buttons */} {sirutaValid && session.connected ? (
) : ( {!session.connected ? ( <>

Conectează-te la eTerra pentru a activa exportul.

) : ( <>

Selectează un UAT pentru a activa exportul.

)}
)} {/* Progress bar */} {exportProgress && exportProgress.status !== "unknown" && exportJobId && ( {/* Phase trail */}
{phaseTrail.map((p, i) => ( {i > 0 && } {p} ))}
{/* Progress info */}
{exportProgress.status === "running" && ( )} {exportProgress.status === "done" && ( )} {exportProgress.status === "error" && ( )}

{exportProgress.phase} {exportProgress.phaseCurrent != null && exportProgress.phaseTotal ? ` — ${exportProgress.phaseCurrent} / ${exportProgress.phaseTotal}` : ""}

{exportProgress.note && (

{exportProgress.note}

)} {exportProgress.message && (

{exportProgress.message}

)}
{progressPct}%
{/* Bar */}
)} {/* ═══════════════════════════════════════════════════════ */} {/* Tab 4: Baza de Date */} {/* ═══════════════════════════════════════════════════════ */} {dbSummaryLoading && !dbSummary ? (

Se încarcă datele din baza de date…

) : !dbSummary || dbSummary.totalFeatures === 0 ? (

Nicio dată în baza de date

Folosește tab-ul Export pentru a sincroniza date din eTerra.

) : ( <> {/* Header row */}
{dbSummary.totalFeatures.toLocaleString("ro-RO")} entități din {dbSummary.totalUats} UAT-uri
{/* UAT cards */} {dbSummary.uats.map((uat) => { const catCounts: Record = {}; let enrichedTotal = 0; let oldestSync: Date | null = null; for (const layer of uat.layers) { const cat = findLayerById(layer.layerId)?.category ?? "administrativ"; catCounts[cat] = (catCounts[cat] ?? 0) + layer.count; enrichedTotal += layer.enrichedCount; if (layer.lastSynced) { const d = new Date(layer.lastSynced); if (!oldestSync || d < oldestSync) oldestSync = d; } } const isCurrentUat = sirutaValid && uat.siruta === siruta; return ( {/* UAT header row */}
{uat.uatName} {uat.county && ( ({uat.county}) )} #{uat.siruta} {isCurrentUat && ( selectat )} {oldestSync ? relativeTime(oldestSync) : "—"}
{/* Category counts in a single compact row */}
{( Object.entries(LAYER_CATEGORY_LABELS) as [ LayerCategory, string, ][] ).map(([cat, label]) => { const count = catCounts[cat] ?? 0; if (count === 0) return null; return ( {label}: {count.toLocaleString("ro-RO")} ); })} {enrichedTotal > 0 && ( Magic: {enrichedTotal.toLocaleString("ro-RO")} )}
{/* Layer detail pills */}
{uat.layers .sort((a, b) => b.count - a.count) .map((layer) => { const meta = findLayerById(layer.layerId); const label = meta?.label ?? layer.layerId.replace(/_/g, " "); const isEnriched = layer.enrichedCount > 0; return ( {label} {layer.count.toLocaleString("ro-RO")} {isEnriched && ( )} ); })}
); })} )}
); }