diff --git a/src/app/api/geoportal/setup-enrichment-views/route.ts b/src/app/api/geoportal/setup-enrichment-views/route.ts new file mode 100644 index 0000000..971e906 --- /dev/null +++ b/src/app/api/geoportal/setup-enrichment-views/route.ts @@ -0,0 +1,88 @@ +/** + * GET /api/geoportal/setup-enrichment-views — check if views exist + * POST /api/geoportal/setup-enrichment-views — create enrichment status views + * + * Creates gis_terenuri_status and gis_cladiri_status views that include + * enrichment metadata (has_enrichment, has_building, build_legal). + * Martin serves these as vector tile sources, MapLibre uses them for + * data-driven styling in the ParcelSync Harta tab. + * + * IMPORTANT: Does NOT modify existing gis_terenuri/gis_cladiri views + * used by the Geoportal module. + */ +import { NextResponse } from "next/server"; +import { prisma } from "@/core/storage/prisma"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const VIEWS = [ + { + name: "gis_terenuri_status", + sql: `CREATE OR REPLACE VIEW gis_terenuri_status AS + SELECT + f.id, + f."layerId" AS layer_id, + f.siruta, + f."objectId" AS object_id, + f."cadastralRef" AS cadastral_ref, + f."areaValue" AS area_value, + f."isActive" AS is_active, + CASE WHEN f.enrichment IS NOT NULL AND f."enrichedAt" IS NOT NULL THEN 1 ELSE 0 END AS has_enrichment, + COALESCE((f.enrichment->>'HAS_BUILDING')::int, 0) AS has_building, + COALESCE((f.enrichment->>'BUILD_LEGAL')::int, 0) AS build_legal, + f.geom + FROM "GisFeature" f + WHERE f.geom IS NOT NULL + AND (f."layerId" LIKE 'TERENURI%' OR f."layerId" LIKE 'CADGEN_LAND%')`, + }, + { + name: "gis_cladiri_status", + sql: `CREATE OR REPLACE VIEW gis_cladiri_status AS + SELECT + f.id, + f."layerId" AS layer_id, + f.siruta, + f."objectId" AS object_id, + f."cadastralRef" AS cadastral_ref, + f."areaValue" AS area_value, + f."isActive" AS is_active, + f.geom + FROM "GisFeature" f + WHERE f.geom IS NOT NULL + AND (f."layerId" LIKE 'CLADIRI%' OR f."layerId" LIKE 'CADGEN_BUILDING%')`, + }, +]; + +/** GET — check if enrichment views exist */ +export async function GET() { + try { + const existing = await prisma.$queryRaw` + SELECT viewname FROM pg_views + WHERE schemaname = 'public' AND (viewname = 'gis_terenuri_status' OR viewname = 'gis_cladiri_status') + ` as Array<{ viewname: string }>; + + const existingNames = new Set(existing.map((r) => r.viewname)); + const missing = VIEWS.filter((v) => !existingNames.has(v.name)).map((v) => v.name); + + return NextResponse.json({ ready: missing.length === 0, missing }); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare"; + return NextResponse.json({ ready: false, missing: VIEWS.map((v) => v.name), error: msg }); + } +} + +/** POST — create enrichment views (idempotent) */ +export async function POST() { + const results: string[] = []; + try { + for (const v of VIEWS) { + await prisma.$executeRawUnsafe(v.sql); + results.push(`${v.name} OK`); + } + return NextResponse.json({ status: "ok", results }); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare"; + return NextResponse.json({ status: "error", results, error: msg }, { status: 500 }); + } +} diff --git a/src/app/api/geoportal/uat-bounds/route.ts b/src/app/api/geoportal/uat-bounds/route.ts new file mode 100644 index 0000000..08f86b5 --- /dev/null +++ b/src/app/api/geoportal/uat-bounds/route.ts @@ -0,0 +1,52 @@ +/** + * GET /api/geoportal/uat-bounds?siruta=57582 + * + * Returns WGS84 bounding box for a UAT from PostGIS geometry. + * Used by ParcelSync Harta tab to zoom to selected UAT. + */ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/core/storage/prisma"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + const siruta = req.nextUrl.searchParams.get("siruta"); + if (!siruta) { + return NextResponse.json({ error: "siruta required" }, { status: 400 }); + } + + try { + const rows = await prisma.$queryRaw` + SELECT + ST_XMin(ST_Transform(geom, 4326)) AS min_lng, + ST_YMin(ST_Transform(geom, 4326)) AS min_lat, + ST_XMax(ST_Transform(geom, 4326)) AS max_lng, + ST_YMax(ST_Transform(geom, 4326)) AS max_lat + FROM "GisUat" + WHERE siruta = ${siruta} AND geom IS NOT NULL + LIMIT 1 + ` as Array<{ + min_lng: number; + min_lat: number; + max_lng: number; + max_lat: number; + }>; + + const first = rows[0]; + if (!first) { + return NextResponse.json({ error: "UAT not found or no geometry" }, { status: 404 }); + } + + return NextResponse.json({ + siruta, + bounds: [ + [first.min_lng, first.min_lat], + [first.max_lng, first.max_lat], + ], + }); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/src/modules/parcel-sync/components/connection-pill.tsx b/src/modules/parcel-sync/components/connection-pill.tsx new file mode 100644 index 0000000..2dde17b --- /dev/null +++ b/src/modules/parcel-sync/components/connection-pill.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { + Loader2, + LogOut, + Wifi, + WifiOff, + AlertTriangle, +} from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/components/ui/dropdown-menu"; +import { cn } from "@/shared/lib/utils"; +import type { SessionStatus } from "./parcel-sync-types"; + +/* ------------------------------------------------------------------ */ +/* Connection Status Pill */ +/* ------------------------------------------------------------------ */ + +export 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 \u00een mentenan\u021b\u0103 +

+

+ Platforma ANCPI nu este disponibil\u0103 momentan. Conectarea va fi + reactivat\u0103 automat c\u00e2nd 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\u00e2nd \u00eencepi s\u0103 scrii un UAT.

+

+ Creden\u021bialele sunt preluate din configurarea serverului. +

+
+ )} + + {/* Error detail (only when NOT maintenance) */} + {!session.connected && + connectionError && + !session.eterraMaintenance && ( +
+

+ Conexiunea automat\u0103 a e\u0219uat. Verific\u0103 creden\u021bialele 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 && ( + + {" "} + \u2014 {session.activeJobPhase} + + )} +

+
+ )} + +
+ +
+ + )} +
+
+ ); +} diff --git a/src/modules/parcel-sync/components/parcel-sync-module.tsx b/src/modules/parcel-sync/components/parcel-sync-module.tsx index 6a97016..e77c19b 100644 --- a/src/modules/parcel-sync/components/parcel-sync-module.tsx +++ b/src/modules/parcel-sync/components/parcel-sync-module.tsx @@ -1,351 +1,38 @@ "use client"; -import { useState, useEffect, useCallback, useMemo, useRef, useDeferredValue } from "react"; +import { useState, useEffect, useCallback, useRef, useDeferredValue } from "react"; import { Search, Download, - CheckCircle2, - XCircle, - Loader2, - MapPin, Layers, - Sparkles, - ChevronDown, - ChevronUp, - FileDown, - LogOut, - Wifi, - WifiOff, - ClipboardCopy, - Trash2, - Plus, - RefreshCw, + MapPin, Database, - HardDrive, - Clock, - ArrowDownToLine, - AlertTriangle, - BarChart3, + FileText, + Map as MapIcon, } 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 { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/shared/components/ui/tooltip"; -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, FileText, Archive, Map as MapIcon } from "lucide-react"; -import dynamic from "next/dynamic"; -import { UatDashboard } from "./uat-dashboard"; +import type { + SessionStatus, + UatEntry, + SyncRunInfo, + DbSummary, +} from "./parcel-sync-types"; +import { normalizeText } from "./parcel-sync-types"; +import { ConnectionPill } from "./connection-pill"; import { EpayConnect, type EpaySessionStatus } from "./epay-connect"; -import { EpayOrderButton } from "./epay-order-button"; -import { EpayTab } from "./epay-tab"; - -/* MapLibre uses WebGL — must disable SSR */ -const MapViewer = dynamic( - () => - import("@/modules/geoportal/components/map-viewer").then((m) => ({ - default: m.MapViewer, - })), - { - ssr: false, - loading: () => ( -
-

Se incarca harta...

-
- ), - } -); - -/* ------------------------------------------------------------------ */ -/* 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"; -} - -/** Format ISO date as DD.MM.YYYY (no time) */ -function formatShortDate(iso?: string | null) { - if (!iso) return "—"; - const d = new Date(iso); - const dd = String(d.getDate()).padStart(2, "0"); - const mm = String(d.getMonth() + 1).padStart(2, "0"); - return `${dd}.${mm}.${d.getFullYear()}`; -} - -/* ------------------------------------------------------------------ */ -/* 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} - - )} -

-
- )} - -
- -
- - )} -
-
- ); -} +import { SearchTab } from "./tabs/search-tab"; +import { LayersTab } from "./tabs/layers-tab"; +import { ExportTab } from "./tabs/export-tab"; +import { DatabaseTab } from "./tabs/database-tab"; +import { CfTab } from "./tabs/cf-tab"; +import { MapTab } from "./tabs/map-tab"; /* ------------------------------------------------------------------ */ /* Main Component */ @@ -371,164 +58,27 @@ export function ParcelSyncModule() { 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 - >({}); + /* ── Sync status (shared between layers + export tabs) ──────── */ + const [syncLocalCounts, setSyncLocalCounts] = useState>({}); 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; - }; + /* ── Global DB summary ──────────────────────────────────────── */ 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); + /* ── Export state (shared flag) ─────────────────────────────── */ + const [exporting, setExporting] = useState(false); - /* ── 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); - - /* ── ePay status (for CF extract features) ──────────────────── */ + /* ── ePay status ────────────────────────────────────────────── */ const [epayStatus, setEpayStatus] = useState({ connected: false }); - /** CF status map: nrCadastral -> "valid" | "expired" | "none" | "processing" */ - const [cfStatusMap, setCfStatusMap] = useState>({}); - /** Latest completed extract IDs per nrCadastral */ - const [cfLatestIds, setCfLatestIds] = useState>({}); - /** Expiry dates per nrCadastral (ISO string) */ - const [cfExpiryDates, setCfExpiryDates] = useState>({}); - /** Whether we're currently loading CF statuses */ - const [cfStatusLoading, setCfStatusLoading] = useState(false); - /** List CF batch order state */ - const [listCfOrdering, setListCfOrdering] = useState(false); - const [listCfOrderResult, setListCfOrderResult] = useState(""); - /** Downloading ZIP state */ - const [listCfDownloading, setListCfDownloading] = useState(false); - /* ── 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); + /* ── Derived ────────────────────────────────────────────────── */ + const sirutaValid = siruta.length > 0 && /^\d+$/.test(siruta); + const selectedUat = uatData.find((u) => u.siruta === siruta); /* ════════════════════════════════════════════════════════════ */ - /* Load UAT data + check server session on mount */ + /* Session management */ /* ════════════════════════════════════════════════════════════ */ const fetchSession = useCallback(async () => { @@ -536,12 +86,7 @@ export function ParcelSyncModule() { 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 - ) { + if (prev.eterraMaintenance && data.eterraAvailable && !data.eterraMaintenance) { autoConnectAttempted.current = false; } return data; @@ -554,14 +99,13 @@ export function ParcelSyncModule() { }, []); useEffect(() => { - // Load UATs from local DB (fast — no eTerra needed) + // Load UATs from local DB fetch("/api/eterra/uats") .then((res) => res.json()) .then((data: { uats?: UatEntry[]; total?: number }) => { if (data.uats && data.uats.length > 0) { setUatData(data.uats); } else { - // 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()) @@ -570,51 +114,23 @@ export function ParcelSyncModule() { } }) .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) */ - /* ════════════════════════════════════════════════════════════ */ - + /* ── Reload UATs when session connects ─────────────────────── */ 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()) @@ -628,13 +144,8 @@ export function ParcelSyncModule() { prevConnected.current = session.connected; }, [session.connected]); - /* ════════════════════════════════════════════════════════════ */ - /* UAT autocomplete filter */ - /* ════════════════════════════════════════════════════════════ */ - - // useDeferredValue lets React prioritize the input update over the filter + /* ── UAT autocomplete filter ───────────────────────────────── */ const deferredUatQuery = useDeferredValue(uatQuery); - useEffect(() => { const raw = deferredUatQuery.trim(); if (raw.length < 2) { @@ -645,30 +156,22 @@ export function ParcelSyncModule() { const query = normalizeText(raw); const nameMatches: typeof uatData = []; const countyOnlyMatches: typeof uatData = []; - for (const item of uatData) { if (isDigit) { if (item.siruta.startsWith(raw)) nameMatches.push(item); } else { const nameMatch = normalizeText(item.name).includes(query); - const countyMatch = - item.county && normalizeText(item.county).includes(query); + const countyMatch = item.county && normalizeText(item.county).includes(query); if (nameMatch) nameMatches.push(item); else if (countyMatch) countyOnlyMatches.push(item); } } - - const results = [...nameMatches, ...countyOnlyMatches].slice(0, 12); - setUatResults(results); + setUatResults([...nameMatches, ...countyOnlyMatches].slice(0, 12)); }, [deferredUatQuery, uatData]); - /* ════════════════════════════════════════════════════════════ */ - /* Auto-connect: trigger on first UAT keystroke */ - /* ════════════════════════════════════════════════════════════ */ - + /* ── Auto-connect 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); @@ -687,28 +190,23 @@ export function ParcelSyncModule() { 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ță", + eterraHealthMessage: data.error ?? "eTerra \u00een mentenan\u021b\u0103", })); - // Allow retry later when maintenance ends autoConnectAttempted.current = false; } else { setConnectionError(data.error ?? "Eroare conectare"); } } catch { - setConnectionError("Eroare rețea"); + setConnectionError("Eroare re\u021bea"); } setConnecting(false); }, [session.connected, session.eterraMaintenance, connecting, fetchSession]); - /* ════════════════════════════════════════════════════════════ */ - /* Disconnect */ - /* ════════════════════════════════════════════════════════════ */ - + /* ── Disconnect ────────────────────────────────────────────── */ const handleDisconnect = useCallback(async () => { try { const res = await fetch("/api/eterra/session", { @@ -716,346 +214,19 @@ export function ParcelSyncModule() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "disconnect" }), }); - const data = (await res.json()) as { - success?: boolean; - error?: string; - }; + 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"); + setConnectionError("Eroare re\u021bea"); } }, []); - /* ════════════════════════════════════════════════════════════ */ - /* 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 */ - /* ════════════════════════════════════════════════════════════ */ - + /* ── Sync status ───────────────────────────────────────────── */ const fetchSyncStatus = useCallback(async () => { if (!siruta) return; try { @@ -1072,893 +243,28 @@ export function ParcelSyncModule() { } }, [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], - ); + /* ── 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); + }, []); - 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]); - - /** Fetch CF extract status for a set of cadastral numbers */ - const fetchCfStatuses = useCallback(async (cadastralNumbers: string[]) => { - if (cadastralNumbers.length === 0) return; - setCfStatusLoading(true); - try { - const nrs = cadastralNumbers.join(","); - const res = await fetch(`/api/ancpi/orders?nrCadastral=${encodeURIComponent(nrs)}&limit=1`); - const data = (await res.json()) as { - statusMap?: Record; - latestById?: Record; - }; - if (data.statusMap) { - setCfStatusMap((prev) => ({ ...prev, ...data.statusMap })); - } - if (data.latestById) { - const idMap: Record = {}; - const expiryMap: Record = {}; - for (const [nr, rec] of Object.entries(data.latestById)) { - if (rec && typeof rec === "object" && "id" in rec) { - idMap[nr] = (rec as { id: string }).id; - const expires = (rec as { expiresAt: string | null }).expiresAt; - if (expires) { - expiryMap[nr] = expires; - } - } - } - setCfLatestIds((prev) => ({ ...prev, ...idMap })); - setCfExpiryDates((prev) => ({ ...prev, ...expiryMap })); - } - } catch { - /* silent */ - } finally { - setCfStatusLoading(false); - } - }, []); - - /** Refresh CF statuses for current search results + list items */ - const refreshCfStatuses = useCallback(() => { - const allNrs = new Set(); - for (const r of searchResults) { - if (r.nrCad) allNrs.add(r.nrCad); - } - for (const p of searchList) { - if (p.nrCad) allNrs.add(p.nrCad); - } - if (allNrs.size > 0) { - void fetchCfStatuses(Array.from(allNrs)); - } - }, [searchResults, searchList, fetchCfStatuses]); - - // Auto-fetch CF statuses when search results change - useEffect(() => { - const nrs = searchResults.map((r) => r.nrCad).filter(Boolean); - if (nrs.length > 0) { - void fetchCfStatuses(nrs); - } - }, [searchResults, fetchCfStatuses]); - - // Auto-fetch CF statuses when list changes - useEffect(() => { - const nrs = searchList.map((p) => p.nrCad).filter(Boolean); - if (nrs.length > 0) { - void fetchCfStatuses(nrs); - } - }, [searchList, fetchCfStatuses]); - - // 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]); - - // Resolve selected UAT entry for ePay order context (needed by CF order handlers below) - const selectedUat = useMemo( - () => uatData.find((u) => u.siruta === siruta), - [uatData, siruta], - ); - - /* ════════════════════════════════════════════════════════════ */ - /* List CF extract ordering + ZIP download */ - /* ════════════════════════════════════════════════════════════ */ - - /** Order CF extracts for list items: skip valid, re-order expired, order new */ - const handleListCfOrder = useCallback(async () => { - if (!siruta || searchList.length === 0 || listCfOrdering) return; - - // Categorize parcels - const toOrder: typeof searchList = []; - const toReorder: typeof searchList = []; - const alreadyValid: typeof searchList = []; - - for (const p of searchList) { - const status = cfStatusMap[p.nrCad]; - if (status === "valid") { - alreadyValid.push(p); - } else if (status === "expired") { - toReorder.push(p); - } else { - // "none" or "processing" or unknown - if (status !== "processing") { - toOrder.push(p); - } - } - } - - const newCount = toOrder.length; - const updateCount = toReorder.length; - const existingCount = alreadyValid.length; - - if (newCount === 0 && updateCount === 0) { - setListCfOrderResult(`Toate cele ${existingCount} extrase sunt valide.`); - return; - } - - // Confirm - const msg = [ - newCount > 0 ? `${newCount} extrase noi` : null, - updateCount > 0 ? `${updateCount} actualizari` : null, - existingCount > 0 ? `${existingCount} existente (skip)` : null, - ].filter(Boolean).join(", "); - - if (!window.confirm(`Comanda extrase CF:\n${msg}\n\nContinui?`)) return; - - setListCfOrdering(true); - setListCfOrderResult(""); - - try { - const allToProcess = [...toOrder, ...toReorder]; - const parcels = allToProcess.map((p) => ({ - nrCadastral: p.nrCad, - siruta, - judetIndex: 0, - judetName: selectedUat?.county ?? "", - uatId: 0, - uatName: selectedUat?.name ?? "", - })); - - const res = await fetch("/api/ancpi/order", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ parcels }), - }); - - const data = (await res.json()) as { orders?: unknown[]; error?: string }; - if (!res.ok || data.error) { - setListCfOrderResult(`Eroare: ${data.error ?? "Eroare la comanda"}`); - } else { - const count = data.orders?.length ?? allToProcess.length; - setListCfOrderResult(`${count} extras${count > 1 ? "e" : ""} CF trimis${count > 1 ? "e" : ""} la procesare.`); - - // Start polling for completion and refresh statuses periodically - const pollInterval = setInterval(() => { - void refreshCfStatuses(); - }, 10_000); - - // Stop after 5 minutes - setTimeout(() => clearInterval(pollInterval), 5 * 60 * 1000); - } - } catch { - setListCfOrderResult("Eroare retea."); - } finally { - setListCfOrdering(false); - } - }, [siruta, searchList, listCfOrdering, cfStatusMap, selectedUat, refreshCfStatuses]); - - /** Download all valid CF extracts from list as ZIP */ - const handleListCfDownloadZip = useCallback(async () => { - if (searchList.length === 0 || listCfDownloading) return; - - // Collect valid extract IDs in list order - const ids: string[] = []; - for (const p of searchList) { - const status = cfStatusMap[p.nrCad]; - const extractId = cfLatestIds[p.nrCad]; - if (status === "valid" && extractId) { - ids.push(extractId); - } - } - - if (ids.length === 0) { - setListCfOrderResult("Niciun extras CF valid in lista."); - return; - } - - setListCfDownloading(true); - try { - const res = await fetch(`/api/ancpi/download-zip?ids=${ids.join(",")}`); - if (!res.ok) throw new Error("Eroare descarcare ZIP"); - - const blob = await res.blob(); - const cd = res.headers.get("Content-Disposition") ?? ""; - const match = /filename="?([^"]+)"?/.exec(cd); - const filename = match?.[1] ? decodeURIComponent(match[1]) : "Extrase_CF_lista.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 { - setListCfOrderResult("Eroare la descarcarea ZIP."); - } finally { - setListCfDownloading(false); - } - }, [searchList, cfStatusMap, cfLatestIds, listCfDownloading]); - - /* ════════════════════════════════════════════════════════════ */ - /* 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`; - }; + void fetchDbSummary(); + }, [fetchDbSummary]); /* ════════════════════════════════════════════════════════════ */ /* Render */ @@ -1970,18 +276,17 @@ export function ParcelSyncModule() {
{/* UAT + Connection row */}
- {/* UAT autocomplete — always visible */} + {/* UAT autocomplete */}
{ setUatQuery(e.target.value); setShowUatResults(true); - // Auto-connect on first keystroke if (e.target.value.trim().length >= 1) { void triggerAutoConnect(); } @@ -1993,19 +298,14 @@ export function ParcelSyncModule() { />
- {/* Selected indicator chip */} {sirutaValid && (
- + SIRUTA {siruta}
)} - {/* Dropdown */} {showUatResults && uatResults.length > 0 && (
{uatResults.map((item) => ( @@ -2022,17 +322,14 @@ export function ParcelSyncModule() { setSiruta(item.siruta); setWorkspacePk(item.workspacePk ?? null); setShowUatResults(false); - setSearchResults([]); }} > {item.name} - - ({item.siruta}) - + ({item.siruta}) {item.county && ( - –{" "} + {"\u2013 "} jud. {item.county} @@ -2052,10 +349,7 @@ export function ParcelSyncModule() { {/* Connection pills */}
- + - Căutare Parcele + C\u0103utare Parcele @@ -2094,2706 +388,64 @@ export function ParcelSyncModule() {
- {/* ═══════════════════════════════════════════════════════ */} - {/* Tab 1: Parcel search */} - {/* ═══════════════════════════════════════════════════════ */} + {/* ═══════════════════════ Tab content ═════════════════════ */} + - {!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. -

- )} -
-
- - - {/* CF Extract status + actions */} - {p.immovablePk && sirutaValid && (() => { - const cfStatus = cfStatusMap[p.nrCad]; - const extractId = cfLatestIds[p.nrCad]; - const cfExpiry = cfExpiryDates[p.nrCad]; - if (cfStatus === "valid") { - return ( - -
- - - - Extras CF - - - - {cfExpiry ? `Valid pana la ${formatShortDate(cfExpiry)}` : "Extras CF valid"} - - - {extractId && ( - - - - - Descarca extras CF - - )} -
-
- ); - } - if (cfStatus === "expired") { - return ( - -
- - - - Expirat - - - - {cfExpiry ? `Expirat pe ${formatShortDate(cfExpiry)}` : "Extras CF expirat"} - - - -
-
- ); - } - if (cfStatus === "processing") { - return ( - - - - - Se proceseaza... - - - Comanda in curs de procesare - - - ); - } - // "none" or unknown - return ( - - ); - })()} -
-
- - {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"} - -
-
- - - {/* CF Extract status + actions */} - {r.immovablePk && sirutaValid && (() => { - const cfStatus = cfStatusMap[r.nrCad]; - const extractId = cfLatestIds[r.nrCad]; - const cfExpiry = cfExpiryDates[r.nrCad]; - if (cfStatus === "valid") { - return ( - -
- - - - Extras CF - - - - {cfExpiry ? `Valid pana la ${formatShortDate(cfExpiry)}` : "Extras CF valid"} - - - {extractId && ( - - - - - Descarca extras CF - - )} -
-
- ); - } - if (cfStatus === "expired") { - return ( - -
- - - - Expirat - - - - {cfExpiry ? `Expirat pe ${formatShortDate(cfExpiry)}` : "Extras CF expirat"} - - - -
-
- ); - } - if (cfStatus === "processing") { - return ( - - - - - Se proceseaza... - - - Comanda in curs de procesare - - - ); - } - return ( - - ); - })()} -
-
- -
- {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) -

-
- - - {/* Download all valid CF extracts as ZIP */} - {searchList.some((p) => cfStatusMap[p.nrCad] === "valid") && (() => { - const validCount = searchList.filter((p) => cfStatusMap[p.nrCad] === "valid").length; - return ( - - - - - - {`Descarca ZIP cu ${validCount} extrase valide din lista`} - - - ); - })()} - {/* Order CF extracts for list */} - {epayStatus.connected && (() => { - const newCount = searchList.filter((p) => { - const s = cfStatusMap[p.nrCad]; - return s !== "valid" && s !== "expired" && s !== "processing"; - }).length; - const updateCount = searchList.filter((p) => cfStatusMap[p.nrCad] === "expired").length; - const totalCredits = newCount + updateCount; - const validCount = searchList.filter((p) => cfStatusMap[p.nrCad] === "valid").length; - return ( - - - - - - - {`Comanda ${newCount} extrase noi + ${updateCount} actualizari = ${totalCredits} credite. ${validCount} existente valide raman.`} - - - - ); - })()} -
-
- - {/* Order result message */} - {listCfOrderResult && ( -

- {listCfOrderResult} -

- )} - -
- - - - - - - - - - - - - - {searchList.map((p, idx) => { - const cfStatus = cfStatusMap[p.nrCad]; - const cfExpiry = cfExpiryDates[p.nrCad]; - return ( - - - - - - - - - - ); - })} - -
- # - - Nr. Cad - - Nr. CF - - Suprafata - - Proprietari - - Extras CF -
- {idx + 1} - - {p.nrCad} - - {p.nrCF || "\u2014"} - - {p.suprafata != null - ? formatArea(p.suprafata) - : "\u2014"} - - {p.proprietari || "\u2014"} - - - - - {cfStatus === "valid" ? ( - - Valid - - ) : cfStatus === "expired" ? ( - - Expirat - - ) : cfStatus === "processing" ? ( - - Procesare - - ) : ( - - Lipsa - - )} - - - {cfStatus === "valid" - ? (cfExpiry ? `Extras CF valid pana la ${formatShortDate(cfExpiry)}` : "Extras CF valid") - : cfStatus === "expired" - ? (cfExpiry ? `Extras CF expirat pe ${formatShortDate(cfExpiry)}. Va fi actualizat automat la 'Scoate Extrase CF'.` : "Extras CF expirat. Va fi actualizat automat la 'Scoate Extrase CF'.") - : cfStatus === "processing" - ? "Comanda in curs de procesare" - : "Nu exista extras CF. Apasa 'Scoate Extrase CF' pentru a comanda."} - - - - - -
-
-
-
- )} - - )} +
- {/* ═══════════════════════════════════════════════════════ */} - {/* 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 */} - -
-
-
- - - Conectare QGIS - -
- {!postgisResult?.success && ( - - - - - - -

- Operatie sigura, reversibila -

-

- Creeaza coloane native PostGIS + view-uri read-only - pentru QGIS. Nu modifica datele existente. Ruleaza o - singura data (~30s). -

-
-
-
- )} -
-
- - {postgisResult ? ( - postgisResult.success ? ( -
-
- - - QGIS compatibil — gata de conectare - -
-
-

- Cum te conectezi din QGIS: -

-
    -
  1. - QGIS → Layer → Add Layer → Add PostGIS Layers -
  2. -
  3. New connection:
  4. -
-
-

- Host: 10.10.10.166 -

-

- Port: 5432 -

-

- Database: architools_db -

-

- Username: architools_user -

-
-

- View-uri disponibile (read-only): -

-
- gis_terenuri, gis_cladiri, gis_documentatii, - gis_administrativ -
-

- SRID: 3844 (Stereo70) -

- {postgisResult.details && ( -

- {String( - ( - postgisResult.details as { - totalFeaturesWithGeom?: number; - } - ).totalFeaturesWithGeom ?? 0, - )}{" "} - features cu geometrie nativa -

- )} -
-
- ) : ( -
- -
-

- PostGIS nu este instalat pe server -

-

- Contacteaza administratorul pentru instalare: -

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

- Permite conectarea din QGIS direct la baza de date pentru - vizualizare si analiza spatiala a parcelelor, cladirilor - si limitelor UAT. -

-

- Apasa butonul pentru a activa — creeaza view-uri read-only - (nu modifica datele, nu afecteaza performanta aplicatiei). -

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

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

-
- - {progressPct}% - -
-
-
-
- - - )} + void fetchSyncStatus()} + exporting={exporting} + /> - {/* ═══════════════════════════════════════════════════════ */} - {/* 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. -
  7. Generare GPKG + CSV
  8. -
  9. Comprimare ZIP + descărcare
  10. -
-
- ); - - // 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 */} -
-
-
- - - )} + void fetchSyncStatus()} + onDbRefresh={() => void fetchDbSummary()} + exporting={exporting} + setExporting={setExporting} + /> - {/* ═══════════════════════════════════════════════════════ */} - {/* 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)} - /> - )} -
- ); - })} - - )} + void fetchDbSummary()} + />
- {/* ═══════════════════════════════════════════════════════ */} - {/* Tab 5: Extrase CF */} - {/* ═══════════════════════════════════════════════════════ */} - + - {/* ═══════════════════════════════════════════════════════ */} - {/* Tab 6: Harta (MapLibre GL) */} - {/* ═══════════════════════════════════════════════════════ */} -
- -
+
); diff --git a/src/modules/parcel-sync/components/parcel-sync-types.ts b/src/modules/parcel-sync/components/parcel-sync-types.ts new file mode 100644 index 0000000..dba715d --- /dev/null +++ b/src/modules/parcel-sync/components/parcel-sync-types.ts @@ -0,0 +1,124 @@ +import type { ParcelDetail } from "@/app/api/eterra/search/route"; +import type { OwnerSearchResult } from "@/app/api/eterra/search-owner/route"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export type UatEntry = { + siruta: string; + name: string; + county?: string; + workspacePk?: number; + localFeatures?: number; +}; + +export 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; +}; + +export type ExportProgress = { + jobId: string; + downloaded: number; + total?: number; + status: "running" | "done" | "error" | "unknown"; + phase?: string; + message?: string; + note?: string; + phaseCurrent?: number; + phaseTotal?: number; +}; + +export type SyncRunInfo = { + id: string; + layerId: string; + status: string; + totalRemote: number; + totalLocal: number; + newFeatures: number; + removedFeatures: number; + startedAt: string; + completedAt?: string; +}; + +export 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; +}; + +export type DbSummary = { + uats: DbUatSummary[]; + totalFeatures: number; + totalUats: number; +}; + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +export const normalizeText = (text: string) => + text + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .trim(); + +export function formatDate(iso?: string | null) { + if (!iso) return "\u2014"; + return new Date(iso).toLocaleDateString("ro-RO", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +export function formatArea(val?: number | null) { + if (val == null) return "\u2014"; + return val.toLocaleString("ro-RO", { maximumFractionDigits: 2 }) + " mp"; +} + +/** Format ISO date as DD.MM.YYYY (no time) */ +export function formatShortDate(iso?: string | null) { + if (!iso) return "\u2014"; + const d = new Date(iso); + const dd = String(d.getDate()).padStart(2, "0"); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + return `${dd}.${mm}.${d.getFullYear()}`; +} + +export function relativeTime(date: Date | null) { + if (!date) return "niciodat\u0103"; + 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`; +} + +/* Re-export for convenience */ +export type { ParcelDetail, OwnerSearchResult }; diff --git a/src/modules/parcel-sync/components/tabs/cf-tab.tsx b/src/modules/parcel-sync/components/tabs/cf-tab.tsx new file mode 100644 index 0000000..ef5add3 --- /dev/null +++ b/src/modules/parcel-sync/components/tabs/cf-tab.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { EpayTab } from "../epay-tab"; + +export function CfTab() { + return ; +} diff --git a/src/modules/parcel-sync/components/tabs/database-tab.tsx b/src/modules/parcel-sync/components/tabs/database-tab.tsx new file mode 100644 index 0000000..2cd2c27 --- /dev/null +++ b/src/modules/parcel-sync/components/tabs/database-tab.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { useState } from "react"; +import { + Loader2, + Database, + RefreshCw, + Sparkles, + BarChart3, +} from "lucide-react"; +import { Button } from "@/shared/components/ui/button"; +import { Badge } from "@/shared/components/ui/badge"; +import { Card, CardContent } from "@/shared/components/ui/card"; +import { cn } from "@/shared/lib/utils"; +import type { DbSummary } from "../parcel-sync-types"; +import { relativeTime } from "../parcel-sync-types"; +import { + LAYER_CATEGORY_LABELS, + type LayerCategory, + findLayerById, +} from "../../services/eterra-layers"; +import { UatDashboard } from "../uat-dashboard"; + +/* ------------------------------------------------------------------ */ +/* Props */ +/* ------------------------------------------------------------------ */ + +type DatabaseTabProps = { + siruta: string; + sirutaValid: boolean; + dbSummary: DbSummary | null; + dbSummaryLoading: boolean; + onRefresh: () => void; +}; + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function DatabaseTab({ + siruta, + sirutaValid, + dbSummary, + dbSummaryLoading, + onRefresh, +}: DatabaseTabProps) { + const [dashboardSiruta, setDashboardSiruta] = useState(null); + + /* ── Loading state ──────────────────────────────────────────────── */ + if (dbSummaryLoading && !dbSummary) { + return ( + + + +

Se \u00eencarc\u0103 datele din baza de date\u2026

+
+
+ ); + } + + /* ── Empty state ────────────────────────────────────────────────── */ + if (!dbSummary || dbSummary.totalFeatures === 0) { + return ( + + + +

Nicio dat\u0103 \u00een baza de date

+

+ Folose\u0219te tab-ul Export pentru a sincroniza date din eTerra. +

+
+
+ ); + } + + /* ── Populated state ────────────────────────────────────────────── */ + return ( + <> + {/* Header row */} +
+
+ + + {dbSummary.totalFeatures.toLocaleString("ro-RO")} entit\u0103\u021bi + + + 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) : "\u2014"} + + +
+ + {/* 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\u0103r\u0103 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)} + /> + )} +
+ ); + })} + + ); +} diff --git a/src/modules/parcel-sync/components/tabs/export-tab.tsx b/src/modules/parcel-sync/components/tabs/export-tab.tsx new file mode 100644 index 0000000..2e56221 --- /dev/null +++ b/src/modules/parcel-sync/components/tabs/export-tab.tsx @@ -0,0 +1,1512 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { + Loader2, + FileDown, + CheckCircle2, + XCircle, + Wifi, + MapPin, + Sparkles, + RefreshCw, + Database, + HardDrive, + Clock, + ArrowDownToLine, + AlertTriangle, +} from "lucide-react"; +import { Button } from "@/shared/components/ui/button"; +import { Badge } from "@/shared/components/ui/badge"; +import { Card, CardContent } from "@/shared/components/ui/card"; +import { cn } from "@/shared/lib/utils"; +import { + LAYER_CATALOG, +} from "../../services/eterra-layers"; +import type { + SessionStatus, + SyncRunInfo, + ExportProgress, +} from "../parcel-sync-types"; +import { relativeTime } from "../parcel-sync-types"; + +/* ------------------------------------------------------------------ */ +/* Props */ +/* ------------------------------------------------------------------ */ + +export type ExportTabProps = { + siruta: string; + workspacePk: number | null; + sirutaValid: boolean; + session: SessionStatus; + syncLocalCounts: Record; + syncRuns: SyncRunInfo[]; + syncingSiruta: string; + exporting: boolean; + setExporting: (v: boolean) => void; + onSyncRefresh: () => void; + onDbRefresh: () => void; +}; + +/* ------------------------------------------------------------------ */ +/* No-geom scan result type */ +/* ------------------------------------------------------------------ */ + +type NoGeomScanResult = { + 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; +}; + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function ExportTab({ + siruta, + workspacePk, + sirutaValid, + session, + syncLocalCounts, + syncRuns, + syncingSiruta, + exporting, + setExporting, + onSyncRefresh, + onDbRefresh, +}: ExportTabProps) { + /* ── Export state ──────────────────────────────────────────── */ + const [exportJobId, setExportJobId] = useState(null); + const [exportProgress, setExportProgress] = useState( + null, + ); + const [phaseTrail, setPhaseTrail] = useState([]); + const pollingRef = useRef | null>(null); + + /* ── No-geometry state ────────────────────────────────────── */ + const [includeNoGeom, setIncludeNoGeom] = useState(false); + const [noGeomScanning, setNoGeomScanning] = useState(false); + const [noGeomScan, setNoGeomScan] = useState(null); + const [noGeomScanSiruta, setNoGeomScanSiruta] = useState(""); + + /* ── 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); + + /* ══════════════════════════════════════════════════════════ */ + /* Derived data */ + /* ══════════════════════════════════════════════════════════ */ + + 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 progressPct = + exportProgress?.total && exportProgress.total > 0 + ? Math.round((exportProgress.downloaded / exportProgress.total) * 100) + : 0; + + /* ══════════════════════════════════════════════════════════ */ + /* 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\u0103rcare complet\u0103 \u2014 ${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 + onSyncRefresh(); + }, + [siruta, exporting, startPolling, includeNoGeom, setExporting, onSyncRefresh], + ); + + /* ══════════════════════════════════════════════════════════ */ + /* 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: NoGeomScanResult = { + 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 \u2014 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]); + + /* ══════════════════════════════════════════════════════════ */ + /* Background sync polling */ + /* ══════════════════════════════════════════════════════════ */ + + 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 + onSyncRefresh(); + onDbRefresh(); + } + } catch { + /* ignore polling errors */ + } + }, 1500); + }, + [onSyncRefresh, onDbRefresh], + ); + + // 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\u0103"]); + localStorage.removeItem("parcel-sync:bg-job"); + } else { + localStorage.removeItem("parcel-sync:bg-job"); + } + } catch { + localStorage.removeItem("parcel-sync:bg-job"); + } + })(); + } catch { + /* */ + } + }, [startBgPolling]); + + /* ══════════════════════════════════════════════════════════ */ + /* Background sync — fire-and-forget server-side processing */ + /* ══════════════════════════════════════════════════════════ */ + + 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) { + // Transient error — ignored in the extracted component + // (parent's syncProgress state is not available here) + console.warn("[sync-background]", data.error ?? `Eroare ${res.status}`); + 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\u021bea"; + console.warn("[sync-background]", msg); + } + }, + [siruta, exporting, includeNoGeom, startBgPolling], + ); + + /* ══════════════════════════════════════════════════════════ */ + /* Download from DB */ + /* ══════════════════════════════════════════════════════════ */ + + 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\u0103rcare"; + console.warn("[download-from-db]", msg); + } + setDownloadingFromDb(false); + }, + [siruta, downloadingFromDb], + ); + + /* ══════════════════════════════════════════════════════════ */ + /* Render */ + /* ══════════════════════════════════════════════════════════ */ + + return ( +
+ {/* DB freshness status */} + {sirutaValid && dbLayersSummary.length > 0 && ( + + +
+ + + + {dbTotalFeatures.toLocaleString("ro-RO")} + {" "} + entit\u0103\u021bi \u00een 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\u0103-te la eTerra pentru a activa exportul.

+ + ) : ( + <> + +

Selecteaz\u0103 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\u0103 lista de imobile din eTerra\u2026 (max 2 min) +
+

+ Po\u021bi folosi butoanele de mai jos f\u0103r\u0103 s\u0103 a\u0219tep\u021bi scanarea. +

+
+
+ ); + + // Scan timed out + if (scanDone && noGeomScan.scannedAt === "timeout") + return ( + + +
+ + Scanarea a dep\u0103\u0219it 2 minute \u2014 serverul eTerra e lent. +
+

+ Po\u021bi lansa sincronizarea fundal f\u0103r\u0103 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\u0103:{" "} + + {noGeomScan.localDbWithGeom.toLocaleString("ro-RO")} + {" "} + cu geometrie + {noGeomScan.localDbNoGeom > 0 && ( + <> + {" + "} + + {noGeomScan.localDbNoGeom.toLocaleString("ro-RO")} + {" "} + f\u0103r\u0103 geometrie + + )} + {noGeomScan.localDbEnriched > 0 && ( + <> + {" \u00b7 "} + + {noGeomScan.localDbEnriched.toLocaleString("ro-RO")} + {" "} + \u00eembog\u0103\u021bite + {staleEnrichment && ( + + {" "} + ({staleCount.toLocaleString("ro-RO")} incomplete) + + )} + + )} + {noGeomScan.localSyncFresh && ( + + (proasp\u0103t) + + )} + +
+ {staleEnrichment && ( +
+ + + {staleCount.toLocaleString("ro-RO")} parcele au \u00eembog\u0103\u021bire + veche (lips\u0103 PROPRIETARI_VECHI). Vor fi re-\u00eembog\u0103\u021bite la + urm\u0103torul export Magic. + +
+ )} +
+ ); + + // Helper: workflow preview (what Magic will do) + const workflowPreview = scanDone && ( +
+

+ La ap\u0103sarea Magic, pa\u0219ii vor fi: +

+
    +
  1. + {"Sync GIS \u2014 "} + 0 + ? "text-emerald-600 dark:text-emerald-400" + : "text-foreground", + )} + > + {noGeomScan.localSyncFresh && + noGeomScan.localDbWithGeom > 0 + ? "skip (date proaspete \u00een DB)" + : `descarc\u0103 ${noGeomScan.remoteGisCount.toLocaleString("ro-RO")} terenuri` + + (noGeomScan.remoteCladiriCount > 0 + ? ` + ${noGeomScan.remoteCladiriCount.toLocaleString("ro-RO")} cl\u0103diri` + : "")} + +
  2. + {includeNoGeom && ( +
  3. + Import parcele f\u0103r\u0103 geometrie \u2014{" "} + + {(() => { + 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. + \u00cembog\u0103\u021bire CF, proprietari, adrese \u2014{" "} + + {(() => { + // 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 \u00eembog\u0103\u021bite"; + })()} + +
  6. +
  7. Generare GPKG + CSV
  8. +
  9. Comprimare ZIP + desc\u0103rcare
  10. +
+
+ ); + + // No-geometry parcels found + if (hasNoGeomParcels) + return ( + + +
+ +
+

+ Layer GIS:{" "} + + {noGeomScan.remoteGisCount.toLocaleString("ro-RO")} + {" "} + terenuri + {noGeomScan.remoteCladiriCount > 0 && ( + <> + {" + "} + + {noGeomScan.remoteCladiriCount.toLocaleString( + "ro-RO", + )} + {" "} + cl\u0103diri + + )} + {" \u00b7 "} + Lista imobile:{" "} + + {noGeomScan.totalImmovables.toLocaleString("ro-RO")} + + {" (estimat "} + + ~ + {Math.max( + 0, + noGeomScan.totalImmovables - + noGeomScan.remoteGisCount, + ).toLocaleString("ro-RO")} + + {" f\u0103r\u0103 geometrie)"} +

+

+ Cele f\u0103r\u0103 geometrie exist\u0103 \u00een baza de date eTerra dar + nu au contur desenat \u00een layerul GIS. +

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

+ Calitate date (din{" "} + {noGeomScan.noGeomCount.toLocaleString("ro-RO")} f\u0103r\u0103 + 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\u00e2rtie:{" "} + + {noGeomScan.qualityBreakdown.withPaperCad.toLocaleString( + "ro-RO", + )} + + + + Cu suprafa\u021b\u0103:{" "} + + {noGeomScan.qualityBreakdown.withArea.toLocaleString( + "ro-RO", + )} + + + + Active (status=1):{" "} + + {noGeomScan.qualityBreakdown.withActiveStatus.toLocaleString( + "ro-RO", + )} + + + + Cu carte funciar\u0103:{" "} + + {noGeomScan.qualityBreakdown.withLandbook.toLocaleString( + "ro-RO", + )} + + +
+
+ + Utilizabile:{" "} + + {noGeomScan.qualityBreakdown.useful.toLocaleString( + "ro-RO", + )} + + + {noGeomScan.qualityBreakdown.empty > 0 && ( + + Filtrate (f\u0103r\u0103 CF/inactive/f\u0103r\u0103 date):{" "} + + {noGeomScan.qualityBreakdown.empty.toLocaleString( + "ro-RO", + )} + + + )} +
+
+ )} + {includeNoGeom && ( +

+ {noGeomScan.qualityBreakdown.empty > 0 + ? `Din ${noGeomScan.noGeomCount.toLocaleString("ro-RO")} f\u0103r\u0103 geometrie, ~${noGeomScan.qualityBreakdown.useful.toLocaleString("ro-RO")} vor fi importate (imobile electronice cu CF). ${noGeomScan.qualityBreakdown.empty.toLocaleString("ro-RO")} vor fi filtrate (f\u0103r\u0103 carte funciar\u0103, inactive sau f\u0103r\u0103 date).` + : "Vor fi importate \u00een DB \u0219i incluse \u00een CSV + Magic GPKG (coloana HAS_GEOMETRY=0/1)."}{" "} + \u00cen GPKG de baz\u0103 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 \u2014 nimic de importat + suplimentar. + {noGeomScan.localDbTotal > 0 && ( + + ({noGeomScan.localDbTotal.toLocaleString("ro-RO")}{" "} + \u00een DB local + {noGeomScan.localDbEnriched > 0 && + `, ${noGeomScan.localDbEnriched.toLocaleString("ro-RO")} \u00eembog\u0103\u021bite`} + {noGeomScan.localDbEnriched > 0 && + noGeomScan.localDbEnrichedComplete < + noGeomScan.localDbEnriched && ( + + {` (${(noGeomScan.localDbEnriched - noGeomScan.localDbEnrichedComplete).toLocaleString("ro-RO")} incomplete)`} + + )} + {noGeomScan.localSyncFresh && ", proasp\u0103t"}) + + )} + + ) : ( + <> + + Nu s-au g\u0103sit imobile \u00een lista eTerra pentru acest + UAT. Verific\u0103 sesiunea eTerra. + + )} +
+
+
+ ); + + return null; + })()} + + {/* ── Background sync + Download from DB ──────────────── */} + {sirutaValid && ( + + + {/* Row 1: Section label */} +
+ + + Procesare fundal & desc\u0103rcare din DB + + + \u2014 porne\u0219te sincronizarea, \u00eenchide pagina, descarc\u0103 mai t\u00e2rziu + +
+ + {/* 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\u0103-te la eTerra pentru a porni sincronizarea fundal, + sau sincronizeaz\u0103 mai \u00eent\u00e2i date \u00een baza local\u0103. +

+ )} +
+
+ )} + + {/* Background sync progress */} + {bgJobId && bgProgress && bgProgress.status !== "unknown" && ( + + + {/* Label */} +
+ + + Sincronizare fundal + + {bgProgress.status === "running" && ( + + (po\u021bi \u00eenchide pagina) + + )} +
+ + {/* Phase trail */} +
+ {bgPhaseTrail.map((p, i) => ( + + {i > 0 && \u2192} + + {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 for bundle export */} + {exportProgress && + exportProgress.status !== "unknown" && + exportJobId && ( + + + {/* Phase trail */} +
+ {phaseTrail.map((p, i) => ( + + {i > 0 && \u2192} + + {p} + + + ))} +
+ + {/* Progress info */} +
+ {exportProgress.status === "running" && ( + + )} + {exportProgress.status === "done" && ( + + )} + {exportProgress.status === "error" && ( + + )} +
+

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

+ {exportProgress.note && ( +

+ {exportProgress.note} +

+ )} + {exportProgress.message && ( +

+ {exportProgress.message} +

+ )} +
+ + {progressPct}% + +
+ + {/* Bar */} +
+
+
+ + + )} +
+ ); +} diff --git a/src/modules/parcel-sync/components/tabs/layers-tab.tsx b/src/modules/parcel-sync/components/tabs/layers-tab.tsx new file mode 100644 index 0000000..7c1213f --- /dev/null +++ b/src/modules/parcel-sync/components/tabs/layers-tab.tsx @@ -0,0 +1,987 @@ +"use client"; + +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { + Search, + Layers, + Loader2, + ChevronDown, + ChevronUp, + Download, + CheckCircle2, + XCircle, + RefreshCw, + Database, + HardDrive, + Sparkles, +} from "lucide-react"; +import { Button } from "@/shared/components/ui/button"; +import { Badge } from "@/shared/components/ui/badge"; +import { Card, CardContent } from "@/shared/components/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/shared/components/ui/tooltip"; +import { cn } from "@/shared/lib/utils"; +import { + LAYER_CATALOG, + LAYER_CATEGORY_LABELS, + type LayerCategory, + type LayerCatalogItem, +} from "../../services/eterra-layers"; +import type { + SessionStatus, + SyncRunInfo, + ExportProgress, +} from "../parcel-sync-types"; + +/* ------------------------------------------------------------------ */ +/* Props */ +/* ------------------------------------------------------------------ */ + +type LayersTabProps = { + siruta: string; + sirutaValid: boolean; + session: SessionStatus; + syncLocalCounts: Record; + syncRuns: SyncRunInfo[]; + syncingSiruta: string; + onSyncRefresh: () => void; + exporting: boolean; +}; + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function LayersTab({ + siruta, + sirutaValid, + session, + syncLocalCounts, + syncRuns, + syncingSiruta, + onSyncRefresh, + exporting, +}: LayersTabProps) { + /* ── Local state ──────────────────────────────────────────────── */ + const [expandedCategories, setExpandedCategories] = useState< + Record + >({}); + const [downloadingLayer, setDownloadingLayer] = useState(null); + const [layerCounts, setLayerCounts] = useState< + Record + >({}); + const [countingLayers, setCountingLayers] = useState(false); + const [layerCountSiruta, setLayerCountSiruta] = useState(""); + const [layerHistory, setLayerHistory] = useState< + { + layerId: string; + label: string; + count: number; + time: string; + siruta: string; + }[] + >([]); + const [syncingLayer, setSyncingLayer] = useState(null); + const [syncProgress, setSyncProgress] = useState(""); + const [syncQueue, setSyncQueue] = useState([]); + const syncQueueRef = useRef([]); + const [exportingLocal, setExportingLocal] = useState(false); + const [postgisRunning, setPostgisRunning] = useState(false); + const [postgisResult, setPostgisResult] = useState<{ + success: boolean; + message?: string; + details?: Record; + error?: string; + } | null>(null); + const [exportProgress, setExportProgress] = useState( + null, + ); + const [phaseTrail, setPhaseTrail] = useState([]); + + const pollingRef = useRef | null>(null); + + /* ── Derived ──────────────────────────────────────────────────── */ + + 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 progressPct = + exportProgress?.total && exportProgress.total > 0 + ? Math.round((exportProgress.downloaded / exportProgress.total) * 100) + : 0; + + /* ── Layer history: load from localStorage on mount ───────────── */ + + useEffect(() => { + try { + const raw = localStorage.getItem("parcel-sync:layer-history"); + if (raw) { + const parsed = JSON.parse(raw) as typeof layerHistory; + const today = new Date().toISOString().slice(0, 10); + const todayEntries = parsed.filter( + (e) => e.time.slice(0, 10) === today, + ); + setLayerHistory(todayEntries); + } + } catch { + // ignore + } + }, []); + + /* ── Cleanup polling on unmount ───────────────────────────────── */ + + useEffect(() => { + return () => { + if (pollingRef.current) clearInterval(pollingRef.current); + }; + }, []); + + /* ── startPolling ─────────────────────────────────────────────── */ + + 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); + }, []); + + /* ── fetchLayerCounts ─────────────────────────────────────────── */ + + 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]); + + /* ── handleSyncLayer ──────────────────────────────────────────── */ + + const handleSyncLayer = useCallback( + async (layerId: string) => { + if (!siruta || syncingLayer) return; + setSyncingLayer(layerId); + setSyncProgress("Sincronizare pornit\u0103\u2026"); + 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 \u2014 ${data.newFeatures ?? 0} noi, ${data.removedFeatures ?? 0} \u0219terse, ${data.totalLocal ?? 0} total local`, + ); + // Refresh sync status + onSyncRefresh(); + } + } catch { + setSyncProgress("Eroare re\u021bea"); + } + // Clear progress after 8s + setTimeout(() => { + setSyncingLayer(null); + setSyncProgress(""); + }, 8_000); + }, + [siruta, syncingLayer, onSyncRefresh], + ); + + /* ── handleExportLocal ────────────────────────────────────────── */ + + 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], + ); + + /* ── handleSyncMultiple ───────────────────────────────────────── */ + + 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}\u2026`, + ); + 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\u021bea"); + } + // Remove from queue + syncQueueRef.current = syncQueueRef.current.filter( + (id) => id !== layerId, + ); + setSyncQueue([...syncQueueRef.current]); + } + // Done — refresh status + onSyncRefresh(); + setSyncingLayer(null); + setSyncProgress(""); + setSyncQueue([]); + syncQueueRef.current = []; + }, + [siruta, syncingLayer, syncQueue.length, onSyncRefresh], + ); + + /* ── handleSetupPostgis ───────────────────────────────────────── */ + + 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]); + + /* ── handleExportLayer ────────────────────────────────────────── */ + + const handleExportLayer = useCallback( + async (layerId: string) => { + if (!siruta || downloadingLayer) return; + setDownloadingLayer(layerId); + const jobId = crypto.randomUUID(); + 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\u0103rcare complet\u0103 \u2014 ${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 + onSyncRefresh(); + }, + [siruta, downloadingLayer, startPolling, onSyncRefresh], + ); + + /* ── Suppress unused warnings for sync multiple (used externally) */ + void handleSyncMultiple; + void phaseTrail; + void syncQueue; + + /* ── JSX ──────────────────────────────────────────────────────── */ + + if (!sirutaValid || !session.connected) { + return ( + + + +

+ {!session.connected + ? "Conecteaz\u0103-te la eTerra \u0219i selecteaz\u0103 un UAT." + : "Selecteaz\u0103 un UAT pentru a vedea catalogul de layere."} +

+
+
+ ); + } + + return ( +
+ {/* Action bar */} +
+

+ {layerCountSiruta === siruta && + Object.keys(layerCounts).length > 0 + ? `Num\u0103r features pentru SIRUTA ${siruta}` + : "Apas\u0103 pentru a num\u0103ra 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}{" "} + + \u2014{" "} + {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 */} + +
+
+
+ + + Conectare QGIS + +
+ {!postgisResult?.success && ( + + + + + + +

+ Operatie sigura, reversibila +

+

+ Creeaza coloane native PostGIS + view-uri read-only + pentru QGIS. Nu modifica datele existente. Ruleaza o + singura data (~30s). +

+
+
+
+ )} +
+
+ + {postgisResult ? ( + postgisResult.success ? ( +
+
+ + + QGIS compatibil \u2014 gata de conectare + +
+
+

+ Cum te conectezi din QGIS: +

+
    +
  1. + QGIS \u2192 Layer \u2192 Add Layer \u2192 Add PostGIS Layers +
  2. +
  3. New connection:
  4. +
+
+

+ Host: 10.10.10.166 +

+

+ Port: 5432 +

+

+ Database: architools_db +

+

+ Username: architools_user +

+
+

+ View-uri disponibile (read-only): +

+
+ gis_terenuri, gis_cladiri, gis_documentatii, + gis_administrativ +
+

+ SRID: 3844 (Stereo70) +

+ {postgisResult.details && ( +

+ {String( + ( + postgisResult.details as { + totalFeaturesWithGeom?: number; + } + ).totalFeaturesWithGeom ?? 0, + )}{" "} + features cu geometrie nativa +

+ )} +
+
+ ) : ( +
+ +
+

+ PostGIS nu este instalat pe server +

+

+ Contacteaza administratorul pentru instalare: +

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

+ Permite conectarea din QGIS direct la baza de date pentru + vizualizare si analiza spatiala a parcelelor, cladirilor + si limitelor UAT. +

+

+ Apasa butonul pentru a activa \u2014 creeaza view-uri read-only + (nu modifica datele, nu afecteaza performanta aplicatiei). +

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

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

+
+ + {progressPct}% + +
+
+
+
+ + + )} +
+ ); +} diff --git a/src/modules/parcel-sync/components/tabs/map-tab.tsx b/src/modules/parcel-sync/components/tabs/map-tab.tsx new file mode 100644 index 0000000..2edd4a3 --- /dev/null +++ b/src/modules/parcel-sync/components/tabs/map-tab.tsx @@ -0,0 +1,396 @@ +"use client"; + +import { useState, useRef, useCallback, useEffect } from "react"; +import dynamic from "next/dynamic"; +import { Map as MapIcon, Loader2 } from "lucide-react"; +import { Card, CardContent } from "@/shared/components/ui/card"; +import { BasemapSwitcher } from "@/modules/geoportal/components/basemap-switcher"; +import { + SelectionToolbar, + type SelectionMode, +} from "@/modules/geoportal/components/selection-toolbar"; +import { FeatureInfoPanel } from "@/modules/geoportal/components/feature-info-panel"; +import type { MapViewerHandle } from "@/modules/geoportal/components/map-viewer"; +import type { + BasemapId, + ClickedFeature, + LayerVisibility, + SelectedFeature, +} from "@/modules/geoportal/types"; + +/* MapLibre uses WebGL — must disable SSR */ +const MapViewer = dynamic( + () => + import("@/modules/geoportal/components/map-viewer").then((m) => ({ + default: m.MapViewer, + })), + { + ssr: false, + loading: () => ( +
+

Se incarca harta...

+
+ ), + }, +); + +/* ------------------------------------------------------------------ */ +/* Layer IDs — must match map-viewer.tsx LAYER_IDS */ +/* ------------------------------------------------------------------ */ + +const BASE_LAYERS = [ + "l-terenuri-fill", + "l-terenuri-line", + "l-terenuri-label", + "l-cladiri-fill", + "l-cladiri-line", +]; + +/* ------------------------------------------------------------------ */ +/* Props */ +/* ------------------------------------------------------------------ */ + +type MapTabProps = { + siruta: string; + sirutaValid: boolean; +}; + +/* ------------------------------------------------------------------ */ +/* Helpers — typed map operations */ +/* ------------------------------------------------------------------ */ + +type MapLike = { + getLayer(id: string): unknown; + getSource(id: string): unknown; + addSource(id: string, source: Record): void; + addLayer(layer: Record, before?: string): void; + setFilter(id: string, filter: unknown[] | null): void; + setLayoutProperty(id: string, prop: string, value: unknown): void; + fitBounds(bounds: [number, number, number, number], opts?: Record): void; + isStyleLoaded(): boolean; +}; + +function asMap(handle: MapViewerHandle | null): MapLike | null { + const m = handle?.getMap(); + return m ? (m as unknown as MapLike) : null; +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function MapTab({ siruta, sirutaValid }: MapTabProps) { + const mapHandleRef = useRef(null); + const [basemap, setBasemap] = useState("liberty"); + const [clickedFeature, setClickedFeature] = + useState(null); + const [selectionMode, setSelectionMode] = useState("off"); + const [selectedFeatures, setSelectedFeatures] = useState( + [], + ); + const [boundsLoading, setBoundsLoading] = useState(false); + const [flyTarget, setFlyTarget] = useState< + { center: [number, number]; zoom?: number } | undefined + >(); + const [mapReady, setMapReady] = useState(false); + const [viewsReady, setViewsReady] = useState(null); + const appliedSirutaRef = useRef(""); + + /* Layer visibility: show terenuri + cladiri, hide admin */ + const [layerVisibility] = useState({ + terenuri: true, + cladiri: true, + administrativ: false, + }); + + /* ── Check if enrichment views exist, create if not ────────── */ + useEffect(() => { + fetch("/api/geoportal/setup-enrichment-views") + .then((r) => r.json()) + .then((data: { ready?: boolean }) => { + if (data.ready) { + setViewsReady(true); + } else { + // Auto-create views + fetch("/api/geoportal/setup-enrichment-views", { method: "POST" }) + .then((r) => r.json()) + .then((res: { status?: string }) => { + setViewsReady(res.status === "ok"); + }) + .catch(() => setViewsReady(false)); + } + }) + .catch(() => setViewsReady(false)); + }, []); + + /* ── Detect when map is ready ──────────────────────────────── */ + useEffect(() => { + if (!sirutaValid) return; + const check = setInterval(() => { + const map = asMap(mapHandleRef.current); + if (map && map.isStyleLoaded()) { + setMapReady(true); + clearInterval(check); + } + }, 200); + return () => clearInterval(check); + }, [sirutaValid]); + + /* ── Apply siruta filter on base map layers ────────────────── */ + useEffect(() => { + if (!mapReady || !sirutaValid || !siruta) return; + if (appliedSirutaRef.current === siruta) return; + + const map = asMap(mapHandleRef.current); + if (!map) return; + + appliedSirutaRef.current = siruta; + const filter = ["==", ["get", "siruta"], siruta]; + + for (const layerId of BASE_LAYERS) { + try { + if (!map.getLayer(layerId)) continue; + map.setFilter(layerId, filter); + } catch { + /* layer may not exist */ + } + } + }, [mapReady, siruta, sirutaValid]); + + /* ── Add enrichment overlay source + layers ────────────────── */ + useEffect(() => { + if (!mapReady || !viewsReady || !sirutaValid || !siruta) return; + + const map = asMap(mapHandleRef.current); + if (!map) return; + + const martinBase = typeof window !== "undefined" + ? `${window.location.origin}/tiles` + : "/tiles"; + + // Add gis_terenuri_status source (only once) + if (!map.getSource("gis_terenuri_status")) { + map.addSource("gis_terenuri_status", { + type: "vector", + tiles: [`${martinBase}/gis_terenuri_status/{z}/{x}/{y}`], + minzoom: 10, + maxzoom: 18, + }); + + // Data-driven fill: color by enrichment status + map.addLayer( + { + id: "l-ps-terenuri-fill", + type: "fill", + source: "gis_terenuri_status", + "source-layer": "gis_terenuri_status", + minzoom: 13, + filter: ["==", ["get", "siruta"], siruta], + paint: { + "fill-color": [ + "case", + // Enriched parcels: darker green + ["==", ["get", "has_enrichment"], 1], + "#15803d", + // No enrichment: lighter green + "#86efac", + ], + "fill-opacity": 0.25, + }, + }, + "l-terenuri-line", // insert before line layer + ); + + // Data-driven outline + map.addLayer( + { + id: "l-ps-terenuri-line", + type: "line", + source: "gis_terenuri_status", + "source-layer": "gis_terenuri_status", + minzoom: 13, + filter: ["==", ["get", "siruta"], siruta], + paint: { + "line-color": [ + "case", + // Has building without legal docs: red + [ + "all", + ["==", ["get", "has_building"], 1], + ["==", ["get", "build_legal"], 0], + ], + "#ef4444", + // Has building with legal: blue + ["==", ["get", "has_building"], 1], + "#3b82f6", + // Default: green + "#15803d", + ], + "line-width": [ + "case", + ["==", ["get", "has_building"], 1], + 1.8, + 0.8, + ], + }, + }, + "l-cladiri-fill", + ); + } else { + // Source already exists — just update filters for new siruta + const sirutaFilter = ["==", ["get", "siruta"], siruta]; + try { + if (map.getLayer("l-ps-terenuri-fill")) + map.setFilter("l-ps-terenuri-fill", sirutaFilter); + if (map.getLayer("l-ps-terenuri-line")) + map.setFilter("l-ps-terenuri-line", sirutaFilter); + } catch { + /* noop */ + } + } + + // Hide the base terenuri-fill (we replaced it with enrichment-aware version) + try { + if (map.getLayer("l-terenuri-fill")) + map.setLayoutProperty("l-terenuri-fill", "visibility", "none"); + } catch { + /* noop */ + } + }, [mapReady, viewsReady, siruta, sirutaValid]); + + /* ── Fetch UAT bounds and zoom ─────────────────────────────── */ + const prevBoundsSirutaRef = useRef(""); + useEffect(() => { + if (!sirutaValid || !siruta) return; + if (prevBoundsSirutaRef.current === siruta) return; + prevBoundsSirutaRef.current = siruta; + + setBoundsLoading(true); + fetch(`/api/geoportal/uat-bounds?siruta=${siruta}`) + .then((r) => (r.ok ? r.json() : null)) + .then( + (data: { + bounds?: [[number, number], [number, number]]; + } | null) => { + if (data?.bounds) { + const [[minLng, minLat], [maxLng, maxLat]] = data.bounds; + const centerLng = (minLng + maxLng) / 2; + const centerLat = (minLat + maxLat) / 2; + setFlyTarget({ center: [centerLng, centerLat], zoom: 13 }); + + // Fit bounds if map is already ready + const map = asMap(mapHandleRef.current); + if (map) { + map.fitBounds([minLng, minLat, maxLng, maxLat], { + padding: 40, + duration: 1500, + }); + } + } + }, + ) + .catch(() => {}) + .finally(() => setBoundsLoading(false)); + }, [siruta, sirutaValid]); + + /* ── Feature click handler ─────────────────────────────────── */ + const handleFeatureClick = useCallback( + (feature: ClickedFeature | null) => { + if (!feature || !feature.properties) { + setClickedFeature(null); + return; + } + setClickedFeature(feature); + }, + [], + ); + + /* ── Selection mode handler ────────────────────────────────── */ + const handleSelectionModeChange = useCallback((mode: SelectionMode) => { + if (mode === "off") { + mapHandleRef.current?.clearSelection(); + setSelectedFeatures([]); + } + setSelectionMode(mode); + }, []); + + /* ── Render ─────────────────────────────────────────────────── */ + + if (!sirutaValid) { + return ( + + + +

Selecteaz\u0103 un UAT din lista de mai sus

+
+
+ ); + } + + return ( +
+ {boundsLoading && ( +
+ + Se \u00eencarc\u0103 zona UAT... +
+ )} + + + + {/* Top-right: basemap switcher + feature panel */} +
+ + {clickedFeature && selectionMode === "off" && ( + setClickedFeature(null)} + /> + )} +
+ + {/* Bottom-left: selection toolbar */} +
+ { + mapHandleRef.current?.clearSelection(); + setSelectedFeatures([]); + }} + /> +
+ + {/* Bottom-right: legend */} +
+
+ + F\u0103r\u0103 enrichment +
+
+ + Cu enrichment +
+
+ + Cu cl\u0103dire +
+
+ + Cl\u0103dire f\u0103r\u0103 acte +
+
+
+ ); +} diff --git a/src/modules/parcel-sync/components/tabs/search-tab.tsx b/src/modules/parcel-sync/components/tabs/search-tab.tsx new file mode 100644 index 0000000..7d1c598 --- /dev/null +++ b/src/modules/parcel-sync/components/tabs/search-tab.tsx @@ -0,0 +1,1449 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { + Search, + Loader2, + Plus, + FileDown, + ClipboardCopy, + Trash2, + XCircle, + Download, + Archive, + FileText, + User, +} 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 { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/shared/components/ui/tooltip"; +import { cn } from "@/shared/lib/utils"; +import { EpayOrderButton } from "../epay-order-button"; +import type { EpaySessionStatus } from "../epay-connect"; +import type { + SessionStatus, + UatEntry, + ParcelDetail, + OwnerSearchResult, +} from "../parcel-sync-types"; +import { formatArea, formatShortDate } from "../parcel-sync-types"; + +/* ------------------------------------------------------------------ */ +/* Props */ +/* ------------------------------------------------------------------ */ + +type SearchTabProps = { + siruta: string; + workspacePk: number | null; + sirutaValid: boolean; + session: SessionStatus; + selectedUat?: UatEntry; + epayStatus: EpaySessionStatus; +}; + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function SearchTab({ + siruta, + workspacePk, + sirutaValid, + session, + selectedUat, + epayStatus, +}: SearchTabProps) { + /* ── Parcel search ────────────────────────────────────────── */ + 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(""); + + /* ── CF extract statuses ──────────────────────────────────── */ + const [cfStatusMap, setCfStatusMap] = useState>({}); + const [cfLatestIds, setCfLatestIds] = useState>({}); + const [cfExpiryDates, setCfExpiryDates] = useState>( + {}, + ); + const [cfStatusLoading, setCfStatusLoading] = useState(false); + const [listCfOrdering, setListCfOrdering] = useState(false); + const [listCfOrderResult, setListCfOrderResult] = useState(""); + const [listCfDownloading, setListCfDownloading] = useState(false); + + /* ── Search handlers ──────────────────────────────────────── */ + + 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\u021bea."); + } + setLoadingFeatures(false); + }, [siruta, featuresSearch, workspacePk]); + + const handleSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleSearch(); + } + }, + [handleSearch], + ); + + /* ── Owner search ──────────────────────────────────────────── */ + + 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 ? ` \u00b7 ${data.total} rezultate` : ""}` + : "", + ); + } + } catch { + setOwnerError("Eroare de re\u021bea."); + } + setOwnerLoading(false); + }, [siruta, ownerSearch, workspacePk]); + + const handleOwnerKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleOwnerSearch(); + } + }, + [handleOwnerSearch], + ); + + 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, + }), + [], + ); + + /* ── List management ───────────────────────────────────────── */ + + 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)); + }, []); + + 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]); + + /* ── CF extract status fetching ────────────────────────────── */ + + const fetchCfStatuses = useCallback(async (cadastralNumbers: string[]) => { + if (cadastralNumbers.length === 0) return; + setCfStatusLoading(true); + try { + const nrs = cadastralNumbers.join(","); + const res = await fetch( + `/api/ancpi/orders?nrCadastral=${encodeURIComponent(nrs)}&limit=1`, + ); + const data = (await res.json()) as { + statusMap?: Record; + latestById?: Record< + string, + { id: string; expiresAt: string | null } + >; + }; + if (data.statusMap) { + setCfStatusMap((prev) => ({ ...prev, ...data.statusMap })); + } + if (data.latestById) { + const idMap: Record = {}; + const expiryMap: Record = {}; + for (const [nr, rec] of Object.entries(data.latestById)) { + if (rec && typeof rec === "object" && "id" in rec) { + idMap[nr] = (rec as { id: string }).id; + const expires = (rec as { expiresAt: string | null }).expiresAt; + if (expires) { + expiryMap[nr] = expires; + } + } + } + setCfLatestIds((prev) => ({ ...prev, ...idMap })); + setCfExpiryDates((prev) => ({ ...prev, ...expiryMap })); + } + } catch { + /* silent */ + } finally { + setCfStatusLoading(false); + } + }, []); + + const refreshCfStatuses = useCallback(() => { + const allNrs = new Set(); + for (const r of searchResults) { + if (r.nrCad) allNrs.add(r.nrCad); + } + for (const p of searchList) { + if (p.nrCad) allNrs.add(p.nrCad); + } + if (allNrs.size > 0) { + void fetchCfStatuses(Array.from(allNrs)); + } + }, [searchResults, searchList, fetchCfStatuses]); + + // Auto-fetch CF statuses when search results change + useEffect(() => { + const nrs = searchResults.map((r) => r.nrCad).filter(Boolean); + if (nrs.length > 0) void fetchCfStatuses(nrs); + }, [searchResults, fetchCfStatuses]); + + // Auto-fetch CF statuses when list changes + useEffect(() => { + const nrs = searchList.map((p) => p.nrCad).filter(Boolean); + if (nrs.length > 0) void fetchCfStatuses(nrs); + }, [searchList, fetchCfStatuses]); + + /* ── List CF ordering + ZIP download ──────────────────────── */ + + const handleListCfOrder = useCallback(async () => { + if (!siruta || searchList.length === 0 || listCfOrdering) return; + const toOrder: typeof searchList = []; + const toReorder: typeof searchList = []; + const alreadyValid: typeof searchList = []; + for (const p of searchList) { + const status = cfStatusMap[p.nrCad]; + if (status === "valid") { + alreadyValid.push(p); + } else if (status === "expired") { + toReorder.push(p); + } else { + if (status !== "processing") toOrder.push(p); + } + } + const newCount = toOrder.length; + const updateCount = toReorder.length; + const existingCount = alreadyValid.length; + if (newCount === 0 && updateCount === 0) { + setListCfOrderResult( + `Toate cele ${existingCount} extrase sunt valide.`, + ); + return; + } + const msg = [ + newCount > 0 ? `${newCount} extrase noi` : null, + updateCount > 0 ? `${updateCount} actualizari` : null, + existingCount > 0 ? `${existingCount} existente (skip)` : null, + ] + .filter(Boolean) + .join(", "); + if (!window.confirm(`Comanda extrase CF:\n${msg}\n\nContinui?`)) return; + setListCfOrdering(true); + setListCfOrderResult(""); + try { + const allToProcess = [...toOrder, ...toReorder]; + const parcels = allToProcess.map((p) => ({ + nrCadastral: p.nrCad, + siruta, + judetIndex: 0, + judetName: selectedUat?.county ?? "", + uatId: 0, + uatName: selectedUat?.name ?? "", + })); + const res = await fetch("/api/ancpi/order", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ parcels }), + }); + const data = (await res.json()) as { + orders?: unknown[]; + error?: string; + }; + if (!res.ok || data.error) { + setListCfOrderResult( + `Eroare: ${data.error ?? "Eroare la comanda"}`, + ); + } else { + const count = data.orders?.length ?? allToProcess.length; + setListCfOrderResult( + `${count} extras${count > 1 ? "e" : ""} CF trimis${count > 1 ? "e" : ""} la procesare.`, + ); + const pollInterval = setInterval(() => { + void refreshCfStatuses(); + }, 10_000); + setTimeout(() => clearInterval(pollInterval), 5 * 60 * 1000); + } + } catch { + setListCfOrderResult("Eroare retea."); + } finally { + setListCfOrdering(false); + } + }, [ + siruta, + searchList, + listCfOrdering, + cfStatusMap, + selectedUat, + refreshCfStatuses, + ]); + + const handleListCfDownloadZip = useCallback(async () => { + if (searchList.length === 0 || listCfDownloading) return; + const ids: string[] = []; + for (const p of searchList) { + const status = cfStatusMap[p.nrCad]; + const extractId = cfLatestIds[p.nrCad]; + if (status === "valid" && extractId) ids.push(extractId); + } + if (ids.length === 0) { + setListCfOrderResult("Niciun extras CF valid in lista."); + return; + } + setListCfDownloading(true); + try { + const res = await fetch(`/api/ancpi/download-zip?ids=${ids.join(",")}`); + if (!res.ok) throw new Error("Eroare descarcare ZIP"); + const blob = await res.blob(); + const cd = res.headers.get("Content-Disposition") ?? ""; + const match = /filename="?([^"]+)"?/.exec(cd); + const filename = match?.[1] + ? decodeURIComponent(match[1]) + : "Extrase_CF_lista.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 { + setListCfOrderResult("Eroare la descarcarea ZIP."); + } finally { + setListCfDownloading(false); + } + }, [searchList, cfStatusMap, cfLatestIds, listCfDownloading]); + + /* ── CF status badge helper ────────────────────────────────── */ + + const CfStatusBadge = useCallback( + ({ + nrCad, + immovablePk, + }: { + nrCad: string; + immovablePk?: number | string | null; + }) => { + if (!immovablePk || !sirutaValid) return null; + const cfStatus = cfStatusMap[nrCad]; + const extractId = cfLatestIds[nrCad]; + const cfExpiry = cfExpiryDates[nrCad]; + + if (cfStatus === "valid") { + return ( + +
+ + + + Extras CF + + + + {cfExpiry + ? `Valid pana la ${formatShortDate(cfExpiry)}` + : "Extras CF valid"} + + + {extractId && ( + + + + + Descarca extras CF + + )} +
+
+ ); + } + if (cfStatus === "expired") { + return ( + +
+ + + + Expirat + + + + {cfExpiry + ? `Expirat pe ${formatShortDate(cfExpiry)}` + : "Extras CF expirat"} + + + +
+
+ ); + } + if (cfStatus === "processing") { + return ( + + + + + Se proceseaza... + + + Comanda in curs de procesare + + + ); + } + // "none" or unknown + return ( + + ); + }, + [cfStatusMap, cfLatestIds, cfExpiryDates, sirutaValid, siruta, selectedUat], + ); + + /* ── Render ─────────────────────────────────────────────────── */ + + if (!sirutaValid) { + return ( + + + +

Selecteaz\u0103 un UAT mai sus pentru a c\u0103uta parcele.

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

+ Necesit\u0103 conexiune eTerra. Folose\u0219te modul Proprietar + pentru a c\u0103uta offline \u00een 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" && ( + <> + {loadingFeatures && searchResults.length === 0 && ( + + + +

Se caut\u0103 \u00een eTerra...

+

+ Prima c\u0103utare pe un UAT nou poate dura ~10-30s (se + \u00eencarc\u0103 lista de jude\u021be). +

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

+ Nr. Cad. {p.nrCad} +

+ {!p.immovablePk && ( +

+ Parcela nu a fost g\u0103sit\u0103 \u00een eTerra. +

+ )} +
+
+ + + +
+
+ + {p.immovablePk && ( +
+
+ + Nr. CF + + + {p.nrCF || "\u2014"} + +
+ {p.nrCFVechi && ( +
+ + CF vechi + + {p.nrCFVechi} +
+ )} +
+ + Nr. Topo + + {p.nrTopo || "\u2014"} +
+
+ + Suprafa\u021b\u0103 + + + {p.suprafata != null + ? formatArea(p.suprafata) + : "\u2014"} + +
+
+ + Intravilan + + + {p.intravilan || "\u2014"} + +
+ {p.categorieFolosinta && ( +
+ + Categorii folosin\u021b\u0103 + + + {p.categorieFolosinta} + +
+ )} + {p.adresa && ( +
+ + Adres\u0103 + + {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 */} + {searchMode === "cadastral" && + searchResults.length === 0 && + !loadingFeatures && + !searchError && ( + + + +

Introdu un num\u0103r cadastral \u0219i apas\u0103 Caut\u0103.

+

+ Po\u021bi c\u0103uta mai multe parcele simultan, separate prin + virgul\u0103. +

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

Se caut\u0103 proprietar...

+

+ Caut\u0103 mai \u00eent\u00e2i \u00een DB local (date \u00eembog\u0103\u021bite), 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\u021b\u0103 + + + {typeof r.suprafata === "number" + ? formatArea(r.suprafata) + : `${r.suprafata} mp`} + +
+ )} + {r.intravilan && ( +
+ + Intravilan + + + {r.intravilan} + +
+ )} + {r.categorieFolosinta && ( +
+ + Categorii folosin\u021b\u0103 + + + {r.categorieFolosinta} + +
+ )} + {r.adresa && ( +
+ + Adres\u0103 + + {r.adresa} +
+ )} + {r.proprietari && ( +
+ + Proprietari actuali + + + {r.proprietari} + +
+ )} + {r.proprietariVechi && ( +
+ + Proprietari anteriori + + + {r.proprietariVechi} + +
+ )} +
+
+
+ ))} +
+ + )} + + {ownerResults.length === 0 && !ownerLoading && !ownerError && ( + + + +

Introdu numele proprietarului \u0219i apas\u0103 Caut\u0103.

+

+ Caut\u0103 \u00een datele \u00eembog\u0103\u021bite (DB local) \u0219i pe eTerra. +
+ Pentru rezultate complete, lanseaz\u0103 "Sync fundal \u2014 + Magic" \u00een tab-ul Export. +

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

+ Lista mea ({searchList.length} parcele) +

+
+ + + {/* Download all valid CF extracts as ZIP */} + {searchList.some( + (p) => cfStatusMap[p.nrCad] === "valid", + ) && + (() => { + const validCount = searchList.filter( + (p) => cfStatusMap[p.nrCad] === "valid", + ).length; + return ( + + + + + + {`Descarca ZIP cu ${validCount} extrase valide din lista`} + + + ); + })()} + {/* Order CF extracts for list */} + {epayStatus.connected && + (() => { + const newCount = searchList.filter((p) => { + const s = cfStatusMap[p.nrCad]; + return ( + s !== "valid" && + s !== "expired" && + s !== "processing" + ); + }).length; + const updateCount = searchList.filter( + (p) => cfStatusMap[p.nrCad] === "expired", + ).length; + const totalCredits = newCount + updateCount; + const validCount = searchList.filter( + (p) => cfStatusMap[p.nrCad] === "valid", + ).length; + return ( + + + + + + + {`Comanda ${newCount} extrase noi + ${updateCount} actualizari = ${totalCredits} credite. ${validCount} existente valide raman.`} + + + + ); + })()} +
+
+ + {/* Order result message */} + {listCfOrderResult && ( +

+ {listCfOrderResult} +

+ )} + +
+ + + + + + + + + + + + + + {searchList.map((p, idx) => { + const cfStatus = cfStatusMap[p.nrCad]; + const cfExpiry = cfExpiryDates[p.nrCad]; + return ( + + + + + + + + + + ); + })} + +
+ # + + Nr. Cad + + Nr. CF + + Suprafata + + Proprietari + + Extras CF +
+ {idx + 1} + + {p.nrCad} + + {p.nrCF || "\u2014"} + + {p.suprafata != null + ? formatArea(p.suprafata) + : "\u2014"} + + {p.proprietari || "\u2014"} + + + + + {cfStatus === "valid" ? ( + + Valid + + ) : cfStatus === "expired" ? ( + + Expirat + + ) : cfStatus === "processing" ? ( + + Procesare + + ) : ( + + Lipsa + + )} + + + {cfStatus === "valid" + ? cfExpiry + ? `Extras CF valid pana la ${formatShortDate(cfExpiry)}` + : "Extras CF valid" + : cfStatus === "expired" + ? cfExpiry + ? `Extras CF expirat pe ${formatShortDate(cfExpiry)}. Va fi actualizat automat la 'Scoate Extrase CF'.` + : "Extras CF expirat. Va fi actualizat automat la 'Scoate Extrase CF'." + : cfStatus === "processing" + ? "Comanda in curs de procesare" + : "Nu exista extras CF. Apasa 'Scoate Extrase CF' pentru a comanda."} + + + + + +
+
+
+
+ )} + + ); +}