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 (
+
+
+
+ {connecting ? (
+
+ ) : session.connected ? (
+
+
+
+
+ ) : session.eterraMaintenance ? (
+
+ ) : connectionError ? (
+
+ ) : (
+
+ )}
+
+ {connecting
+ ? "Se conecteaz\u0103\u2026"
+ : session.connected
+ ? "eTerra"
+ : session.eterraMaintenance
+ ? "Mentenan\u021b\u0103"
+ : connectionError
+ ? "Eroare"
+ : "Deconectat"}
+
+
+
+
+
+ {/* 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}
+
+ )}
+
+
+ )}
+
+
+
+
+ Deconectare
+
+
+ >
+ )}
+
+
+ );
+}
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: () => (
-
- ),
- }
-);
-
-/* ------------------------------------------------------------------ */
-/* 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 (
-
-
-
- {connecting ? (
-
- ) : session.connected ? (
-
-
-
-
- ) : session.eterraMaintenance ? (
-
- ) : connectionError ? (
-
- ) : (
-
- )}
-
- {connecting
- ? "Se conectează…"
- : session.connected
- ? "eTerra"
- : session.eterraMaintenance
- ? "Mentenanță"
- : connectionError
- ? "Eroare"
- : "Deconectat"}
-
-
-
-
-
- {/* 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}
-
- )}
-
-
- )}
-
-
-
-
- Deconectare
-
-
- >
- )}
-
-
- );
-}
+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 */}
-
- setSearchMode("cadastral")}
- className={cn(
- "px-3 py-1 text-xs rounded font-medium transition-colors",
- searchMode === "cadastral"
- ? "bg-background shadow text-foreground"
- : "text-muted-foreground hover:text-foreground",
- )}
- >
-
- Nr. Cadastral
-
- setSearchMode("owner")}
- className={cn(
- "px-3 py-1 text-xs rounded font-medium transition-colors",
- searchMode === "owner"
- ? "bg-background shadow text-foreground"
- : "text-muted-foreground hover:text-foreground",
- )}
- >
-
- Proprietar
-
-
-
- {/* Cadastral search input */}
- {searchMode === "cadastral" && (
-
-
-
- Numere cadastrale (separate prin virgulă sau Enter)
-
-
-
- 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.
-
- )}
-
-
void handleSearch()}
- disabled={
- loadingFeatures ||
- !featuresSearch.trim() ||
- !session.connected
- }
- >
- {loadingFeatures ? (
-
- ) : (
-
- )}
- Caută
-
-
- )}
-
- {/* Owner search input */}
- {searchMode === "owner" && (
-
-
-
- Nume proprietar (caută în DB local + eTerra)
-
-
-
- setOwnerSearch(e.target.value)}
- onKeyDown={handleOwnerKeyDown}
- />
-
-
-
void handleOwnerSearch()}
- disabled={ownerLoading || ownerSearch.trim().length < 2}
- >
- {ownerLoading ? (
-
- ) : (
-
- )}
- Caută
-
-
- )}
-
- {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 && (
-
{
- for (const r of searchResults) addToList(r);
- }}
- >
-
- Adaugă toate în listă
-
- )}
-
-
- Descarcă CSV
-
-
-
-
- {/* Detail cards */}
-
- {searchResults.map((p, idx) => (
-
-
-
-
-
- Nr. Cad. {p.nrCad}
-
- {!p.immovablePk && (
-
- Parcela nu a fost găsită în eTerra.
-
- )}
-
-
-
addToList(p)}
- disabled={!p.immovablePk}
- >
-
-
-
{
- const text = [
- `Nr. Cad: ${p.nrCad}`,
- `Nr. CF: ${p.nrCF || "\u2014"}`,
- p.nrCFVechi
- ? `CF vechi: ${p.nrCFVechi}`
- : null,
- p.nrTopo ? `Nr. Topo: ${p.nrTopo}` : null,
- p.suprafata != null
- ? `Suprafata: ${p.suprafata.toLocaleString("ro-RO")} mp`
- : null,
- `Intravilan: ${p.intravilan || "\u2014"}`,
- p.categorieFolosinta
- ? `Categorie: ${p.categorieFolosinta}`
- : null,
- p.adresa ? `Adresa: ${p.adresa}` : null,
- p.proprietariActuali
- ? `Proprietari actuali: ${p.proprietariActuali}`
- : null,
- p.proprietariVechi
- ? `Proprietari vechi: ${p.proprietariVechi}`
- : null,
- !p.proprietariActuali &&
- !p.proprietariVechi &&
- p.proprietari
- ? `Proprietari: ${p.proprietari}`
- : null,
- p.solicitant
- ? `Solicitant: ${p.solicitant}`
- : null,
- ]
- .filter(Boolean)
- .join("\n");
- void navigator.clipboard.writeText(text);
- }}
- >
-
-
- {/* 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}"
-
-
-
{
- for (const r of ownerResults)
- addToList(ownerResultToParcelDetail(r));
- }}
- >
-
- Adaugă toate în listă
-
-
-
- Descarcă CSV
-
-
-
-
-
- {ownerResults.map((r, idx) => (
-
-
-
-
-
- Nr. Cad. {r.nrCad}
-
-
- {r.source === "db"
- ? "din baza de date"
- : "eTerra online"}
-
-
-
-
- addToList(ownerResultToParcelDetail(r))
- }
- >
-
-
-
{
- const text = [
- `Nr. Cad: ${r.nrCad}`,
- r.nrCF ? `Nr. CF: ${r.nrCF}` : null,
- r.proprietari
- ? `Proprietari: ${r.proprietari}`
- : null,
- r.proprietariVechi
- ? `Proprietari vechi: ${r.proprietariVechi}`
- : null,
- r.adresa ? `Adresa: ${r.adresa}` : null,
- r.suprafata
- ? `Suprafata: ${r.suprafata} mp`
- : null,
- ]
- .filter(Boolean)
- .join("\n");
- void navigator.clipboard.writeText(text);
- }}
- >
-
-
- {/* 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)
-
-
-
{
- setSearchList([]);
- setListCfOrderResult("");
- }}
- >
-
- Goleste
-
-
-
- CSV din lista
-
- {/* 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 (
-
-
-
- void handleListCfDownloadZip()}
- >
- {listCfDownloading ? (
-
- ) : (
-
- )}
- Descarca Extrase CF
-
-
- {`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 (
-
-
-
- void handleListCfOrder()}
- >
- {listCfOrdering ? (
-
- ) : (
-
- )}
- Scoate Extrase CF
-
-
-
- {`Comanda ${newCount} extrase noi + ${updateCount} actualizari = ${totalCredits} credite. ${validCount} existente valide raman.`}
-
-
-
- );
- })()}
-
-
-
- {/* Order result message */}
- {listCfOrderResult && (
-
- {listCfOrderResult}
-
- )}
-
-
-
-
-
-
- #
-
-
- Nr. Cad
-
-
- Nr. CF
-
-
- Suprafata
-
-
- Proprietari
-
-
- Extras CF
-
-
-
-
-
- {searchList.map((p, idx) => {
- const cfStatus = cfStatusMap[p.nrCad];
- const cfExpiry = cfExpiryDates[p.nrCad];
- return (
-
-
- {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."}
-
-
-
-
-
- removeFromList(p.nrCad)}
- >
-
-
-
-
- );
- })}
-
-
-
-
-
- )}
- >
- )}
+
- {/* ═══════════════════════════════════════════════════════ */}
- {/* 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) && (
- void handleExportLocal()}
- className="border-violet-300 text-violet-700 dark:border-violet-700 dark:text-violet-300"
- >
- {exportingLocal ? (
-
- ) : (
-
- )}
- Export local
-
- )}
- void fetchLayerCounts()}
- >
- {countingLayers ? (
-
- ) : (
-
- )}
- {countingLayers ? "Se numără…" : "Numără"}
-
-
-
-
- {/* 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 (
-
-
- setExpandedCategories((prev) => ({
- ...prev,
- [cat]: !prev[cat],
- }))
- }
- >
-
-
- {LAYER_CATEGORY_LABELS[cat]}
-
-
- {layers.length}
-
- {catTotal != null && catTotal > 0 && (
-
- {catTotal.toLocaleString("ro-RO")} remote
-
- )}
- {catLocal != null && catLocal > 0 && (
-
- {catLocal.toLocaleString("ro-RO")} local
-
- )}
-
- {isExpanded ? (
-
- ) : (
-
- )}
-
-
- {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 */}
-
- void handleSyncLayer(layer.id)
- }
- className="border-violet-200 dark:border-violet-800"
- title="Sincronizează în baza de date"
- >
- {isSyncing ? (
-
- ) : (
-
- )}
-
- Sync
-
-
- {/* GPKG (sync-first: syncs to DB if needed, then exports from DB) */}
-
- void handleExportLayer(layer.id)
- }
- title={
- localCount > 0
- ? "Descarcă GPKG (din cache dacă e proaspăt)"
- : "Sincronizează + descarcă GPKG"
- }
- >
- {isDownloading ? (
-
- ) : (
-
- )}
-
- GPKG
-
-
-
-
-
- );
- })}
-
- )}
-
- );
- },
- )}
-
- {/* 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 && (
-
-
-
- void handleSetupPostgis()}
- className="border-violet-300 text-violet-700 dark:border-violet-700 dark:text-violet-300"
- >
- {postgisRunning ? (
-
- ) : (
-
- )}
- {postgisRunning
- ? "Se activeaza…"
- : "Activeaza compatibilitate QGIS"}
-
-
-
-
- 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:
-
-
-
- QGIS → Layer → Add Layer → Add PostGIS Layers
-
- New connection:
-
-
-
- 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 ? (
-
-
void handleExportBundle("base")}
- >
- {exporting && exportProgress?.phase !== "Detalii parcele" ? (
-
- ) : (
-
- )}
-
-
- Descarcă Terenuri și Clădiri
-
-
- Sync + GPKG (din cache dacă e proaspăt)
-
-
-
-
-
void handleExportBundle("magic")}
- >
- {exporting && exportProgress?.phase === "Detalii parcele" ? (
-
- ) : (
-
- )}
-
-
Magic
-
- Sync + îmbogățire (CF, proprietari, adresă) + GPKG + CSV
-
-
-
-
- ) : (
-
-
- {!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.
-
- void handleNoGeomScan()}
- >
-
- Reîncearcă scanarea
-
-
-
- );
-
- // 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:
-
-
-
- {"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`
- : "")}
-
-
- {includeNoGeom && (
-
- 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";
- })()}
-
-
- )}
-
- Î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";
- })()}
-
-
- Generare GPKG + CSV
- Comprimare ZIP + descărcare
-
-
- );
-
- // No-geometry parcels found
- if (hasNoGeomParcels)
- return (
-
-
-
-
-
-
- Layer GIS:{" "}
-
- {noGeomScan.remoteGisCount.toLocaleString("ro-RO")}
- {" "}
- terenuri
- {noGeomScan.remoteCladiriCount > 0 && (
- <>
- {" + "}
-
- {noGeomScan.remoteCladiriCount.toLocaleString(
- "ro-RO",
- )}
- {" "}
- clădiri
- >
- )}
- {" · "}
- Lista imobile:{" "}
-
- {noGeomScan.totalImmovables.toLocaleString("ro-RO")}
-
- {" (estimat "}
-
- ~
- {Math.max(
- 0,
- noGeomScan.totalImmovables -
- noGeomScan.remoteGisCount,
- ).toLocaleString("ro-RO")}
-
- {" fără geometrie)"}
-
-
- Cele fără geometrie există în baza de date eTerra dar
- nu au contur desenat în layerul GIS.
-
- {localDbLine}
-
-
void handleNoGeomScan()}
- title="Re-scanare"
- >
-
-
-
-
- setIncludeNoGeom(e.target.checked)}
- disabled={exporting}
- className="h-4 w-4 rounded border-muted-foreground/30 accent-amber-600"
- />
-
- Include și parcelele fără geometrie la export
-
-
- {/* 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 && (
-
- setIncludeNoGeom(e.target.checked)}
- disabled={
- exporting ||
- (!!bgJobId && bgProgress?.status === "running")
- }
- className="h-4 w-4 rounded border-muted-foreground/30 accent-amber-600"
- />
-
- Include și parcelele fără geometrie
-
- {noGeomScanning && (
-
- (scanare în curs…)
-
- )}
-
- )}
-
- {/* Row 2: Background sync buttons */}
- {session.connected && (
-
-
void handleSyncBackground("base")}
- >
- {bgJobId &&
- bgProgress?.status === "running" &&
- !bgPhaseTrail.some((p) => p.includes("Îmbogățire")) ? (
-
- ) : (
-
- )}
-
-
- Sync fundal — Bază
-
-
- Terenuri + clădiri → salvează în DB
-
-
-
-
void handleSyncBackground("magic")}
- >
- {bgJobId &&
- bgProgress?.status === "running" &&
- bgPhaseTrail.some((p) => p.includes("Îmbogățire")) ? (
-
- ) : (
-
- )}
-
-
- Sync fundal — Magic
-
-
- Sync + îmbogățire → salvează în DB
-
-
-
-
- )}
-
- {/* Row 3: Download from DB buttons */}
- {dbTotalFeatures > 0 && (
-
-
void handleDownloadFromDb("base")}
- >
- {downloadingFromDb ? (
-
- ) : (
-
- )}
-
-
- Descarcă din DB — Bază
-
-
- GPKG terenuri + clădiri (instant, fără eTerra)
-
-
-
-
void handleDownloadFromDb("magic")}
- >
- {downloadingFromDb ? (
-
- ) : (
-
- )}
-
-
- Descarcă din DB — Magic
-
-
- GPKG + CSV + raport calitate (instant)
-
-
-
-
- )}
-
- {!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" && (
-
- void handleDownloadFromDb("magic")}
- >
- {downloadingFromDb ? (
-
- ) : (
-
- )}
- Descarcă din DB (Magic)
-
- {
- setBgJobId(null);
- setBgProgress(null);
- setBgPhaseTrail([]);
- }}
- >
- Închide
-
-
- )}
-
-
- )}
-
- {/* 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
-
-
-
void fetchDbSummary()}
- >
- {dbSummaryLoading ? (
-
- ) : (
-
- )}
- Reîncarcă
-
-
-
- {/* 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
-
- )}
-
-
- setDashboardSiruta(
- dashboardSiruta === uat.siruta
- ? null
- : uat.siruta,
- )
- }
- >
-
- Dashboard
-
-
- {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
+
+
+
+ {dbSummaryLoading ? (
+
+ ) : (
+
+ )}
+ Re\u00eencarc\u0103
+
+
+
+ {/* 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
+
+ )}
+
+
+ setDashboardSiruta(
+ dashboardSiruta === uat.siruta
+ ? null
+ : uat.siruta,
+ )
+ }
+ >
+
+ Dashboard
+
+
+ {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 ? (
+
+
void handleExportBundle("base")}
+ >
+ {exporting && exportProgress?.phase !== "Detalii parcele" ? (
+
+ ) : (
+
+ )}
+
+
+ Descarc\u0103 Terenuri \u0219i Cl\u0103diri
+
+
+ Sync + GPKG (din cache dac\u0103 e proasp\u0103t)
+
+
+
+
+
void handleExportBundle("magic")}
+ >
+ {exporting && exportProgress?.phase === "Detalii parcele" ? (
+
+ ) : (
+
+ )}
+
+
Magic
+
+ Sync + \u00eembog\u0103\u021bire (CF, proprietari, adres\u0103) + GPKG + CSV
+
+
+
+
+ ) : (
+
+
+ {!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.
+
+ void handleNoGeomScan()}
+ >
+
+ Re\u00eencearc\u0103 scanarea
+
+
+
+ );
+
+ // 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:
+
+
+
+ {"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`
+ : "")}
+
+
+ {includeNoGeom && (
+
+ 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";
+ })()}
+
+
+ )}
+
+ \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";
+ })()}
+
+
+ Generare GPKG + CSV
+ Comprimare ZIP + desc\u0103rcare
+
+
+ );
+
+ // 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}
+
+
void handleNoGeomScan()}
+ title="Re-scanare"
+ >
+
+
+
+
+ setIncludeNoGeom(e.target.checked)}
+ disabled={exporting}
+ className="h-4 w-4 rounded border-muted-foreground/30 accent-amber-600"
+ />
+
+ Include \u0219i parcelele f\u0103r\u0103 geometrie la export
+
+
+ {/* 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 && (
+
+ setIncludeNoGeom(e.target.checked)}
+ disabled={
+ exporting ||
+ (!!bgJobId && bgProgress?.status === "running")
+ }
+ className="h-4 w-4 rounded border-muted-foreground/30 accent-amber-600"
+ />
+
+ Include \u0219i parcelele f\u0103r\u0103 geometrie
+
+ {noGeomScanning && (
+
+ (scanare \u00een curs\u2026)
+
+ )}
+
+ )}
+
+ {/* Row 2: Background sync buttons */}
+ {session.connected && (
+
+
void handleSyncBackground("base")}
+ >
+ {bgJobId &&
+ bgProgress?.status === "running" &&
+ !bgPhaseTrail.some((p) => p.includes("\u00cembog\u0103\u021bire")) ? (
+
+ ) : (
+
+ )}
+
+
+ Sync fundal \u2014 Baz\u0103
+
+
+ Terenuri + cl\u0103diri \u2192 salveaz\u0103 \u00een DB
+
+
+
+
void handleSyncBackground("magic")}
+ >
+ {bgJobId &&
+ bgProgress?.status === "running" &&
+ bgPhaseTrail.some((p) => p.includes("\u00cembog\u0103\u021bire")) ? (
+
+ ) : (
+
+ )}
+
+
+ Sync fundal \u2014 Magic
+
+
+ Sync + \u00eembog\u0103\u021bire \u2192 salveaz\u0103 \u00een DB
+
+
+
+
+ )}
+
+ {/* Row 3: Download from DB buttons */}
+ {dbTotalFeatures > 0 && (
+
+
void handleDownloadFromDb("base")}
+ >
+ {downloadingFromDb ? (
+
+ ) : (
+
+ )}
+
+
+ Descarc\u0103 din DB \u2014 Baz\u0103
+
+
+ GPKG terenuri + cl\u0103diri (instant, f\u0103r\u0103 eTerra)
+
+
+
+
void handleDownloadFromDb("magic")}
+ >
+ {downloadingFromDb ? (
+
+ ) : (
+
+ )}
+
+
+ Descarc\u0103 din DB \u2014 Magic
+
+
+ GPKG + CSV + raport calitate (instant)
+
+
+
+
+ )}
+
+ {!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" && (
+
+ void handleDownloadFromDb("magic")}
+ >
+ {downloadingFromDb ? (
+
+ ) : (
+
+ )}
+ Descarc\u0103 din DB (Magic)
+
+ {
+ setBgJobId(null);
+ setBgProgress(null);
+ setBgPhaseTrail([]);
+ }}
+ >
+ \u00cenchide
+
+
+ )}
+
+
+ )}
+
+ {/* 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) && (
+ void handleExportLocal()}
+ className="border-violet-300 text-violet-700 dark:border-violet-700 dark:text-violet-300"
+ >
+ {exportingLocal ? (
+
+ ) : (
+
+ )}
+ Export local
+
+ )}
+ void fetchLayerCounts()}
+ >
+ {countingLayers ? (
+
+ ) : (
+
+ )}
+ {countingLayers ? "Se num\u0103r\u0103\u2026" : "Num\u0103r\u0103"}
+
+
+
+
+ {/* 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 (
+
+
+ setExpandedCategories((prev) => ({
+ ...prev,
+ [cat]: !prev[cat],
+ }))
+ }
+ >
+
+
+ {LAYER_CATEGORY_LABELS[cat]}
+
+
+ {layers.length}
+
+ {catTotal != null && catTotal > 0 && (
+
+ {catTotal.toLocaleString("ro-RO")} remote
+
+ )}
+ {catLocal != null && catLocal > 0 && (
+
+ {catLocal.toLocaleString("ro-RO")} local
+
+ )}
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+
+ {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 */}
+
+ void handleSyncLayer(layer.id)
+ }
+ className="border-violet-200 dark:border-violet-800"
+ title="Sincronizeaz\u0103 \u00een baza de date"
+ >
+ {isSyncing ? (
+
+ ) : (
+
+ )}
+
+ Sync
+
+
+ {/* GPKG (sync-first: syncs to DB if needed, then exports from DB) */}
+
+ void handleExportLayer(layer.id)
+ }
+ title={
+ localCount > 0
+ ? "Descarc\u0103 GPKG (din cache dac\u0103 e proasp\u0103t)"
+ : "Sincronizeaz\u0103 + descarc\u0103 GPKG"
+ }
+ >
+ {isDownloading ? (
+
+ ) : (
+
+ )}
+
+ GPKG
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+ })}
+
+ {/* 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 && (
+
+
+
+ void handleSetupPostgis()}
+ className="border-violet-300 text-violet-700 dark:border-violet-700 dark:text-violet-300"
+ >
+ {postgisRunning ? (
+
+ ) : (
+
+ )}
+ {postgisRunning
+ ? "Se activeaza\u2026"
+ : "Activeaza compatibilitate QGIS"}
+
+
+
+
+ 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:
+
+
+
+ QGIS \u2192 Layer \u2192 Add Layer \u2192 Add PostGIS Layers
+
+ New connection:
+
+
+
+ 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: () => (
+
+ ),
+ },
+);
+
+/* ------------------------------------------------------------------ */
+/* 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 */}
+
+ setSearchMode("cadastral")}
+ className={cn(
+ "px-3 py-1 text-xs rounded font-medium transition-colors",
+ searchMode === "cadastral"
+ ? "bg-background shadow text-foreground"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+
+ Nr. Cadastral
+
+ setSearchMode("owner")}
+ className={cn(
+ "px-3 py-1 text-xs rounded font-medium transition-colors",
+ searchMode === "owner"
+ ? "bg-background shadow text-foreground"
+ : "text-muted-foreground hover:text-foreground",
+ )}
+ >
+
+ Proprietar
+
+
+
+ {/* Cadastral search input */}
+ {searchMode === "cadastral" && (
+
+
+
+ Numere cadastrale (separate prin virgul\u0103 sau Enter)
+
+
+
+ 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.
+
+ )}
+
+
void handleSearch()}
+ disabled={
+ loadingFeatures ||
+ !featuresSearch.trim() ||
+ !session.connected
+ }
+ >
+ {loadingFeatures ? (
+
+ ) : (
+
+ )}
+ Caut\u0103
+
+
+ )}
+
+ {/* Owner search input */}
+ {searchMode === "owner" && (
+
+
+
+ Nume proprietar (caut\u0103 \u00een DB local + eTerra)
+
+
+
+ setOwnerSearch(e.target.value)}
+ onKeyDown={handleOwnerKeyDown}
+ />
+
+
+
void handleOwnerSearch()}
+ disabled={ownerLoading || ownerSearch.trim().length < 2}
+ >
+ {ownerLoading ? (
+
+ ) : (
+
+ )}
+ Caut\u0103
+
+
+ )}
+
+ {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 && (
+
{
+ for (const r of searchResults) addToList(r);
+ }}
+ >
+
+ Adaug\u0103 toate \u00een list\u0103
+
+ )}
+
+
+ Descarc\u0103 CSV
+
+
+
+
+ {/* Detail cards */}
+
+ {searchResults.map((p, idx) => (
+
+
+
+
+
+ Nr. Cad. {p.nrCad}
+
+ {!p.immovablePk && (
+
+ Parcela nu a fost g\u0103sit\u0103 \u00een eTerra.
+
+ )}
+
+
+
addToList(p)}
+ disabled={!p.immovablePk}
+ >
+
+
+
{
+ const text = [
+ `Nr. Cad: ${p.nrCad}`,
+ `Nr. CF: ${p.nrCF || "\u2014"}`,
+ p.nrCFVechi
+ ? `CF vechi: ${p.nrCFVechi}`
+ : null,
+ p.nrTopo
+ ? `Nr. Topo: ${p.nrTopo}`
+ : null,
+ p.suprafata != null
+ ? `Suprafata: ${p.suprafata.toLocaleString("ro-RO")} mp`
+ : null,
+ `Intravilan: ${p.intravilan || "\u2014"}`,
+ p.categorieFolosinta
+ ? `Categorie: ${p.categorieFolosinta}`
+ : null,
+ p.adresa ? `Adresa: ${p.adresa}` : null,
+ p.proprietariActuali
+ ? `Proprietari actuali: ${p.proprietariActuali}`
+ : null,
+ p.proprietariVechi
+ ? `Proprietari vechi: ${p.proprietariVechi}`
+ : null,
+ !p.proprietariActuali &&
+ !p.proprietariVechi &&
+ p.proprietari
+ ? `Proprietari: ${p.proprietari}`
+ : null,
+ p.solicitant
+ ? `Solicitant: ${p.solicitant}`
+ : null,
+ ]
+ .filter(Boolean)
+ .join("\n");
+ void navigator.clipboard.writeText(text);
+ }}
+ >
+
+
+
+
+
+
+ {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}"
+
+
+
{
+ for (const r of ownerResults)
+ addToList(ownerResultToParcelDetail(r));
+ }}
+ >
+
+ Adaug\u0103 toate \u00een list\u0103
+
+
+
+ Descarc\u0103 CSV
+
+
+
+
+
+ {ownerResults.map((r, idx) => (
+
+
+
+
+
+ Nr. Cad. {r.nrCad}
+
+
+ {r.source === "db"
+ ? "din baza de date"
+ : "eTerra online"}
+
+
+
+
+ addToList(ownerResultToParcelDetail(r))
+ }
+ >
+
+
+
{
+ const text = [
+ `Nr. Cad: ${r.nrCad}`,
+ r.nrCF ? `Nr. CF: ${r.nrCF}` : null,
+ r.proprietari
+ ? `Proprietari: ${r.proprietari}`
+ : null,
+ r.proprietariVechi
+ ? `Proprietari vechi: ${r.proprietariVechi}`
+ : null,
+ r.adresa ? `Adresa: ${r.adresa}` : null,
+ r.suprafata
+ ? `Suprafata: ${r.suprafata} mp`
+ : null,
+ ]
+ .filter(Boolean)
+ .join("\n");
+ void navigator.clipboard.writeText(text);
+ }}
+ >
+
+
+
+
+
+
+
+ {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)
+
+
+
{
+ setSearchList([]);
+ setListCfOrderResult("");
+ }}
+ >
+
+ Goleste
+
+
+
+ CSV din lista
+
+ {/* 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 (
+
+
+
+
+ void handleListCfDownloadZip()
+ }
+ >
+ {listCfDownloading ? (
+
+ ) : (
+
+ )}
+ Descarca Extrase CF
+
+
+ {`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 (
+
+
+
+ void handleListCfOrder()}
+ >
+ {listCfOrdering ? (
+
+ ) : (
+
+ )}
+ Scoate Extrase CF
+
+
+
+ {`Comanda ${newCount} extrase noi + ${updateCount} actualizari = ${totalCredits} credite. ${validCount} existente valide raman.`}
+
+
+
+ );
+ })()}
+
+
+
+ {/* Order result message */}
+ {listCfOrderResult && (
+
+ {listCfOrderResult}
+
+ )}
+
+
+
+
+
+
+ #
+
+
+ Nr. Cad
+
+
+ Nr. CF
+
+
+ Suprafata
+
+
+ Proprietari
+
+
+ Extras CF
+
+
+
+
+
+ {searchList.map((p, idx) => {
+ const cfStatus = cfStatusMap[p.nrCad];
+ const cfExpiry = cfExpiryDates[p.nrCad];
+ return (
+
+
+ {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."}
+
+
+
+
+
+ removeFromList(p.nrCad)}
+ >
+
+
+
+
+ );
+ })}
+
+
+
+
+
+ )}
+ >
+ );
+}