"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, AlertTriangle, BarChart3, } 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"; import type { OwnerSearchResult } from "@/app/api/eterra/search-owner/route"; import { User } from "lucide-react"; import { UatDashboard } from "./uat-dashboard"; /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ type UatEntry = { siruta: string; name: string; county?: string; workspacePk?: number; localFeatures?: number; }; type SessionStatus = { connected: boolean; username?: string; connectedAt?: string; activeJobCount: number; activeJobPhase?: string; /** eTerra platform health */ eterraAvailable?: boolean; /** True when eTerra is in maintenance */ eterraMaintenance?: boolean; /** Human-readable health message */ eterraHealthMessage?: 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}

)}
{/* Maintenance banner */} {!session.connected && session.eterraMaintenance && (

eTerra este în mentenanță

Platforma ANCPI nu este disponibilă momentan. Conectarea va fi reactivată automat când serviciul revine online.

{session.eterraHealthMessage && (

{session.eterraHealthMessage}

)}
)} {/* Info when not connected (and not in maintenance) */} {!session.connected && !connectionError && !session.eterraMaintenance && (

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

Credențialele sunt preluate din configurarea serverului.

)} {/* Error detail (only when NOT maintenance — to avoid confusing users) */} {!session.connected && connectionError && !session.eterraMaintenance && (

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; noGeomCount: number; lastSynced: string | null; }[]; totalFeatures: number; totalEnriched: number; totalNoGeom: 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 [searchMode, setSearchMode] = useState<"cadastral" | "owner">( "cadastral", ); const [searchResults, setSearchResults] = useState([]); const [searchList, setSearchList] = useState([]); const [featuresSearch, setFeaturesSearch] = useState(""); const [loadingFeatures, setLoadingFeatures] = useState(false); const [searchError, setSearchError] = useState(""); /* owner search */ const [ownerSearch, setOwnerSearch] = useState(""); const [ownerResults, setOwnerResults] = useState([]); const [ownerLoading, setOwnerLoading] = useState(false); const [ownerError, setOwnerError] = useState(""); const [ownerNote, setOwnerNote] = useState(""); /* dashboard */ const [dashboardSiruta, setDashboardSiruta] = useState(null); /* ── No-geometry import option ──────────────────────────────── */ const [includeNoGeom, setIncludeNoGeom] = useState(false); const [noGeomScanning, setNoGeomScanning] = useState(false); const [noGeomScan, setNoGeomScan] = useState<{ totalImmovables: number; withGeometry: number; remoteGisCount: number; remoteCladiriCount: number; noGeomCount: number; matchedByRef: number; matchedById: number; qualityBreakdown: { withCadRef: number; withPaperCad: number; withPaperLb: number; withLandbook: number; withArea: number; withActiveStatus: number; useful: number; empty: number; }; localDbTotal: number; localDbWithGeom: number; localDbNoGeom: number; localDbEnriched: number; localDbEnrichedComplete: number; localSyncFresh: boolean; scannedAt: string; } | null>(null); const [noGeomScanSiruta, setNoGeomScanSiruta] = useState(""); // siruta for which scan was done /* ── Background sync state ──────────────────────────────────── */ const [bgJobId, setBgJobId] = useState(null); const [bgProgress, setBgProgress] = useState(null); const [bgPhaseTrail, setBgPhaseTrail] = useState([]); const bgPollingRef = useRef | null>(null); const [downloadingFromDb, setDownloadingFromDb] = useState(false); /* ════════════════════════════════════════════════════════════ */ /* 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((prev) => { // If eTerra was in maintenance but is now back online, reset auto-connect 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 (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]); /* ════════════════════════════════════════════════════════════ */ /* Reload UAT data when session connects (county data may */ /* have been populated by the login flow) */ /* ════════════════════════════════════════════════════════════ */ const prevConnected = useRef(false); useEffect(() => { if (session.connected && !prevConnected.current) { // Just connected — reload UATs after a short delay to let // the server-side county refresh finish 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 */ /* ════════════════════════════════════════════════════════════ */ 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; // Don't attempt login when eTerra is in maintenance 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) { // eTerra is in maintenance — set flag, DON'T show as connection error setSession((prev) => ({ ...prev, eterraMaintenance: true, eterraAvailable: false, eterraHealthMessage: data.error ?? "eTerra în mentenanță", })); // Allow retry later when maintenance ends 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 { // 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, includeNoGeometry: includeNoGeom, }), }); 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, includeNoGeom], ); /* ════════════════════════════════════════════════════════════ */ /* No-geometry scan */ /* ════════════════════════════════════════════════════════════ */ const handleNoGeomScan = useCallback( async (targetSiruta?: string) => { const s = targetSiruta ?? siruta; if (!s) return; setNoGeomScanning(true); setNoGeomScan(null); setNoGeomScanSiruta(s); const emptyQuality = { withCadRef: 0, withPaperCad: 0, withPaperLb: 0, withLandbook: 0, withArea: 0, withActiveStatus: 0, useful: 0, empty: 0, }; const emptyResult = { totalImmovables: 0, withGeometry: 0, remoteGisCount: 0, remoteCladiriCount: 0, noGeomCount: 0, matchedByRef: 0, matchedById: 0, qualityBreakdown: emptyQuality, localDbTotal: 0, localDbWithGeom: 0, localDbNoGeom: 0, localDbEnriched: 0, localDbEnrichedComplete: 0, localSyncFresh: false, scannedAt: "", }; try { // 2min timeout — scan is informational, should not block the page const scanAbort = new AbortController(); const scanTimer = setTimeout(() => scanAbort.abort(), 120_000); const res = await fetch("/api/eterra/no-geom-scan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ siruta: s, workspacePk: workspacePk ?? undefined, }), signal: scanAbort.signal, }); clearTimeout(scanTimer); const data = (await res.json()) as Record; if (data.error) { console.warn("[no-geom-scan]", data.error); setNoGeomScan(emptyResult); } else { const qb = (data.qualityBreakdown ?? {}) as Record; setNoGeomScan({ totalImmovables: Number(data.totalImmovables ?? 0), withGeometry: Number(data.withGeometry ?? 0), remoteGisCount: Number(data.remoteGisCount ?? 0), remoteCladiriCount: Number(data.remoteCladiriCount ?? 0), noGeomCount: Number(data.noGeomCount ?? 0), matchedByRef: Number(data.matchedByRef ?? 0), matchedById: Number(data.matchedById ?? 0), qualityBreakdown: { withCadRef: Number(qb.withCadRef ?? 0), withPaperCad: Number(qb.withPaperCad ?? 0), withPaperLb: Number(qb.withPaperLb ?? 0), withLandbook: Number(qb.withLandbook ?? 0), withArea: Number(qb.withArea ?? 0), withActiveStatus: Number(qb.withActiveStatus ?? 0), useful: Number(qb.useful ?? 0), empty: Number(qb.empty ?? 0), }, localDbTotal: Number(data.localDbTotal ?? 0), localDbWithGeom: Number(data.localDbWithGeom ?? 0), localDbNoGeom: Number(data.localDbNoGeom ?? 0), localDbEnriched: Number(data.localDbEnriched ?? 0), localDbEnrichedComplete: Number(data.localDbEnrichedComplete ?? 0), localSyncFresh: Boolean(data.localSyncFresh), scannedAt: String(data.scannedAt ?? new Date().toISOString()), }); } } catch (err) { // Distinguish timeout from other errors for the user const isTimeout = err instanceof DOMException && err.name === "AbortError"; if (isTimeout) { console.warn( "[no-geom-scan] Timeout after 2 min — server eTerra lent", ); } setNoGeomScan({ ...emptyResult, scannedAt: isTimeout ? "timeout" : "", }); } setNoGeomScanning(false); }, [siruta, workspacePk], ); // Auto-scan for no-geometry parcels when UAT is selected + connected const noGeomAutoScanRef = useRef(""); useEffect(() => { if (!siruta || !session.connected) return; // Don't re-scan if we already scanned (or are scanning) this siruta if (noGeomAutoScanRef.current === siruta) return; noGeomAutoScanRef.current = siruta; void handleNoGeomScan(siruta); }, [siruta, session.connected, handleNoGeomScan]); /* ════════════════════════════════════════════════════════════ */ /* 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], ); /* ════════════════════════════════════════════════════════════ */ /* Background sync — fire-and-forget server-side processing */ /* ════════════════════════════════════════════════════════════ */ const startBgPolling = useCallback( (jid: string) => { if (bgPollingRef.current) clearInterval(bgPollingRef.current); bgPollingRef.current = setInterval(async () => { try { const res = await fetch( `/api/eterra/progress?jobId=${encodeURIComponent(jid)}`, ); const data = (await res.json()) as ExportProgress; setBgProgress(data); if (data.phase) { setBgPhaseTrail((prev) => { if (prev[prev.length - 1] === data.phase) return prev; return [...prev, data.phase!]; }); } if (data.status === "done" || data.status === "error") { if (bgPollingRef.current) { clearInterval(bgPollingRef.current); bgPollingRef.current = null; } // Clean localStorage marker try { localStorage.removeItem("parcel-sync:bg-job"); } catch { /* */ } // Refresh sync status and DB summary refreshSyncRef.current?.(); void fetchDbSummary(); } } catch { /* ignore polling errors */ } }, 1500); }, [fetchDbSummary], ); // Cleanup bg polling on unmount useEffect(() => { return () => { if (bgPollingRef.current) clearInterval(bgPollingRef.current); }; }, []); // Recover background job from localStorage on mount useEffect(() => { try { const raw = localStorage.getItem("parcel-sync:bg-job"); if (!raw) return; const saved = JSON.parse(raw) as { jobId: string; siruta: string; startedAt: string; }; // Ignore jobs older than 8 hours const age = Date.now() - new Date(saved.startedAt).getTime(); if (age > 8 * 60 * 60 * 1000) { localStorage.removeItem("parcel-sync:bg-job"); return; } // Check if job is still running void (async () => { try { const res = await fetch( `/api/eterra/progress?jobId=${encodeURIComponent(saved.jobId)}`, ); const data = (await res.json()) as ExportProgress; if (data.status === "running") { setBgJobId(saved.jobId); setBgProgress(data); if (data.phase) setBgPhaseTrail([data.phase]); startBgPolling(saved.jobId); } else if (data.status === "done") { setBgJobId(saved.jobId); setBgProgress(data); if (data.phase) setBgPhaseTrail(["Sincronizare completă"]); localStorage.removeItem("parcel-sync:bg-job"); } else { localStorage.removeItem("parcel-sync:bg-job"); } } catch { localStorage.removeItem("parcel-sync:bg-job"); } })(); } catch { /* */ } }, [startBgPolling]); const handleSyncBackground = useCallback( async (mode: "base" | "magic") => { if (!siruta || exporting) return; try { const res = await fetch("/api/eterra/sync-background", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ siruta, mode, includeNoGeometry: includeNoGeom, }), }); const data = (await res.json()) as { jobId?: string; error?: string }; if (!res.ok || data.error) { setSyncProgress(data.error ?? `Eroare ${res.status}`); setTimeout(() => setSyncProgress(""), 5_000); return; } const jid = data.jobId!; setBgJobId(jid); setBgProgress({ jobId: jid, downloaded: 0, total: 100, status: "running", phase: "Pornire sincronizare fundal", }); setBgPhaseTrail(["Pornire sincronizare fundal"]); // Persist in localStorage so we can recover on page refresh try { localStorage.setItem( "parcel-sync:bg-job", JSON.stringify({ jobId: jid, siruta, startedAt: new Date().toISOString(), }), ); } catch { /* */ } startBgPolling(jid); } catch (error) { const msg = error instanceof Error ? error.message : "Eroare rețea"; setSyncProgress(msg); setTimeout(() => setSyncProgress(""), 5_000); } }, [siruta, exporting, includeNoGeom, startBgPolling], ); const handleDownloadFromDb = useCallback( async (mode: "base" | "magic") => { if (!siruta || downloadingFromDb) return; setDownloadingFromDb(true); try { const res = await fetch("/api/eterra/export-local", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ siruta, 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] ?? `eterra_uat_${siruta}_${mode}_local.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 descărcare"; setSyncProgress(msg); setTimeout(() => setSyncProgress(""), 5_000); } setDownloadingFromDb(false); }, [siruta, downloadingFromDb], ); // 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], ); /* ── Owner search handler ────────────────────────────────── */ const handleOwnerSearch = useCallback(async () => { if (!siruta || !/^\d+$/.test(siruta)) return; if (!ownerSearch.trim() || ownerSearch.trim().length < 2) { setOwnerError("Minim 2 caractere."); return; } setOwnerLoading(true); setOwnerError(""); setOwnerNote(""); try { const res = await fetch("/api/eterra/search-owner", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ siruta, ownerName: ownerSearch.trim(), ...(workspacePk ? { workspacePk } : {}), }), }); const data = (await res.json()) as { results?: OwnerSearchResult[]; total?: number; dbSearched?: boolean; eterraSearched?: boolean; eterraNote?: string; error?: string; }; if (data.error) { setOwnerResults([]); setOwnerError(data.error); } else { setOwnerResults(data.results ?? []); const notes: string[] = []; if (data.dbSearched) notes.push("DB local"); if (data.eterraSearched) notes.push("eTerra API"); if (data.eterraNote) notes.push(data.eterraNote); setOwnerNote( notes.length > 0 ? `Surse: ${notes.join(" + ")}${data.total ? ` · ${data.total} rezultate` : ""}` : "", ); } } catch { setOwnerError("Eroare de rețea."); } setOwnerLoading(false); }, [siruta, ownerSearch, workspacePk]); const handleOwnerKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); void handleOwnerSearch(); } }, [handleOwnerSearch], ); /** Convert an OwnerSearchResult → ParcelDetail so it can be added to the list */ const ownerResultToParcelDetail = useCallback( (r: OwnerSearchResult): ParcelDetail => ({ nrCad: r.nrCad, nrCF: r.nrCF, nrCFVechi: "", nrTopo: "", intravilan: r.intravilan, categorieFolosinta: r.categorieFolosinta, adresa: r.adresa, proprietari: r.proprietari || r.proprietariVechi, proprietariActuali: r.proprietari, proprietariVechi: r.proprietariVechi, suprafata: typeof r.suprafata === "number" ? r.suprafata : null, solicitant: "", immovablePk: r.immovablePk, }), [], ); // 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 ? (

Selectează un UAT mai sus pentru a căuta parcele.

) : ( <> {/* Search input — mode toggle + input */} {/* Mode toggle */}
{/* Cadastral search input */} {searchMode === "cadastral" && (
setFeaturesSearch(e.target.value)} onKeyDown={handleSearchKeyDown} disabled={!session.connected} />
{!session.connected && (

Necesită conexiune eTerra. Folosește modul Proprietar pentru a căuta offline în DB.

)}
)} {/* Owner search input */} {searchMode === "owner" && (
setOwnerSearch(e.target.value)} onKeyDown={handleOwnerKeyDown} />
)} {searchMode === "cadastral" && searchError && (

{searchError}

)} {searchMode === "owner" && ownerError && (

{ownerError}

)} {searchMode === "owner" && ownerNote && (

{ownerNote}

)}
{/* ─── Cadastral search results ────────────── */} {searchMode === "cadastral" && ( <> {/* 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 */} {searchMode === "cadastral" && searchResults.length === 0 && !loadingFeatures && !searchError && (

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

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

)} )} {/* ─── Owner search results ────────────────── */} {searchMode === "owner" && ( <> {ownerLoading && ownerResults.length === 0 && (

Se caută proprietar...

Caută mai întâi în DB local (date îmbogățite), apoi pe eTerra.

)} {ownerResults.length > 0 && ( <>
{ownerResults.length} rezultat {ownerResults.length > 1 ? "e" : ""} pentru " {ownerSearch}"
{ownerResults.map((r, idx) => (

Nr. Cad. {r.nrCad}

{r.source === "db" ? "din baza de date" : "eTerra online"}
{r.nrCF && (
Nr. CF {r.nrCF}
)} {r.suprafata && (
Suprafață {typeof r.suprafata === "number" ? formatArea(r.suprafata) : `${r.suprafata} mp`}
)} {r.intravilan && (
Intravilan {r.intravilan}
)} {r.categorieFolosinta && (
Categorii folosință {r.categorieFolosinta}
)} {r.adresa && (
Adresă {r.adresa}
)} {r.proprietari && (
Proprietari actuali {r.proprietari}
)} {r.proprietariVechi && (
Proprietari anteriori {r.proprietariVechi}
)}
))}
)} {ownerResults.length === 0 && !ownerLoading && !ownerError && (

Introdu numele proprietarului și apasă Caută.

Caută în datele îmbogățite (DB local) și pe eTerra.
Pentru rezultate complete, lansează "Sync fundal — Magic" în tab-ul Export.

)} )} {/* 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.

)}
)} {/* No-geometry option — shown after auto-scan completes */} {sirutaValid && session.connected && (() => { const scanDone = noGeomScan !== null && noGeomScanSiruta === siruta; const estimatedNoGeom = scanDone ? Math.max( 0, noGeomScan.totalImmovables - noGeomScan.remoteGisCount, ) : 0; const hasNoGeomParcels = scanDone && estimatedNoGeom > 0; const scanning = noGeomScanning; // Still scanning if (scanning) return (
Se scanează lista de imobile din eTerra… (max 2 min)

Poți folosi butoanele de mai jos fără să aștepți scanarea.

); // Scan timed out if (scanDone && noGeomScan.scannedAt === "timeout") return (
Scanarea a depășit 2 minute — serverul eTerra e lent.

Poți lansa sincronizarea fundal fără rezultate de scanare. Include no-geom nu va fi disponibil.

); // Helper: local DB status line const staleEnrichment = scanDone && noGeomScan.localDbEnriched > 0 && noGeomScan.localDbEnrichedComplete < noGeomScan.localDbEnriched; const staleCount = scanDone ? noGeomScan.localDbEnriched - noGeomScan.localDbEnrichedComplete : 0; const localDbLine = scanDone && noGeomScan.localDbTotal > 0 && (
Baza de date locală:{" "} {noGeomScan.localDbWithGeom.toLocaleString("ro-RO")} {" "} cu geometrie {noGeomScan.localDbNoGeom > 0 && ( <> {" + "} {noGeomScan.localDbNoGeom.toLocaleString("ro-RO")} {" "} fără geometrie )} {noGeomScan.localDbEnriched > 0 && ( <> {" · "} {noGeomScan.localDbEnriched.toLocaleString("ro-RO")} {" "} îmbogățite {staleEnrichment && ( {" "} ({staleCount.toLocaleString("ro-RO")} incomplete) )} )} {noGeomScan.localSyncFresh && ( (proaspăt) )}
{staleEnrichment && (
{staleCount.toLocaleString("ro-RO")} parcele au îmbogățire veche (lipsă PROPRIETARI_VECHI). Vor fi re-îmbogățite la următorul export Magic.
)}
); // Helper: workflow preview (what Magic will do) const workflowPreview = scanDone && (

La apăsarea Magic, pașii vor fi:

  1. {"Sync GIS — "} 0 ? "text-emerald-600 dark:text-emerald-400" : "text-foreground", )} > {noGeomScan.localSyncFresh && noGeomScan.localDbWithGeom > 0 ? "skip (date proaspete în DB)" : `descarcă ${noGeomScan.remoteGisCount.toLocaleString("ro-RO")} terenuri` + (noGeomScan.remoteCladiriCount > 0 ? ` + ${noGeomScan.remoteCladiriCount.toLocaleString("ro-RO")} clădiri` : "")}
  2. {includeNoGeom && (
  3. Import parcele fără geometrie —{" "} {(() => { const usefulNoGeom = noGeomScan.qualityBreakdown.useful; const newNoGeom = Math.max( 0, usefulNoGeom - noGeomScan.localDbNoGeom, ); const filtered = noGeomScan.qualityBreakdown.empty; return newNoGeom > 0 ? `~${newNoGeom.toLocaleString("ro-RO")} noi de importat` + (filtered > 0 ? ` (${filtered.toLocaleString("ro-RO")} filtrate)` : "") : "deja importate"; })()}
  4. )}
  5. Îmbogățire CF, proprietari, adrese —{" "} {(() => { // What will be in DB after sync + optional no-geom import: // If DB is empty: sync will add remoteGisCount geo features // If DB is fresh: keep localDbTotal const geoAfterSync = noGeomScan.localSyncFresh && noGeomScan.localDbWithGeom > 0 ? noGeomScan.localDbWithGeom : noGeomScan.remoteGisCount; const noGeomAfterImport = includeNoGeom ? Math.max( noGeomScan.localDbNoGeom, noGeomScan.qualityBreakdown.useful, ) : noGeomScan.localDbNoGeom; const totalAfter = geoAfterSync + noGeomAfterImport; const remaining = totalAfter - noGeomScan.localDbEnrichedComplete; return remaining > 0 ? `~${remaining.toLocaleString("ro-RO")} de procesat (~${Math.ceil((remaining * 0.25) / 60)} min)` : "deja îmbogățite"; })()}
  6. Generare GPKG + CSV
  7. Comprimare ZIP + descărcare
); // No-geometry parcels found if (hasNoGeomParcels) return (

Layer GIS:{" "} {noGeomScan.remoteGisCount.toLocaleString("ro-RO")} {" "} terenuri {noGeomScan.remoteCladiriCount > 0 && ( <> {" + "} {noGeomScan.remoteCladiriCount.toLocaleString( "ro-RO", )} {" "} clădiri )} {" · "} Lista imobile:{" "} {noGeomScan.totalImmovables.toLocaleString("ro-RO")} {" (estimat "} ~ {Math.max( 0, noGeomScan.totalImmovables - noGeomScan.remoteGisCount, ).toLocaleString("ro-RO")} {" fără geometrie)"}

Cele fără geometrie există în baza de date eTerra dar nu au contur desenat în layerul GIS.

{localDbLine}
{/* Quality breakdown of no-geom items */} {scanDone && noGeomScan.noGeomCount > 0 && (

Calitate date (din{" "} {noGeomScan.noGeomCount.toLocaleString("ro-RO")} fără geometrie):

Cu nr. cadastral eTerra:{" "} {noGeomScan.qualityBreakdown.withCadRef.toLocaleString( "ro-RO", )} Cu nr. CF/LB:{" "} {noGeomScan.qualityBreakdown.withPaperLb.toLocaleString( "ro-RO", )} Cu nr. cad. pe hârtie:{" "} {noGeomScan.qualityBreakdown.withPaperCad.toLocaleString( "ro-RO", )} Cu suprafață:{" "} {noGeomScan.qualityBreakdown.withArea.toLocaleString( "ro-RO", )} Active (status=1):{" "} {noGeomScan.qualityBreakdown.withActiveStatus.toLocaleString( "ro-RO", )} Cu carte funciară:{" "} {noGeomScan.qualityBreakdown.withLandbook.toLocaleString( "ro-RO", )}
Utilizabile:{" "} {noGeomScan.qualityBreakdown.useful.toLocaleString( "ro-RO", )} {noGeomScan.qualityBreakdown.empty > 0 && ( Filtrate (fără CF/inactive/fără date):{" "} {noGeomScan.qualityBreakdown.empty.toLocaleString( "ro-RO", )} )}
)} {includeNoGeom && (

{noGeomScan.qualityBreakdown.empty > 0 ? `Din ${noGeomScan.noGeomCount.toLocaleString("ro-RO")} fără geometrie, ~${noGeomScan.qualityBreakdown.useful.toLocaleString("ro-RO")} vor fi importate (imobile electronice cu CF). ${noGeomScan.qualityBreakdown.empty.toLocaleString("ro-RO")} vor fi filtrate (fără carte funciară, inactive sau fără date).` : "Vor fi importate în DB și incluse în CSV + Magic GPKG (coloana HAS_GEOMETRY=0/1)."}{" "} În GPKG de bază apar doar cele cu geometrie.

)} {workflowPreview}
); // Scan done, all parcels have geometry (or totalImmovables=0 ⇒ workspace issue) if (scanDone && !hasNoGeomParcels) return (
{noGeomScan.totalImmovables > 0 ? ( <> Toate cele{" "} {noGeomScan.totalImmovables.toLocaleString("ro-RO")}{" "} imobile din eTerra au geometrie — nimic de importat suplimentar. {noGeomScan.localDbTotal > 0 && ( ({noGeomScan.localDbTotal.toLocaleString("ro-RO")}{" "} în DB local {noGeomScan.localDbEnriched > 0 && `, ${noGeomScan.localDbEnriched.toLocaleString("ro-RO")} îmbogățite`} {noGeomScan.localDbEnriched > 0 && noGeomScan.localDbEnrichedComplete < noGeomScan.localDbEnriched && ( {` (${(noGeomScan.localDbEnriched - noGeomScan.localDbEnrichedComplete).toLocaleString("ro-RO")} incomplete)`} )} {noGeomScan.localSyncFresh && ", proaspăt"}) )} ) : ( <> Nu s-au găsit imobile în lista eTerra pentru acest UAT. Verifică sesiunea eTerra. )}
); return null; })()} {/* ── Background sync + Download from DB ──────────────── */} {sirutaValid && ( {/* Row 1: Section label */}
Procesare fundal & descărcare din DB — pornește sincronizarea, închide pagina, descarcă mai târziu
{/* Include no-geom toggle (works independently of scan) */} {session.connected && ( )} {/* Row 2: Background sync buttons */} {session.connected && (
)} {/* Row 3: Download from DB buttons */} {dbTotalFeatures > 0 && (
)} {!session.connected && dbTotalFeatures === 0 && (

Conectează-te la eTerra pentru a porni sincronizarea fundal, sau sincronizează mai întâi date în baza locală.

)}
)} {/* Background sync progress */} {bgJobId && bgProgress && bgProgress.status !== "unknown" && ( {/* Label */}
Sincronizare fundal {bgProgress.status === "running" && ( (poți închide pagina) )}
{/* Phase trail */}
{bgPhaseTrail.map((p, i) => ( {i > 0 && } {p} ))}
{/* Progress info */}
{bgProgress.status === "running" && ( )} {bgProgress.status === "done" && ( )} {bgProgress.status === "error" && ( )}

{bgProgress.phase}

{bgProgress.note && (

{bgProgress.note}

)} {bgProgress.message && (

{bgProgress.message}

)}
{bgProgress.total && bgProgress.total > 0 ? Math.round( (bgProgress.downloaded / bgProgress.total) * 100, ) : 0} %
{/* Bar */}
0 ? Math.round((bgProgress.downloaded / bgProgress.total) * 100) : 0)}%`, }} />
{/* Done — show download from DB button */} {bgProgress.status === "done" && (
)} )} {/* 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 noGeomTotal = 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; noGeomTotal += layer.noGeomCount ?? 0; 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")} )} {noGeomTotal > 0 && ( Fără geom: {noGeomTotal.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 && ( )} ); })}
{/* Dashboard panel (expanded below card) */} {dashboardSiruta === uat.siruta && ( setDashboardSiruta(null)} /> )}
); })} )}
); }