Files
ArchiTools/src/modules/parcel-sync/components/parcel-sync-module.tsx
T
AI Assistant 2886703d0f perf(parcel-sync): use useDeferredValue for UAT search input
React's useDeferredValue lets the input update immediately while
deferring the expensive filter (3186 items) to a lower priority.
Removes the setTimeout debounce in favor of React's built-in
concurrent rendering scheduler. Input stays responsive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:30:47 +02:00

4732 lines
204 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useCallback, useMemo, useRef, useDeferredValue } from "react";
import {
Search,
Download,
CheckCircle2,
XCircle,
Loader2,
MapPin,
Layers,
Sparkles,
ChevronDown,
ChevronUp,
FileDown,
LogOut,
Wifi,
WifiOff,
ClipboardCopy,
Trash2,
Plus,
RefreshCw,
Database,
HardDrive,
Clock,
ArrowDownToLine,
AlertTriangle,
BarChart3,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Badge } from "@/shared/components/ui/badge";
import { Card, CardContent } from "@/shared/components/ui/card";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/shared/components/ui/tabs";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu";
import { cn } from "@/shared/lib/utils";
import {
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 } from "lucide-react";
import { UatDashboard } from "./uat-dashboard";
import { EpayConnect, type EpaySessionStatus } from "./epay-connect";
import { EpayOrderButton } from "./epay-order-button";
import { EpayTab } from "./epay-tab";
/* ------------------------------------------------------------------ */
/* 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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-medium transition-all",
"hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
session.connected
? "border-emerald-200 bg-emerald-50/80 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-400"
: session.eterraMaintenance
? "border-amber-200 bg-amber-50/80 text-amber-600 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-400"
: connectionError
? "border-rose-200 bg-rose-50/80 text-rose-600 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-400"
: "border-muted-foreground/20 bg-muted/50 text-muted-foreground",
)}
>
{connecting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : session.connected ? (
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
</span>
) : session.eterraMaintenance ? (
<AlertTriangle className="h-3 w-3" />
) : connectionError ? (
<WifiOff className="h-3 w-3" />
) : (
<Wifi className="h-3 w-3 opacity-50" />
)}
<span className="hidden sm:inline">
{connecting
? "Se conectează…"
: session.connected
? "eTerra"
: session.eterraMaintenance
? "Mentenanță"
: connectionError
? "Eroare"
: "Deconectat"}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72 p-0">
{/* Status header */}
<div
className={cn(
"px-3 py-2.5 border-b",
session.connected
? "bg-emerald-50/50 dark:bg-emerald-950/20"
: "bg-muted/30",
)}
>
<div className="flex items-center justify-between">
<DropdownMenuLabel className="p-0 text-xs font-semibold">
Conexiune eTerra
</DropdownMenuLabel>
{session.connected && (
<span className="text-[10px] text-emerald-600 dark:text-emerald-400 font-mono">
{elapsedLabel}
</span>
)}
</div>
{session.connected && session.username && (
<p className="text-[11px] text-muted-foreground mt-0.5 truncate">
{session.username}
</p>
)}
{connectionError && (
<p className="text-[11px] text-rose-500 mt-0.5">
{connectionError}
</p>
)}
</div>
{/* Maintenance banner */}
{!session.connected && session.eterraMaintenance && (
<div className="px-3 py-3 text-xs border-b bg-amber-50/50 dark:bg-amber-950/20">
<div className="flex items-start gap-2">
<AlertTriangle className="h-3.5 w-3.5 text-amber-500 mt-0.5 shrink-0" />
<div>
<p className="font-medium text-amber-700 dark:text-amber-400">
eTerra este în mentenanță
</p>
<p className="mt-1 text-[11px] text-amber-600/80 dark:text-amber-400/70">
Platforma ANCPI nu este disponibilă momentan. Conectarea va fi
reactivată automat când serviciul revine online.
</p>
{session.eterraHealthMessage && (
<p className="mt-1 text-[10px] opacity-60 font-mono">
{session.eterraHealthMessage}
</p>
)}
</div>
</div>
</div>
)}
{/* Info when not connected (and not in maintenance) */}
{!session.connected &&
!connectionError &&
!session.eterraMaintenance && (
<div className="px-3 py-3 text-xs text-muted-foreground">
<p>Conexiunea se face automat când începi scrii un UAT.</p>
<p className="mt-1 text-[11px] opacity-70">
Credențialele sunt preluate din configurarea serverului.
</p>
</div>
)}
{/* Error detail (only when NOT maintenance — to avoid confusing users) */}
{!session.connected &&
connectionError &&
!session.eterraMaintenance && (
<div className="px-3 py-3 text-xs text-muted-foreground">
<p>
Conexiunea automată a eșuat. Verifică credențialele din
variabilele de mediu (ETERRA_USERNAME / ETERRA_PASSWORD).
</p>
</div>
)}
{/* Connected — active jobs info + disconnect */}
{session.connected && (
<>
{session.activeJobCount > 0 && (
<div className="px-3 py-2 border-b bg-amber-50/50 dark:bg-amber-950/20">
<p className="text-[11px] text-amber-700 dark:text-amber-400">
<span className="font-semibold">
{session.activeJobCount} job
{session.activeJobCount > 1 ? "-uri" : ""} activ
{session.activeJobCount > 1 ? "e" : ""}
</span>
{session.activeJobPhase && (
<span className="opacity-70">
{" "}
{session.activeJobPhase}
</span>
)}
</p>
</div>
)}
<DropdownMenuSeparator className="m-0" />
<div className="p-1.5">
<button
type="button"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
onClick={onDisconnect}
>
<LogOut className="h-3 w-3" />
Deconectare
</button>
</div>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
/* ------------------------------------------------------------------ */
/* Main Component */
/* ------------------------------------------------------------------ */
export function ParcelSyncModule() {
/* ── Server session ─────────────────────────────────────────── */
const [session, setSession] = useState<SessionStatus>({
connected: false,
activeJobCount: 0,
});
const [connecting, setConnecting] = useState(false);
const [connectionError, setConnectionError] = useState("");
const autoConnectAttempted = useRef(false);
const sessionPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
/* ── UAT autocomplete ───────────────────────────────────────── */
const [uatData, setUatData] = useState<UatEntry[]>([]);
const [uatQuery, setUatQuery] = useState("");
const [uatResults, setUatResults] = useState<UatEntry[]>([]);
const [showUatResults, setShowUatResults] = useState(false);
const [siruta, setSiruta] = useState("");
const [workspacePk, setWorkspacePk] = useState<number | null>(null);
const uatRef = useRef<HTMLDivElement>(null);
/* ── Export state ────────────────────────────────────────────── */
const [exportJobId, setExportJobId] = useState<string | null>(null);
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(
null,
);
const [phaseTrail, setPhaseTrail] = useState<string[]>([]);
const [exporting, setExporting] = useState(false);
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
/* ── Layer catalog UI ───────────────────────────────────────── */
const [expandedCategories, setExpandedCategories] = useState<
Record<string, boolean>
>({});
const [downloadingLayer, setDownloadingLayer] = useState<string | null>(null);
const [layerCounts, setLayerCounts] = useState<
Record<string, { count: number; error?: string }>
>({});
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<string, number>
>({});
const [syncRuns, setSyncRuns] = useState<SyncRunInfo[]>([]);
const [syncingSiruta, setSyncingSiruta] = useState("");
const [syncingLayer, setSyncingLayer] = useState<string | null>(null);
const [syncProgress, setSyncProgress] = useState("");
const [exportingLocal, setExportingLocal] = useState(false);
const refreshSyncRef = useRef<(() => void) | null>(null);
/* ── Global DB summary (all UATs) ────────────────────────────── */
type DbUatSummary = {
siruta: string;
uatName: string;
county: string | null;
layers: {
layerId: string;
count: number;
enrichedCount: number;
noGeomCount: number;
lastSynced: string | null;
}[];
totalFeatures: number;
totalEnriched: number;
totalNoGeom: number;
};
type DbSummary = {
uats: DbUatSummary[];
totalFeatures: number;
totalUats: number;
};
const [dbSummary, setDbSummary] = useState<DbSummary | null>(null);
const [dbSummaryLoading, setDbSummaryLoading] = useState(false);
/* ── PostGIS setup ───────────────────────────────────────────── */
const [postgisRunning, setPostgisRunning] = useState(false);
const [postgisResult, setPostgisResult] = useState<{
success: boolean;
message?: string;
details?: Record<string, unknown>;
error?: string;
} | null>(null);
/* ── Parcel search tab ──────────────────────────────────────── */
const [searchMode, setSearchMode] = useState<"cadastral" | "owner">(
"cadastral",
);
const [searchResults, setSearchResults] = useState<ParcelDetail[]>([]);
const [searchList, setSearchList] = useState<ParcelDetail[]>([]);
const [featuresSearch, setFeaturesSearch] = useState("");
const [loadingFeatures, setLoadingFeatures] = useState(false);
const [searchError, setSearchError] = useState("");
/* owner search */
const [ownerSearch, setOwnerSearch] = useState("");
const [ownerResults, setOwnerResults] = useState<OwnerSearchResult[]>([]);
const [ownerLoading, setOwnerLoading] = useState(false);
const [ownerError, setOwnerError] = useState("");
const [ownerNote, setOwnerNote] = useState("");
/* dashboard */
const [dashboardSiruta, setDashboardSiruta] = useState<string | null>(null);
/* ── ePay status (for CF extract features) ──────────────────── */
const [epayStatus, setEpayStatus] = useState<EpaySessionStatus>({ connected: false });
/** CF status map: nrCadastral -> "valid" | "expired" | "none" | "processing" */
const [cfStatusMap, setCfStatusMap] = useState<Record<string, string>>({});
/** Latest completed extract IDs per nrCadastral */
const [cfLatestIds, setCfLatestIds] = useState<Record<string, string>>({});
/** Expiry dates per nrCadastral (ISO string) */
const [cfExpiryDates, setCfExpiryDates] = useState<Record<string, string>>({});
/** 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<string | null>(null);
const [bgProgress, setBgProgress] = useState<ExportProgress | null>(null);
const [bgPhaseTrail, setBgPhaseTrail] = useState<string[]>([]);
const bgPollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [downloadingFromDb, setDownloadingFromDb] = useState(false);
/* ════════════════════════════════════════════════════════════ */
/* Load UAT data + check server session on mount */
/* ════════════════════════════════════════════════════════════ */
const fetchSession = useCallback(async () => {
try {
const res = await fetch("/api/eterra/session");
const data = (await res.json()) as SessionStatus;
setSession((prev) => {
// If eTerra was in maintenance but is now back online, reset auto-connect
if (
prev.eterraMaintenance &&
data.eterraAvailable &&
!data.eterraMaintenance
) {
autoConnectAttempted.current = false;
}
return data;
});
if (data.connected) setConnectionError("");
return data;
} catch {
return null;
}
}, []);
useEffect(() => {
// Load UATs from local DB (fast — no eTerra needed)
fetch("/api/eterra/uats")
.then((res) => res.json())
.then((data: { uats?: UatEntry[]; total?: number }) => {
if (data.uats && data.uats.length > 0) {
setUatData(data.uats);
} else {
// DB empty — seed from uat.json via POST, then load from uat.json
fetch("/api/eterra/uats", { method: "POST" }).catch(() => {});
fetch("/uat.json")
.then((res) => res.json())
.then((fallback: UatEntry[]) => setUatData(fallback))
.catch(() => {});
}
})
.catch(() => {
// API failed — fall back to static uat.json
fetch("/uat.json")
.then((res) => res.json())
.then((fallback: UatEntry[]) => setUatData(fallback))
.catch(() => {});
});
// Check existing server session on mount
void fetchSession();
// Poll session every 30s to stay in sync with other clients
sessionPollRef.current = setInterval(() => void fetchSession(), 30_000);
return () => {
if (sessionPollRef.current) clearInterval(sessionPollRef.current);
};
}, [fetchSession]);
/* ── Fetch global DB summary ─────────────────────────────────── */
const fetchDbSummary = useCallback(async () => {
setDbSummaryLoading(true);
try {
const res = await fetch("/api/eterra/db-summary");
const data = (await res.json()) as DbSummary;
if (data.uats) setDbSummary(data);
} catch {
// silent
}
setDbSummaryLoading(false);
}, []);
useEffect(() => {
void fetchDbSummary();
}, [fetchDbSummary]);
/* ════════════════════════════════════════════════════════════ */
/* Reload UAT data when session connects (county data may */
/* have been populated by the login flow) */
/* ════════════════════════════════════════════════════════════ */
const prevConnected = useRef(false);
useEffect(() => {
if (session.connected && !prevConnected.current) {
// Just connected — reload UATs after a short delay to let
// the server-side county refresh finish
const timer = setTimeout(() => {
fetch("/api/eterra/uats")
.then((res) => res.json())
.then((data: { uats?: UatEntry[] }) => {
if (data.uats && data.uats.length > 0) setUatData(data.uats);
})
.catch(() => {});
}, 5000);
return () => clearTimeout(timer);
}
prevConnected.current = session.connected;
}, [session.connected]);
/* ════════════════════════════════════════════════════════════ */
/* UAT autocomplete filter */
/* ════════════════════════════════════════════════════════════ */
// useDeferredValue lets React prioritize the input update over the filter
const deferredUatQuery = useDeferredValue(uatQuery);
useEffect(() => {
const raw = deferredUatQuery.trim();
if (raw.length < 2) {
setUatResults([]);
return;
}
const isDigit = /^\d+$/.test(raw);
const query = normalizeText(raw);
const nameMatches: typeof uatData = [];
const countyOnlyMatches: typeof uatData = [];
for (const item of uatData) {
if (isDigit) {
if (item.siruta.startsWith(raw)) nameMatches.push(item);
} else {
const nameMatch = normalizeText(item.name).includes(query);
const countyMatch =
item.county && normalizeText(item.county).includes(query);
if (nameMatch) nameMatches.push(item);
else if (countyMatch) countyOnlyMatches.push(item);
}
}
const results = [...nameMatches, ...countyOnlyMatches].slice(0, 12);
setUatResults(results);
}, [deferredUatQuery, uatData]);
/* ════════════════════════════════════════════════════════════ */
/* Auto-connect: trigger on first UAT keystroke */
/* ════════════════════════════════════════════════════════════ */
const triggerAutoConnect = useCallback(async () => {
if (session.connected || connecting || autoConnectAttempted.current) return;
// Don't attempt login when eTerra is in maintenance
if (session.eterraMaintenance) return;
autoConnectAttempted.current = true;
setConnecting(true);
setConnectionError("");
try {
const res = await fetch("/api/eterra/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "connect" }),
});
const data = (await res.json()) as {
success?: boolean;
error?: string;
maintenance?: boolean;
};
if (data.success) {
await fetchSession();
} else if (data.maintenance) {
// eTerra is in maintenance — set flag, DON'T show as connection error
setSession((prev) => ({
...prev,
eterraMaintenance: true,
eterraAvailable: false,
eterraHealthMessage: data.error ?? "eTerra în mentenanță",
}));
// Allow retry later when maintenance ends
autoConnectAttempted.current = false;
} else {
setConnectionError(data.error ?? "Eroare conectare");
}
} catch {
setConnectionError("Eroare rețea");
}
setConnecting(false);
}, [session.connected, session.eterraMaintenance, connecting, fetchSession]);
/* ════════════════════════════════════════════════════════════ */
/* Disconnect */
/* ════════════════════════════════════════════════════════════ */
const handleDisconnect = useCallback(async () => {
try {
const res = await fetch("/api/eterra/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "disconnect" }),
});
const data = (await res.json()) as {
success?: boolean;
error?: string;
};
if (data.success) {
setSession({ connected: false, activeJobCount: 0 });
autoConnectAttempted.current = false;
} else {
// Jobs are running — show warning
setConnectionError(data.error ?? "Nu se poate deconecta");
}
} catch {
setConnectionError("Eroare rețea");
}
}, []);
/* ════════════════════════════════════════════════════════════ */
/* Progress polling */
/* ════════════════════════════════════════════════════════════ */
const startPolling = useCallback((jid: string) => {
if (pollingRef.current) clearInterval(pollingRef.current);
pollingRef.current = setInterval(async () => {
try {
const res = await fetch(
`/api/eterra/progress?jobId=${encodeURIComponent(jid)}`,
);
const data = (await res.json()) as ExportProgress;
setExportProgress(data);
if (data.phase) {
setPhaseTrail((prev) => {
if (prev[prev.length - 1] === data.phase) return prev;
return [...prev, data.phase!];
});
}
if (data.status === "done" || data.status === "error") {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
}
} catch {
/* ignore polling errors */
}
}, 1000);
}, []);
useEffect(() => {
return () => {
if (pollingRef.current) clearInterval(pollingRef.current);
};
}, []);
/* ════════════════════════════════════════════════════════════ */
/* Export bundle (base / magic) */
/* ════════════════════════════════════════════════════════════ */
const handleExportBundle = useCallback(
async (mode: "base" | "magic") => {
if (!siruta || exporting) return;
const jobId = crypto.randomUUID();
setExportJobId(jobId);
setExportProgress(null);
setPhaseTrail([]);
setExporting(true);
startPolling(jobId);
try {
const res = await fetch("/api/eterra/export-bundle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
siruta,
jobId,
mode,
includeNoGeometry: includeNoGeom,
}),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(err.error ?? `HTTP ${res.status}`);
}
const blob = await res.blob();
const cd = res.headers.get("Content-Disposition") ?? "";
const match = /filename="?([^"]+)"?/.exec(cd);
const filename =
match?.[1] ??
(mode === "magic"
? `eterra_uat_${siruta}_magic.zip`
: `eterra_uat_${siruta}_terenuri_cladiri.zip`);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
// Mark progress as done after successful download
setExportProgress((prev) =>
prev
? {
...prev,
status: "done",
phase: "Finalizat",
downloaded: prev.total ?? 100,
total: prev.total ?? 100,
message: `Descărcare completă — ${filename}`,
note: undefined,
}
: null,
);
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare export";
setExportProgress((prev) =>
prev
? { ...prev, status: "error", message: msg }
: {
jobId,
downloaded: 0,
status: "error",
message: msg,
},
);
}
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
setExporting(false);
// Refresh sync status — data was synced to DB
refreshSyncRef.current?.();
},
[siruta, exporting, startPolling, includeNoGeom],
);
/* ════════════════════════════════════════════════════════════ */
/* No-geometry scan */
/* ════════════════════════════════════════════════════════════ */
const handleNoGeomScan = useCallback(
async (targetSiruta?: string) => {
const s = targetSiruta ?? siruta;
if (!s) return;
setNoGeomScanning(true);
setNoGeomScan(null);
setNoGeomScanSiruta(s);
const emptyQuality = {
withCadRef: 0,
withPaperCad: 0,
withPaperLb: 0,
withLandbook: 0,
withArea: 0,
withActiveStatus: 0,
useful: 0,
empty: 0,
};
const emptyResult = {
totalImmovables: 0,
withGeometry: 0,
remoteGisCount: 0,
remoteCladiriCount: 0,
noGeomCount: 0,
matchedByRef: 0,
matchedById: 0,
qualityBreakdown: emptyQuality,
localDbTotal: 0,
localDbWithGeom: 0,
localDbNoGeom: 0,
localDbEnriched: 0,
localDbEnrichedComplete: 0,
localSyncFresh: false,
scannedAt: "",
};
try {
// 2min timeout — scan is informational, should not block the page
const scanAbort = new AbortController();
const scanTimer = setTimeout(() => scanAbort.abort(), 120_000);
const res = await fetch("/api/eterra/no-geom-scan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
siruta: s,
workspacePk: workspacePk ?? undefined,
}),
signal: scanAbort.signal,
});
clearTimeout(scanTimer);
const data = (await res.json()) as Record<string, unknown>;
if (data.error) {
console.warn("[no-geom-scan]", data.error);
setNoGeomScan(emptyResult);
} else {
const qb = (data.qualityBreakdown ?? {}) as Record<string, unknown>;
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<string, { count: number; error?: string }>;
error?: string;
};
if (data.counts) {
setLayerCounts(data.counts);
setLayerCountSiruta(siruta);
// Save non-zero counts to history
const now = new Date().toISOString();
const today = now.slice(0, 10);
const newEntries: typeof layerHistory = [];
for (const [layerId, info] of Object.entries(data.counts)) {
if (info.count > 0) {
const layer = LAYER_CATALOG.find((l) => l.id === layerId);
newEntries.push({
layerId,
label: layer?.label ?? layerId,
count: info.count,
time: now,
siruta,
});
}
}
setLayerHistory((prev) => {
// Keep today's entries only, add new batch
const kept = prev.filter(
(e) => e.time.slice(0, 10) === today && e.siruta !== siruta,
);
const merged = [...kept, ...newEntries];
try {
localStorage.setItem(
"parcel-sync:layer-history",
JSON.stringify(merged),
);
} catch {
// quota
}
return merged;
});
}
} catch {
// silent
}
setCountingLayers(false);
}, [siruta, countingLayers]);
/* ════════════════════════════════════════════════════════════ */
/* Sync status — load local feature counts for current UAT */
/* ════════════════════════════════════════════════════════════ */
const fetchSyncStatus = useCallback(async () => {
if (!siruta) return;
try {
const res = await fetch(`/api/eterra/sync-status?siruta=${siruta}`);
const data = (await res.json()) as {
localCounts?: Record<string, number>;
runs?: SyncRunInfo[];
};
if (data.localCounts) setSyncLocalCounts(data.localCounts);
if (data.runs) setSyncRuns(data.runs);
setSyncingSiruta(siruta);
} catch {
// silent
}
}, [siruta]);
// Keep ref in sync so callbacks defined earlier can trigger refresh
refreshSyncRef.current = () => void fetchSyncStatus();
// Auto-fetch sync status when siruta changes
useEffect(() => {
if (siruta && /^\d+$/.test(siruta)) {
void fetchSyncStatus();
}
}, [siruta, fetchSyncStatus]);
const handleSyncLayer = useCallback(
async (layerId: string) => {
if (!siruta || syncingLayer) return;
setSyncingLayer(layerId);
setSyncProgress("Sincronizare pornită…");
try {
const res = await fetch("/api/eterra/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
siruta,
layerId,
jobId: crypto.randomUUID(),
}),
});
const data = (await res.json()) as {
status?: string;
newFeatures?: number;
removedFeatures?: number;
totalLocal?: number;
error?: string;
};
if (data.error) {
setSyncProgress(`Eroare: ${data.error}`);
} else {
setSyncProgress(
`Finalizat — ${data.newFeatures ?? 0} noi, ${data.removedFeatures ?? 0} șterse, ${data.totalLocal ?? 0} total local`,
);
// Refresh sync status
await fetchSyncStatus();
}
} catch {
setSyncProgress("Eroare rețea");
}
// Clear progress after 8s
setTimeout(() => {
setSyncingLayer(null);
setSyncProgress("");
}, 8_000);
},
[siruta, syncingLayer, fetchSyncStatus],
);
const handleExportLocal = useCallback(
async (layerIds?: string[]) => {
if (!siruta || exportingLocal) return;
setExportingLocal(true);
try {
const res = await fetch("/api/eterra/export-local", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
siruta,
...(layerIds ? { layerIds } : { allLayers: true }),
}),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(err.error ?? `HTTP ${res.status}`);
}
const blob = await res.blob();
const cd = res.headers.get("Content-Disposition") ?? "";
const match = /filename="?([^"]+)"?/.exec(cd);
const filename =
match?.[1] ??
`eterra_local_${siruta}.${layerIds?.length === 1 ? "gpkg" : "zip"}`;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare export";
setSyncProgress(msg);
setTimeout(() => setSyncProgress(""), 5_000);
}
setExportingLocal(false);
},
[siruta, exportingLocal],
);
/* ════════════════════════════════════════════════════════════ */
/* Background sync — fire-and-forget server-side processing */
/* ════════════════════════════════════════════════════════════ */
const startBgPolling = useCallback(
(jid: string) => {
if (bgPollingRef.current) clearInterval(bgPollingRef.current);
bgPollingRef.current = setInterval(async () => {
try {
const res = await fetch(
`/api/eterra/progress?jobId=${encodeURIComponent(jid)}`,
);
const data = (await res.json()) as ExportProgress;
setBgProgress(data);
if (data.phase) {
setBgPhaseTrail((prev) => {
if (prev[prev.length - 1] === data.phase) return prev;
return [...prev, data.phase!];
});
}
if (data.status === "done" || data.status === "error") {
if (bgPollingRef.current) {
clearInterval(bgPollingRef.current);
bgPollingRef.current = null;
}
// Clean localStorage marker
try {
localStorage.removeItem("parcel-sync:bg-job");
} catch {
/* */
}
// Refresh sync status and DB summary
refreshSyncRef.current?.();
void fetchDbSummary();
}
} catch {
/* ignore polling errors */
}
}, 1500);
},
[fetchDbSummary],
);
// Cleanup bg polling on unmount
useEffect(() => {
return () => {
if (bgPollingRef.current) clearInterval(bgPollingRef.current);
};
}, []);
// Recover background job from localStorage on mount
useEffect(() => {
try {
const raw = localStorage.getItem("parcel-sync:bg-job");
if (!raw) return;
const saved = JSON.parse(raw) as {
jobId: string;
siruta: string;
startedAt: string;
};
// Ignore jobs older than 8 hours
const age = Date.now() - new Date(saved.startedAt).getTime();
if (age > 8 * 60 * 60 * 1000) {
localStorage.removeItem("parcel-sync:bg-job");
return;
}
// Check if job is still running
void (async () => {
try {
const res = await fetch(
`/api/eterra/progress?jobId=${encodeURIComponent(saved.jobId)}`,
);
const data = (await res.json()) as ExportProgress;
if (data.status === "running") {
setBgJobId(saved.jobId);
setBgProgress(data);
if (data.phase) setBgPhaseTrail([data.phase]);
startBgPolling(saved.jobId);
} else if (data.status === "done") {
setBgJobId(saved.jobId);
setBgProgress(data);
if (data.phase) setBgPhaseTrail(["Sincronizare completă"]);
localStorage.removeItem("parcel-sync:bg-job");
} else {
localStorage.removeItem("parcel-sync:bg-job");
}
} catch {
localStorage.removeItem("parcel-sync:bg-job");
}
})();
} catch {
/* */
}
}, [startBgPolling]);
const handleSyncBackground = useCallback(
async (mode: "base" | "magic") => {
if (!siruta || exporting) return;
try {
const res = await fetch("/api/eterra/sync-background", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
siruta,
mode,
includeNoGeometry: includeNoGeom,
}),
});
const data = (await res.json()) as { jobId?: string; error?: string };
if (!res.ok || data.error) {
setSyncProgress(data.error ?? `Eroare ${res.status}`);
setTimeout(() => setSyncProgress(""), 5_000);
return;
}
const jid = data.jobId!;
setBgJobId(jid);
setBgProgress({
jobId: jid,
downloaded: 0,
total: 100,
status: "running",
phase: "Pornire sincronizare fundal",
});
setBgPhaseTrail(["Pornire sincronizare fundal"]);
// Persist in localStorage so we can recover on page refresh
try {
localStorage.setItem(
"parcel-sync:bg-job",
JSON.stringify({
jobId: jid,
siruta,
startedAt: new Date().toISOString(),
}),
);
} catch {
/* */
}
startBgPolling(jid);
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare rețea";
setSyncProgress(msg);
setTimeout(() => setSyncProgress(""), 5_000);
}
},
[siruta, exporting, includeNoGeom, startBgPolling],
);
const handleDownloadFromDb = useCallback(
async (mode: "base" | "magic") => {
if (!siruta || downloadingFromDb) return;
setDownloadingFromDb(true);
try {
const res = await fetch("/api/eterra/export-local", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ siruta, mode }),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(err.error ?? `HTTP ${res.status}`);
}
const blob = await res.blob();
const cd = res.headers.get("Content-Disposition") ?? "";
const match = /filename="?([^"]+)"?/.exec(cd);
const filename = match?.[1] ?? `eterra_uat_${siruta}_${mode}_local.zip`;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (error) {
const msg =
error instanceof Error ? error.message : "Eroare descărcare";
setSyncProgress(msg);
setTimeout(() => setSyncProgress(""), 5_000);
}
setDownloadingFromDb(false);
},
[siruta, downloadingFromDb],
);
// Sync multiple layers sequentially (for "sync all" / "sync category")
const [syncQueue, setSyncQueue] = useState<string[]>([]);
const syncQueueRef = useRef<string[]>([]);
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<string, string>;
latestById?: Record<string, { id: string; expiresAt: string | null }>;
};
if (data.statusMap) {
setCfStatusMap((prev) => ({ ...prev, ...data.statusMap }));
}
if (data.latestById) {
const idMap: Record<string, string> = {};
const expiryMap: Record<string, string> = {};
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<string>();
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<string, LayerCatalogItem[]> = {};
for (const layer of LAYER_CATALOG) {
if (!grouped[layer.category]) grouped[layer.category] = [];
grouped[layer.category]!.push(layer);
}
return grouped;
}, []);
const sirutaValid = siruta.length > 0 && /^\d+$/.test(siruta);
const progressPct =
exportProgress?.total && exportProgress.total > 0
? Math.round((exportProgress.downloaded / exportProgress.total) * 100)
: 0;
// DB status: which layers have data for the current UAT
const dbLayersSummary = useMemo(() => {
if (!sirutaValid || syncingSiruta !== siruta) return [];
return LAYER_CATALOG.filter((l) => (syncLocalCounts[l.id] ?? 0) > 0).map(
(l) => {
const count = syncLocalCounts[l.id] ?? 0;
const lastRun = syncRuns.find(
(r) => r.layerId === l.id && r.status === "done",
);
const lastSynced = lastRun?.completedAt
? new Date(lastRun.completedAt)
: null;
const ageMs = lastSynced ? Date.now() - lastSynced.getTime() : null;
const isFresh = ageMs !== null ? ageMs < 168 * 60 * 60 * 1000 : false;
return { ...l, count, lastSynced, isFresh };
},
);
}, [sirutaValid, syncingSiruta, siruta, syncLocalCounts, syncRuns]);
const dbTotalFeatures = dbLayersSummary.reduce((sum, l) => sum + l.count, 0);
const relativeTime = (date: Date | null) => {
if (!date) return "niciodată";
const mins = Math.floor((Date.now() - date.getTime()) / 60_000);
if (mins < 1) return "acum";
if (mins < 60) return `acum ${mins} min`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `acum ${hours}h`;
const days = Math.floor(hours / 24);
return `acum ${days}z`;
};
/* ════════════════════════════════════════════════════════════ */
/* Render */
/* ════════════════════════════════════════════════════════════ */
return (
<Tabs defaultValue="search" className="space-y-4">
{/* ═══════════════════════ Persistent header ═══════════════ */}
<div className="space-y-3">
{/* UAT + Connection row */}
<div className="flex items-start gap-3">
{/* UAT autocomplete — always visible */}
<div className="relative flex-1 min-w-0" ref={uatRef}>
<div className="relative">
<MapPin className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
id="uat-search"
placeholder="Selectează UAT — scrie nume sau cod SIRUTA…"
value={uatQuery}
onChange={(e) => {
setUatQuery(e.target.value);
setShowUatResults(true);
// Auto-connect on first keystroke
if (e.target.value.trim().length >= 1) {
void triggerAutoConnect();
}
}}
onFocus={() => setShowUatResults(true)}
onBlur={() => setTimeout(() => setShowUatResults(false), 150)}
className="pl-9 font-medium"
autoComplete="off"
/>
</div>
{/* Selected indicator chip */}
{sirutaValid && (
<div className="absolute right-2 top-1.5">
<Badge
variant="outline"
className="text-[10px] font-mono bg-background"
>
SIRUTA {siruta}
</Badge>
</div>
)}
{/* Dropdown */}
{showUatResults && uatResults.length > 0 && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-60 overflow-auto">
{uatResults.map((item) => (
<button
key={item.siruta}
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
onMouseDown={(e) => {
e.preventDefault();
const label = item.county
? `${item.name} (${item.siruta}), jud. ${item.county}`
: `${item.name} (${item.siruta})`;
setUatQuery(label);
setSiruta(item.siruta);
setWorkspacePk(item.workspacePk ?? null);
setShowUatResults(false);
setSearchResults([]);
}}
>
<span className="flex items-center gap-1.5 min-w-0 flex-wrap">
<span className="font-medium">{item.name}</span>
<span className="text-muted-foreground">
({item.siruta})
</span>
{item.county && (
<span className="text-muted-foreground">
{" "}
<span className="font-medium text-foreground/70">
jud. {item.county}
</span>
</span>
)}
{(item.localFeatures ?? 0) > 0 && (
<span className="inline-flex items-center rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400 shrink-0">
{(item.localFeatures ?? 0).toLocaleString("ro")} local
</span>
)}
</span>
</button>
))}
</div>
)}
</div>
{/* Connection pills */}
<div className="flex items-center gap-2">
<EpayConnect
triggerConnect={sirutaValid}
onStatusChange={setEpayStatus}
/>
<ConnectionPill
session={session}
connecting={connecting}
connectionError={connectionError}
onDisconnect={handleDisconnect}
/>
</div>
</div>
{/* Tab bar */}
<TabsList>
<TabsTrigger value="search" className="gap-1.5">
<Search className="h-4 w-4" />
Căutare Parcele
</TabsTrigger>
<TabsTrigger value="layers" className="gap-1.5">
<Layers className="h-4 w-4" />
Catalog Layere
</TabsTrigger>
<TabsTrigger value="export" className="gap-1.5">
<Download className="h-4 w-4" />
Export
</TabsTrigger>
<TabsTrigger value="database" className="gap-1.5">
<Database className="h-4 w-4" />
Baza de Date
</TabsTrigger>
<TabsTrigger value="extracts" className="gap-1.5">
<FileText className="h-4 w-4" />
Extrase CF
</TabsTrigger>
</TabsList>
</div>
{/* ═══════════════════════════════════════════════════════ */}
{/* Tab 1: Parcel search */}
{/* ═══════════════════════════════════════════════════════ */}
<TabsContent value="search" className="space-y-4">
{!sirutaValid ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Search className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>Selectează un UAT mai sus pentru a căuta parcele.</p>
</CardContent>
</Card>
) : (
<>
{/* Search input — mode toggle + input */}
<Card>
<CardContent className="pt-4 space-y-3">
{/* Mode toggle */}
<div className="flex gap-1 p-0.5 bg-muted rounded-md w-fit">
<button
onClick={() => 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",
)}
>
<Search className="inline h-3 w-3 mr-1 -mt-0.5" />
Nr. Cadastral
</button>
<button
onClick={() => 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",
)}
>
<User className="inline h-3 w-3 mr-1 -mt-0.5" />
Proprietar
</button>
</div>
{/* Cadastral search input */}
{searchMode === "cadastral" && (
<div className="flex gap-3 items-end">
<div className="space-y-1 flex-1">
<Label className="text-xs">
Numere cadastrale (separate prin virgulă sau Enter)
</Label>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="ex: 62580 sau 62580, 62581, 62582"
className="pl-9"
value={featuresSearch}
onChange={(e) => setFeaturesSearch(e.target.value)}
onKeyDown={handleSearchKeyDown}
disabled={!session.connected}
/>
</div>
{!session.connected && (
<p className="text-xs text-muted-foreground">
Necesită conexiune eTerra. Folosește modul Proprietar
pentru a căuta offline în DB.
</p>
)}
</div>
<Button
onClick={() => void handleSearch()}
disabled={
loadingFeatures ||
!featuresSearch.trim() ||
!session.connected
}
>
{loadingFeatures ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Search className="mr-2 h-4 w-4" />
)}
Caută
</Button>
</div>
)}
{/* Owner search input */}
{searchMode === "owner" && (
<div className="flex gap-3 items-end">
<div className="space-y-1 flex-1">
<Label className="text-xs">
Nume proprietar (caută în DB local + eTerra)
</Label>
<div className="relative">
<User className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="ex: Popescu Ion"
className="pl-9"
value={ownerSearch}
onChange={(e) => setOwnerSearch(e.target.value)}
onKeyDown={handleOwnerKeyDown}
/>
</div>
</div>
<Button
onClick={() => void handleOwnerSearch()}
disabled={ownerLoading || ownerSearch.trim().length < 2}
>
{ownerLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Search className="mr-2 h-4 w-4" />
)}
Caută
</Button>
</div>
)}
{searchMode === "cadastral" && searchError && (
<p className="text-xs text-destructive">{searchError}</p>
)}
{searchMode === "owner" && ownerError && (
<p className="text-xs text-destructive">{ownerError}</p>
)}
{searchMode === "owner" && ownerNote && (
<p className="text-xs text-muted-foreground">{ownerNote}</p>
)}
</CardContent>
</Card>
{/* ─── Cadastral search results ────────────── */}
{searchMode === "cadastral" && (
<>
{/* Results */}
{loadingFeatures && searchResults.length === 0 && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Loader2 className="h-10 w-10 mx-auto mb-3 animate-spin opacity-50" />
<p>Se caută în eTerra...</p>
<p className="text-xs mt-1 opacity-60">
Prima căutare pe un UAT nou poate dura ~10-30s (se
încarcă lista de județe).
</p>
</CardContent>
</Card>
)}
{searchResults.length > 0 && (
<>
{/* Action bar */}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{searchResults.length} rezultat
{searchResults.length > 1 ? "e" : ""}
{searchList.length > 0 && (
<span className="ml-2">
· <strong>{searchList.length}</strong> în listă
</span>
)}
</span>
<div className="flex gap-2">
{searchResults.length > 0 && (
<Button
size="sm"
variant="outline"
onClick={() => {
for (const r of searchResults) addToList(r);
}}
>
<Plus className="mr-1 h-3.5 w-3.5" />
Adaugă toate în listă
</Button>
)}
<Button
size="sm"
variant="default"
onClick={downloadCSV}
disabled={
searchResults.length === 0 &&
searchList.length === 0
}
>
<FileDown className="mr-1 h-3.5 w-3.5" />
Descarcă CSV
</Button>
</div>
</div>
{/* Detail cards */}
<div className="space-y-3">
{searchResults.map((p, idx) => (
<Card
key={`${p.nrCad}-${p.immovablePk}-${idx}`}
className={cn(
"transition-colors",
!p.immovablePk && "opacity-60",
)}
>
<CardContent className="pt-4">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-lg font-semibold tabular-nums">
Nr. Cad. {p.nrCad}
</h3>
{!p.immovablePk && (
<p className="text-xs text-destructive">
Parcela nu a fost găsită în eTerra.
</p>
)}
</div>
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
title="Adaugă în listă"
onClick={() => addToList(p)}
disabled={!p.immovablePk}
>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
title="Copiaza detalii"
onClick={() => {
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);
}}
>
<ClipboardCopy className="h-3.5 w-3.5" />
</Button>
{/* 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 (
<TooltipProvider>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="text-[10px] border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-400 cursor-default"
>
Extras CF
</Badge>
</TooltipTrigger>
<TooltipContent>
{cfExpiry ? `Valid pana la ${formatShortDate(cfExpiry)}` : "Extras CF valid"}
</TooltipContent>
</Tooltip>
{extractId && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-emerald-600"
asChild
>
<a
href={`/api/ancpi/download?id=${extractId}`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-3.5 w-3.5" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>Descarca extras CF</TooltipContent>
</Tooltip>
)}
</div>
</TooltipProvider>
);
}
if (cfStatus === "expired") {
return (
<TooltipProvider>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="text-[10px] border-orange-200 text-orange-600 dark:border-orange-800 dark:text-orange-400 cursor-default"
>
Expirat
</Badge>
</TooltipTrigger>
<TooltipContent>
{cfExpiry ? `Expirat pe ${formatShortDate(cfExpiry)}` : "Extras CF expirat"}
</TooltipContent>
</Tooltip>
<EpayOrderButton
nrCadastral={p.nrCad}
siruta={siruta}
judetName={selectedUat?.county ?? ""}
uatName={selectedUat?.name ?? ""}
label="Actualizeaza"
tooltipText="Comanda extras CF nou (1 credit)"
/>
</div>
</TooltipProvider>
);
}
if (cfStatus === "processing") {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="text-[10px] border-yellow-200 text-yellow-600 dark:border-yellow-800 dark:text-yellow-400 animate-pulse cursor-default"
>
Se proceseaza...
</Badge>
</TooltipTrigger>
<TooltipContent>Comanda in curs de procesare</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
// "none" or unknown
return (
<EpayOrderButton
nrCadastral={p.nrCad}
siruta={siruta}
judetName={selectedUat?.county ?? ""}
uatName={selectedUat?.name ?? ""}
tooltipText="Comanda extras CF (1 credit)"
/>
);
})()}
</div>
</div>
{p.immovablePk && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-2 text-sm">
<div>
<span className="text-xs text-muted-foreground block">
Nr. CF
</span>
<span className="font-medium">
{p.nrCF || "—"}
</span>
</div>
{p.nrCFVechi && (
<div>
<span className="text-xs text-muted-foreground block">
CF vechi
</span>
<span>{p.nrCFVechi}</span>
</div>
)}
<div>
<span className="text-xs text-muted-foreground block">
Nr. Topo
</span>
<span>{p.nrTopo || "—"}</span>
</div>
<div>
<span className="text-xs text-muted-foreground block">
Suprafață
</span>
<span className="tabular-nums">
{p.suprafata != null
? formatArea(p.suprafata)
: "—"}
</span>
</div>
<div>
<span className="text-xs text-muted-foreground block">
Intravilan
</span>
<Badge
variant={
p.intravilan === "Da"
? "default"
: p.intravilan === "Nu"
? "secondary"
: "outline"
}
className="text-[11px]"
>
{p.intravilan || "—"}
</Badge>
</div>
{p.categorieFolosinta && (
<div className="col-span-2">
<span className="text-xs text-muted-foreground block">
Categorii folosință
</span>
<span className="text-xs">
{p.categorieFolosinta}
</span>
</div>
)}
{p.adresa && (
<div className="col-span-2">
<span className="text-xs text-muted-foreground block">
Adresă
</span>
<span>{p.adresa}</span>
</div>
)}
{(p.proprietariActuali ||
p.proprietariVechi) && (
<div className="col-span-2 lg:col-span-4">
{p.proprietariActuali && (
<div className="mb-1">
<span className="text-xs text-muted-foreground block">
Proprietari actuali
</span>
<span className="font-medium text-sm">
{p.proprietariActuali}
</span>
</div>
)}
{p.proprietariVechi && (
<div>
<span className="text-xs text-muted-foreground block">
Proprietari anteriori
</span>
<span className="text-[11px] text-muted-foreground/80">
{p.proprietariVechi}
</span>
</div>
)}
{!p.proprietariActuali &&
!p.proprietariVechi &&
p.proprietari && (
<div>
<span className="text-xs text-muted-foreground block">
Proprietari
</span>
<span>{p.proprietari}</span>
</div>
)}
</div>
)}
{p.solicitant && (
<div className="col-span-2">
<span className="text-xs text-muted-foreground block">
Solicitant
</span>
<span>{p.solicitant}</span>
</div>
)}
</div>
)}
</CardContent>
</Card>
))}
</div>
</>
)}
{/* Empty state when no search has been done */}
{searchMode === "cadastral" &&
searchResults.length === 0 &&
!loadingFeatures &&
!searchError && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Search className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>Introdu un număr cadastral și apasă Caută.</p>
<p className="text-xs mt-1 opacity-60">
Poți căuta mai multe parcele simultan, separate prin
virgulă.
</p>
</CardContent>
</Card>
)}
</>
)}
{/* ─── Owner search results ────────────────── */}
{searchMode === "owner" && (
<>
{ownerLoading && ownerResults.length === 0 && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Loader2 className="h-10 w-10 mx-auto mb-3 animate-spin opacity-50" />
<p>Se caută proprietar...</p>
<p className="text-xs mt-1 opacity-60">
Caută mai întâi în DB local (date îmbogățite), apoi pe
eTerra.
</p>
</CardContent>
</Card>
)}
{ownerResults.length > 0 && (
<>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{ownerResults.length} rezultat
{ownerResults.length > 1 ? "e" : ""} pentru &quot;
{ownerSearch}&quot;
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
for (const r of ownerResults)
addToList(ownerResultToParcelDetail(r));
}}
>
<Plus className="mr-1 h-3.5 w-3.5" />
Adaugă toate în listă
</Button>
<Button
size="sm"
variant="default"
onClick={downloadCSV}
disabled={
ownerResults.length === 0 && searchList.length === 0
}
>
<FileDown className="mr-1 h-3.5 w-3.5" />
Descarcă CSV
</Button>
</div>
</div>
<div className="space-y-3">
{ownerResults.map((r, idx) => (
<Card key={`owner-${r.nrCad}-${idx}`}>
<CardContent className="pt-4">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-lg font-semibold tabular-nums">
Nr. Cad. {r.nrCad}
</h3>
<Badge
variant="outline"
className="text-[10px] mt-1"
>
{r.source === "db"
? "din baza de date"
: "eTerra online"}
</Badge>
</div>
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
title="Adaugă în listă"
onClick={() =>
addToList(ownerResultToParcelDetail(r))
}
>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
title="Copiaza detalii"
onClick={() => {
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);
}}
>
<ClipboardCopy className="h-3.5 w-3.5" />
</Button>
{/* 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 (
<TooltipProvider>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="text-[10px] border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-400 cursor-default"
>
Extras CF
</Badge>
</TooltipTrigger>
<TooltipContent>
{cfExpiry ? `Valid pana la ${formatShortDate(cfExpiry)}` : "Extras CF valid"}
</TooltipContent>
</Tooltip>
{extractId && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-emerald-600"
asChild
>
<a
href={`/api/ancpi/download?id=${extractId}`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-3.5 w-3.5" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>Descarca extras CF</TooltipContent>
</Tooltip>
)}
</div>
</TooltipProvider>
);
}
if (cfStatus === "expired") {
return (
<TooltipProvider>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="text-[10px] border-orange-200 text-orange-600 dark:border-orange-800 dark:text-orange-400 cursor-default"
>
Expirat
</Badge>
</TooltipTrigger>
<TooltipContent>
{cfExpiry ? `Expirat pe ${formatShortDate(cfExpiry)}` : "Extras CF expirat"}
</TooltipContent>
</Tooltip>
<EpayOrderButton
nrCadastral={r.nrCad}
siruta={siruta}
judetName={selectedUat?.county ?? ""}
uatName={selectedUat?.name ?? ""}
label="Actualizeaza"
tooltipText="Comanda extras CF nou (1 credit)"
/>
</div>
</TooltipProvider>
);
}
if (cfStatus === "processing") {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="text-[10px] border-yellow-200 text-yellow-600 dark:border-yellow-800 dark:text-yellow-400 animate-pulse cursor-default"
>
Se proceseaza...
</Badge>
</TooltipTrigger>
<TooltipContent>Comanda in curs de procesare</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return (
<EpayOrderButton
nrCadastral={r.nrCad}
siruta={siruta}
judetName={selectedUat?.county ?? ""}
uatName={selectedUat?.name ?? ""}
tooltipText="Comanda extras CF (1 credit)"
/>
);
})()}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-2 text-sm">
{r.nrCF && (
<div>
<span className="text-xs text-muted-foreground block">
Nr. CF
</span>
<span className="font-medium">{r.nrCF}</span>
</div>
)}
{r.suprafata && (
<div>
<span className="text-xs text-muted-foreground block">
Suprafață
</span>
<span className="tabular-nums">
{typeof r.suprafata === "number"
? formatArea(r.suprafata)
: `${r.suprafata} mp`}
</span>
</div>
)}
{r.intravilan && (
<div>
<span className="text-xs text-muted-foreground block">
Intravilan
</span>
<Badge
variant={
r.intravilan === "Da"
? "default"
: r.intravilan === "Nu"
? "secondary"
: "outline"
}
className="text-[11px]"
>
{r.intravilan}
</Badge>
</div>
)}
{r.categorieFolosinta && (
<div className="col-span-2">
<span className="text-xs text-muted-foreground block">
Categorii folosință
</span>
<span className="text-xs">
{r.categorieFolosinta}
</span>
</div>
)}
{r.adresa && (
<div className="col-span-2">
<span className="text-xs text-muted-foreground block">
Adresă
</span>
<span>{r.adresa}</span>
</div>
)}
{r.proprietari && (
<div className="col-span-2 lg:col-span-4">
<span className="text-xs text-muted-foreground block">
Proprietari actuali
</span>
<span className="font-medium text-sm">
{r.proprietari}
</span>
</div>
)}
{r.proprietariVechi && (
<div className="col-span-2 lg:col-span-4">
<span className="text-xs text-muted-foreground block">
Proprietari anteriori
</span>
<span className="text-[11px] text-muted-foreground/80">
{r.proprietariVechi}
</span>
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
</>
)}
{ownerResults.length === 0 && !ownerLoading && !ownerError && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<User className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>Introdu numele proprietarului și apasă Caută.</p>
<p className="text-xs mt-1 opacity-60">
Caută în datele îmbogățite (DB local) și pe eTerra.
<br />
Pentru rezultate complete, lansează &quot;Sync fundal
Magic&quot; în tab-ul Export.
</p>
</CardContent>
</Card>
)}
</>
)}
{/* Saved list */}
{searchList.length > 0 && (
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<h3 className="text-sm font-medium">
Lista mea ({searchList.length} parcele)
</h3>
<div className="flex gap-2 flex-wrap">
<Button
size="sm"
variant="outline"
onClick={() => {
setSearchList([]);
setListCfOrderResult("");
}}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
Goleste
</Button>
<Button size="sm" variant="outline" onClick={downloadCSV}>
<FileDown className="mr-1 h-3.5 w-3.5" />
CSV din lista
</Button>
{/* 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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-400"
disabled={listCfDownloading}
onClick={() => void handleListCfDownloadZip()}
>
{listCfDownloading ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Archive className="mr-1 h-3.5 w-3.5" />
)}
Descarca Extrase CF
</Button>
</TooltipTrigger>
<TooltipContent>{`Descarca ZIP cu ${validCount} extrase valide din lista`}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
})()}
{/* 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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
disabled={listCfOrdering}
onClick={() => void handleListCfOrder()}
>
{listCfOrdering ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<FileText className="mr-1 h-3.5 w-3.5" />
)}
Scoate Extrase CF
</Button>
</TooltipTrigger>
<TooltipContent>
{`Comanda ${newCount} extrase noi + ${updateCount} actualizari = ${totalCredits} credite. ${validCount} existente valide raman.`}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
})()}
</div>
</div>
{/* Order result message */}
{listCfOrderResult && (
<p className={cn(
"text-xs mb-2",
listCfOrderResult.startsWith("Eroare")
? "text-destructive"
: "text-emerald-600 dark:text-emerald-400",
)}>
{listCfOrderResult}
</p>
)}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40">
<th className="px-2 py-2 text-center font-medium w-8 text-muted-foreground">
#
</th>
<th className="px-3 py-2 text-left font-medium">
Nr. Cad
</th>
<th className="px-3 py-2 text-left font-medium">
Nr. CF
</th>
<th className="px-3 py-2 text-right font-medium hidden sm:table-cell">
Suprafata
</th>
<th className="px-3 py-2 text-left font-medium hidden md:table-cell">
Proprietari
</th>
<th className="px-3 py-2 text-center font-medium">
Extras CF
</th>
<th className="px-3 py-2 w-8"></th>
</tr>
</thead>
<tbody>
{searchList.map((p, idx) => {
const cfStatus = cfStatusMap[p.nrCad];
const cfExpiry = cfExpiryDates[p.nrCad];
return (
<tr
key={`list-${p.nrCad}-${p.immovablePk}`}
className="border-b hover:bg-muted/30 transition-colors"
>
<td className="px-2 py-2 text-center text-xs text-muted-foreground tabular-nums">
{idx + 1}
</td>
<td className="px-3 py-2 font-mono text-xs font-medium">
{p.nrCad}
</td>
<td className="px-3 py-2 text-xs">
{p.nrCF || "\u2014"}
</td>
<td className="px-3 py-2 text-right hidden sm:table-cell tabular-nums text-xs">
{p.suprafata != null
? formatArea(p.suprafata)
: "\u2014"}
</td>
<td className="px-3 py-2 hidden md:table-cell text-xs truncate max-w-[300px]">
{p.proprietari || "\u2014"}
</td>
<td className="px-3 py-2 text-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{cfStatus === "valid" ? (
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-400 dark:border-emerald-800 cursor-default">
Valid
</span>
) : cfStatus === "expired" ? (
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950/40 dark:text-orange-400 dark:border-orange-800 cursor-default">
Expirat
</span>
) : cfStatus === "processing" ? (
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-yellow-100 text-yellow-700 border-yellow-200 dark:bg-yellow-950/40 dark:text-yellow-400 dark:border-yellow-800 animate-pulse cursor-default">
Procesare
</span>
) : (
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-muted text-muted-foreground border-muted-foreground/20 cursor-default">
Lipsa
</span>
)}
</TooltipTrigger>
<TooltipContent>
{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."}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</td>
<td className="px-3 py-2">
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => removeFromList(p.nrCad)}
>
<XCircle className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</>
)}
</TabsContent>
{/* ═══════════════════════════════════════════════════════ */}
{/* Tab 2: Layer catalog */}
{/* ═══════════════════════════════════════════════════════ */}
<TabsContent value="layers" className="space-y-4">
{!sirutaValid || !session.connected ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Layers className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>
{!session.connected
? "Conectează-te la eTerra și selectează un UAT."
: "Selectează un UAT pentru a vedea catalogul de layere."}
</p>
</CardContent>
</Card>
) : (
<div className="space-y-3">
{/* Action bar */}
<div className="flex items-center justify-between gap-2 flex-wrap">
<p className="text-xs text-muted-foreground">
{layerCountSiruta === siruta &&
Object.keys(layerCounts).length > 0
? `Număr features pentru SIRUTA ${siruta}`
: "Apasă pentru a număra features-urile din fiecare layer."}
</p>
<div className="flex items-center gap-2">
{/* Export from local DB */}
{syncingSiruta === siruta &&
Object.values(syncLocalCounts).some((c) => c > 0) && (
<Button
size="sm"
variant="outline"
disabled={exportingLocal}
onClick={() => void handleExportLocal()}
className="border-violet-300 text-violet-700 dark:border-violet-700 dark:text-violet-300"
>
{exportingLocal ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
) : (
<HardDrive className="h-3.5 w-3.5 mr-1.5" />
)}
Export local
</Button>
)}
<Button
size="sm"
variant="outline"
disabled={countingLayers}
onClick={() => void fetchLayerCounts()}
>
{countingLayers ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
) : (
<Search className="h-3.5 w-3.5 mr-1.5" />
)}
{countingLayers ? "Se numără…" : "Numără"}
</Button>
</div>
</div>
{/* Sync progress message */}
{syncProgress && (
<div className="flex items-center gap-2 rounded-lg border px-3 py-2 text-xs">
{syncingLayer ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-blue-500 shrink-0" />
) : (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
)}
<span>{syncProgress}</span>
</div>
)}
{(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 (
<Card key={cat}>
<button
type="button"
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-muted/50 transition-colors rounded-t-xl"
onClick={() =>
setExpandedCategories((prev) => ({
...prev,
[cat]: !prev[cat],
}))
}
>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold">
{LAYER_CATEGORY_LABELS[cat]}
</span>
<Badge
variant="outline"
className="font-normal text-[11px]"
>
{layers.length}
</Badge>
{catTotal != null && catTotal > 0 && (
<Badge className="font-mono text-[10px] bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 border-0">
{catTotal.toLocaleString("ro-RO")} remote
</Badge>
)}
{catLocal != null && catLocal > 0 && (
<Badge className="font-mono text-[10px] bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300 border-0">
{catLocal.toLocaleString("ro-RO")} local
</Badge>
)}
</div>
{isExpanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
{isExpanded && (
<CardContent className="pt-0 pb-3 space-y-1.5">
{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 (
<div
key={layer.id}
className={cn(
"rounded-lg border px-3 py-2.5 transition-colors",
isDownloading || isSyncing
? "border-blue-300 bg-blue-50/50 dark:border-blue-700 dark:bg-blue-950/20"
: "hover:bg-muted/50",
)}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium truncate">
{layer.label}
</p>
{lc != null && !lc.error && (
<Badge
variant="secondary"
className={cn(
"font-mono text-[10px] shrink-0",
lc.count === 0
? "opacity-40"
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
)}
>
{lc.count.toLocaleString("ro-RO")}
</Badge>
)}
{lc?.error && (
<span className="text-[10px] text-rose-500">
eroare
</span>
)}
{localCount > 0 && (
<Badge className="font-mono text-[10px] bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300 border-0 shrink-0">
<Database className="h-2.5 w-2.5 mr-0.5" />
{localCount.toLocaleString("ro-RO")}
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<p className="text-[11px] text-muted-foreground font-mono">
{layer.id}
</p>
{lastRun && (
<span className="text-[10px] text-muted-foreground/70">
sync{" "}
{new Date(
lastRun.completedAt ??
lastRun.startedAt,
).toLocaleDateString("ro-RO", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{/* Sync to DB */}
<Button
size="sm"
variant="outline"
disabled={
!!syncingLayer ||
!!downloadingLayer ||
exporting
}
onClick={() =>
void handleSyncLayer(layer.id)
}
className="border-violet-200 dark:border-violet-800"
title="Sincronizează în baza de date"
>
{isSyncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
<span className="ml-1.5 hidden sm:inline">
Sync
</span>
</Button>
{/* GPKG (sync-first: syncs to DB if needed, then exports from DB) */}
<Button
size="sm"
variant="outline"
disabled={!!downloadingLayer || exporting}
onClick={() =>
void handleExportLayer(layer.id)
}
title={
localCount > 0
? "Descarcă GPKG (din cache dacă e proaspăt)"
: "Sincronizează + descarcă GPKG"
}
>
{isDownloading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
<span className="ml-1.5 hidden sm:inline">
GPKG
</span>
</Button>
</div>
</div>
</div>
);
})}
</CardContent>
)}
</Card>
);
},
)}
{/* Drumul de azi — today's layer count history */}
{layerHistory.length > 0 && (
<Card>
<div className="px-4 py-3 border-b">
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-amber-500" />
<span className="text-sm font-semibold">Drumul de azi</span>
<Badge
variant="outline"
className="font-normal text-[11px]"
>
{layerHistory.length}
</Badge>
</div>
</div>
<CardContent className="pt-3 pb-3">
<div className="space-y-2 max-h-64 overflow-y-auto">
{/* Group by siruta */}
{(() => {
const grouped = new Map<string, typeof layerHistory>();
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]) => (
<div key={sir} className="space-y-1">
<p className="text-[11px] font-semibold text-muted-foreground">
SIRUTA {sir}{" "}
<span className="font-normal opacity-70">
{" "}
{new Date(entries[0]!.time).toLocaleTimeString(
"ro-RO",
{ hour: "2-digit", minute: "2-digit" },
)}
</span>
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-1">
{entries
.sort((a, b) => b.count - a.count)
.map((e) => (
<div
key={e.layerId}
className="flex items-center justify-between gap-1 rounded border px-2 py-1 text-[11px]"
>
<span className="truncate">{e.label}</span>
<span className="font-mono font-semibold text-emerald-600 dark:text-emerald-400 shrink-0">
{e.count.toLocaleString("ro-RO")}
</span>
</div>
))}
</div>
</div>
),
);
})()}
</div>
</CardContent>
</Card>
)}
{/* PostGIS / QGIS setup */}
<Card>
<div className="px-4 py-3 border-b">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-violet-500" />
<span className="text-sm font-semibold">
QGIS / PostGIS
</span>
</div>
<Button
size="sm"
variant="outline"
disabled={postgisRunning}
onClick={() => void handleSetupPostgis()}
className="border-violet-300 text-violet-700 dark:border-violet-700 dark:text-violet-300"
>
{postgisRunning ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
) : (
<Database className="h-3.5 w-3.5 mr-1.5" />
)}
{postgisRunning ? "Se configurează…" : "Setup PostGIS"}
</Button>
</div>
</div>
<CardContent className="py-3 space-y-2">
{postgisResult ? (
postgisResult.success ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-medium text-emerald-700 dark:text-emerald-400">
{postgisResult.message}
</span>
</div>
{postgisResult.details && (
<div className="rounded bg-muted/50 p-3 text-xs space-y-1 font-mono">
<p>
Backfill:{" "}
{String(
(
postgisResult.details as {
backfilledFeatures?: number;
}
).backfilledFeatures ?? 0,
)}{" "}
features convertite
</p>
<p>
Total cu geometrie nativă:{" "}
{String(
(
postgisResult.details as {
totalFeaturesWithGeom?: number;
}
).totalFeaturesWithGeom ?? 0,
)}
</p>
<p className="text-muted-foreground mt-1">
QGIS PostgreSQL 10.10.10.166:5432 /
architools_db
</p>
<p className="text-muted-foreground">
View-uri: gis_terenuri, gis_cladiri,
gis_documentatii, gis_administrativ
</p>
<p className="text-muted-foreground">SRID: 3844</p>
</div>
)}
</div>
) : (
<div className="flex items-start gap-2">
<XCircle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-medium text-red-700 dark:text-red-400">
PostGIS nu este instalat
</p>
<p className="text-xs text-muted-foreground mt-1">
Instalează PostGIS pe serverul PostgreSQL:
</p>
<code className="text-xs block mt-1 bg-muted rounded px-2 py-1">
apt install postgresql-16-postgis-3
</code>
</div>
</div>
)
) : (
<p className="text-xs text-muted-foreground">
Creează coloana nativă PostGIS, trigger auto-conversie,
index spațial GiST și view-uri QGIS-compatibile. Necesită
PostGIS instalat pe server.
</p>
)}
</CardContent>
</Card>
</div>
)}
{/* Progress bar for layer download */}
{downloadingLayer && exportProgress && (
<Card className="border-blue-200 dark:border-blue-800">
<CardContent className="pt-4 space-y-2">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 text-blue-600 animate-spin shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{exportProgress.phase}
{exportProgress.phaseCurrent != null &&
exportProgress.phaseTotal
? `${exportProgress.phaseCurrent} / ${exportProgress.phaseTotal}`
: ""}
</p>
</div>
<span className="text-sm font-mono font-semibold tabular-nums">
{progressPct}%
</span>
</div>
<div className="h-2 w-full rounded-full bg-blue-200/50 dark:bg-blue-800/30">
<div
className="h-2 rounded-full bg-blue-600 transition-all duration-300"
style={{ width: `${Math.max(2, progressPct)}%` }}
/>
</div>
</CardContent>
</Card>
)}
</TabsContent>
{/* ═══════════════════════════════════════════════════════ */}
{/* Tab 3: Export */}
{/* ═══════════════════════════════════════════════════════ */}
<TabsContent value="export" className="space-y-4">
{/* DB freshness status */}
{sirutaValid && dbLayersSummary.length > 0 && (
<Card className="border-dashed">
<CardContent className="py-3 px-4">
<div className="flex items-center gap-3 flex-wrap">
<Database className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{dbTotalFeatures.toLocaleString("ro-RO")}
</span>{" "}
entități în DB din{" "}
<span className="font-medium text-foreground">
{dbLayersSummary.length}
</span>{" "}
layere
</span>
{(() => {
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 ? (
<Badge
variant="outline"
className="text-emerald-600 border-emerald-200 dark:text-emerald-400 dark:border-emerald-800"
>
<CheckCircle2 className="h-3 w-3 mr-1" />
Proaspete
</Badge>
) : (
<Badge
variant="outline"
className="text-amber-600 border-amber-200 dark:text-amber-400 dark:border-amber-800"
>
<Clock className="h-3 w-3 mr-1" />
{staleCount} vechi
</Badge>
)}
{oldestSync && (
<span className="text-xs text-muted-foreground">
Ultima sincronizare: {relativeTime(oldestSync)}
</span>
)}
</>
);
})()}
</div>
</CardContent>
</Card>
)}
{/* Hero buttons */}
{sirutaValid && session.connected ? (
<div className="grid gap-3 sm:grid-cols-2">
<Button
size="lg"
className="h-auto py-4 text-base bg-zinc-900 hover:bg-zinc-800 text-white dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
disabled={exporting}
onClick={() => void handleExportBundle("base")}
>
{exporting && exportProgress?.phase !== "Detalii parcele" ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : (
<FileDown className="mr-2 h-5 w-5" />
)}
<div className="text-left">
<div className="font-semibold">
Descarcă Terenuri și Clădiri
</div>
<div className="text-xs opacity-70 font-normal">
Sync + GPKG (din cache dacă e proaspăt)
</div>
</div>
</Button>
<Button
size="lg"
className="h-auto py-4 text-base bg-teal-700 hover:bg-teal-600 text-white dark:bg-teal-600 dark:hover:bg-teal-500"
disabled={exporting}
onClick={() => void handleExportBundle("magic")}
>
{exporting && exportProgress?.phase === "Detalii parcele" ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : (
<Sparkles className="mr-2 h-5 w-5" />
)}
<div className="text-left">
<div className="font-semibold">Magic</div>
<div className="text-xs opacity-70 font-normal">
Sync + îmbogățire (CF, proprietari, adresă) + GPKG + CSV
</div>
</div>
</Button>
</div>
) : (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
{!session.connected ? (
<>
<Wifi className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>Conectează-te la eTerra pentru a activa exportul.</p>
</>
) : (
<>
<MapPin className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>Selectează un UAT pentru a activa exportul.</p>
</>
)}
</CardContent>
</Card>
)}
{/* 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 (
<Card className="border-amber-200/50 dark:border-amber-800/50">
<CardContent className="py-3 px-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin text-amber-500" />
Se scanează lista de imobile din eTerra (max 2 min)
</div>
<p className="text-[11px] text-muted-foreground mt-1 ml-6">
Poți folosi butoanele de mai jos fără aștepți scanarea.
</p>
</CardContent>
</Card>
);
// Scan timed out
if (scanDone && noGeomScan.scannedAt === "timeout")
return (
<Card className="border-amber-200/50 dark:border-amber-800/50">
<CardContent className="py-3 px-4">
<div className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-400">
<Clock className="h-4 w-4 shrink-0" />
Scanarea a depășit 2 minute serverul eTerra e lent.
</div>
<p className="text-[11px] text-muted-foreground mt-1 ml-6">
Poți lansa sincronizarea fundal fără rezultate de scanare.
Include no-geom nu va fi disponibil.
</p>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs mt-1 ml-6"
onClick={() => void handleNoGeomScan()}
>
<RefreshCw className="h-3 w-3 mr-1" />
Reîncearcă scanarea
</Button>
</CardContent>
</Card>
);
// 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 && (
<div className="space-y-0.5 mt-1">
<div className="flex items-center gap-1.5 flex-wrap text-[11px] text-muted-foreground">
<Database className="h-3 w-3 shrink-0" />
<span>
Baza de date locală:{" "}
<span className="font-medium text-foreground">
{noGeomScan.localDbWithGeom.toLocaleString("ro-RO")}
</span>{" "}
cu geometrie
{noGeomScan.localDbNoGeom > 0 && (
<>
{" + "}
<span className="font-medium text-amber-600 dark:text-amber-400">
{noGeomScan.localDbNoGeom.toLocaleString("ro-RO")}
</span>{" "}
fără geometrie
</>
)}
{noGeomScan.localDbEnriched > 0 && (
<>
{" · "}
<span className="font-medium text-teal-600 dark:text-teal-400">
{noGeomScan.localDbEnriched.toLocaleString("ro-RO")}
</span>{" "}
îmbogățite
{staleEnrichment && (
<span className="text-orange-600 dark:text-orange-400">
{" "}
({staleCount.toLocaleString("ro-RO")} incomplete)
</span>
)}
</>
)}
{noGeomScan.localSyncFresh && (
<span className="text-emerald-600 dark:text-emerald-400 ml-1">
(proaspăt)
</span>
)}
</span>
</div>
{staleEnrichment && (
<div className="flex items-center gap-1.5 text-[11px] text-orange-600 dark:text-orange-400 ml-[18px]">
<AlertTriangle className="h-3 w-3 shrink-0" />
<span>
{staleCount.toLocaleString("ro-RO")} parcele au îmbogățire
veche (lipsă PROPRIETARI_VECHI). Vor fi re-îmbogățite la
următorul export Magic.
</span>
</div>
)}
</div>
);
// Helper: workflow preview (what Magic will do)
const workflowPreview = scanDone && (
<div className="mt-2 ml-7 space-y-0.5">
<p className="text-[11px] font-medium text-muted-foreground">
La apăsarea Magic, pașii vor fi:
</p>
<ol className="text-[11px] text-muted-foreground list-decimal ml-4 space-y-px">
<li>
{"Sync GIS — "}
<span
className={cn(
"font-medium",
noGeomScan.localSyncFresh &&
noGeomScan.localDbWithGeom > 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`
: "")}
</span>
</li>
{includeNoGeom && (
<li>
Import parcele fără geometrie {" "}
<span className="font-medium text-amber-600 dark:text-amber-400">
{(() => {
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";
})()}
</span>
</li>
)}
<li>
Îmbogățire CF, proprietari, adrese {" "}
<span className="font-medium text-teal-600 dark:text-teal-400">
{(() => {
// 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";
})()}
</span>
</li>
<li>Generare GPKG + CSV</li>
<li>Comprimare ZIP + descărcare</li>
</ol>
</div>
);
// No-geometry parcels found
if (hasNoGeomParcels)
return (
<Card
className={cn(
"transition-colors",
includeNoGeom
? "border-amber-400 bg-amber-50/50 dark:border-amber-700 dark:bg-amber-950/20"
: "border-amber-200 dark:border-amber-800/50",
)}
>
<CardContent className="py-3 px-4 space-y-2">
<div className="flex items-center gap-3">
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm">
Layer GIS:{" "}
<span className="font-semibold">
{noGeomScan.remoteGisCount.toLocaleString("ro-RO")}
</span>{" "}
terenuri
{noGeomScan.remoteCladiriCount > 0 && (
<>
{" + "}
<span className="font-semibold">
{noGeomScan.remoteCladiriCount.toLocaleString(
"ro-RO",
)}
</span>{" "}
clădiri
</>
)}
{" · "}
Lista imobile:{" "}
<span className="font-semibold">
{noGeomScan.totalImmovables.toLocaleString("ro-RO")}
</span>
{" (estimat "}
<span className="font-semibold text-amber-600 dark:text-amber-400">
~
{Math.max(
0,
noGeomScan.totalImmovables -
noGeomScan.remoteGisCount,
).toLocaleString("ro-RO")}
</span>
{" fără geometrie)"}
</p>
<p className="text-[11px] text-muted-foreground mt-0.5">
Cele fără geometrie există în baza de date eTerra dar
nu au contur desenat în layerul GIS.
</p>
{localDbLine}
</div>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs shrink-0"
disabled={noGeomScanning || exporting}
onClick={() => void handleNoGeomScan()}
title="Re-scanare"
>
<RefreshCw className="h-3 w-3" />
</Button>
</div>
<label className="flex items-center gap-2 cursor-pointer select-none ml-7">
<input
type="checkbox"
checked={includeNoGeom}
onChange={(e) => setIncludeNoGeom(e.target.checked)}
disabled={exporting}
className="h-4 w-4 rounded border-muted-foreground/30 accent-amber-600"
/>
<span className="text-sm font-medium">
Include și parcelele fără geometrie la export
</span>
</label>
{/* Quality breakdown of no-geom items */}
{scanDone && noGeomScan.noGeomCount > 0 && (
<div className="ml-7 p-2 rounded-md bg-muted/40 space-y-1">
<p className="text-[11px] font-medium text-muted-foreground">
Calitate date (din{" "}
{noGeomScan.noGeomCount.toLocaleString("ro-RO")} fără
geometrie):
</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5 text-[11px] text-muted-foreground">
<span>
Cu nr. cadastral eTerra:{" "}
<span className="font-medium text-foreground">
{noGeomScan.qualityBreakdown.withCadRef.toLocaleString(
"ro-RO",
)}
</span>
</span>
<span>
Cu nr. CF/LB:{" "}
<span className="font-medium text-foreground">
{noGeomScan.qualityBreakdown.withPaperLb.toLocaleString(
"ro-RO",
)}
</span>
</span>
<span>
Cu nr. cad. pe hârtie:{" "}
<span className="font-medium text-foreground">
{noGeomScan.qualityBreakdown.withPaperCad.toLocaleString(
"ro-RO",
)}
</span>
</span>
<span>
Cu suprafață:{" "}
<span className="font-medium text-foreground">
{noGeomScan.qualityBreakdown.withArea.toLocaleString(
"ro-RO",
)}
</span>
</span>
<span>
Active (status=1):{" "}
<span className="font-medium text-foreground">
{noGeomScan.qualityBreakdown.withActiveStatus.toLocaleString(
"ro-RO",
)}
</span>
</span>
<span>
Cu carte funciară:{" "}
<span className="font-medium text-foreground">
{noGeomScan.qualityBreakdown.withLandbook.toLocaleString(
"ro-RO",
)}
</span>
</span>
</div>
<div className="flex items-center gap-3 text-[11px] pt-0.5 border-t border-muted-foreground/10">
<span>
Utilizabile:{" "}
<span className="font-semibold text-emerald-600 dark:text-emerald-400">
{noGeomScan.qualityBreakdown.useful.toLocaleString(
"ro-RO",
)}
</span>
</span>
{noGeomScan.qualityBreakdown.empty > 0 && (
<span>
Filtrate (fără CF/inactive/fără date):{" "}
<span className="font-semibold text-rose-600 dark:text-rose-400">
{noGeomScan.qualityBreakdown.empty.toLocaleString(
"ro-RO",
)}
</span>
</span>
)}
</div>
</div>
)}
{includeNoGeom && (
<p className="text-[11px] text-muted-foreground ml-7">
{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.
</p>
)}
{workflowPreview}
</CardContent>
</Card>
);
// Scan done, all parcels have geometry (or totalImmovables=0 ⇒ workspace issue)
if (scanDone && !hasNoGeomParcels)
return (
<Card className="border-dashed">
<CardContent className="py-2.5 px-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{noGeomScan.totalImmovables > 0 ? (
<>
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
Toate cele{" "}
{noGeomScan.totalImmovables.toLocaleString("ro-RO")}{" "}
imobile din eTerra au geometrie nimic de importat
suplimentar.
{noGeomScan.localDbTotal > 0 && (
<span className="ml-1">
({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 && (
<span className="text-orange-600 dark:text-orange-400">
{` (${(noGeomScan.localDbEnriched - noGeomScan.localDbEnrichedComplete).toLocaleString("ro-RO")} incomplete)`}
</span>
)}
{noGeomScan.localSyncFresh && ", proaspăt"})
</span>
)}
</>
) : (
<>
<AlertTriangle className="h-3.5 w-3.5 text-muted-foreground" />
Nu s-au găsit imobile în lista eTerra pentru acest
UAT. Verifică sesiunea eTerra.
</>
)}
</div>
</CardContent>
</Card>
);
return null;
})()}
{/* ── Background sync + Download from DB ──────────────── */}
{sirutaValid && (
<Card className="border-dashed">
<CardContent className="py-3 px-4 space-y-3">
{/* Row 1: Section label */}
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
Procesare fundal &amp; descărcare din DB
</span>
<span className="text-[10px] text-muted-foreground">
pornește sincronizarea, închide pagina, descarcă mai târziu
</span>
</div>
{/* Include no-geom toggle (works independently of scan) */}
{session.connected && (
<label className="flex items-center gap-2 cursor-pointer select-none ml-6">
<input
type="checkbox"
checked={includeNoGeom}
onChange={(e) => setIncludeNoGeom(e.target.checked)}
disabled={
exporting ||
(!!bgJobId && bgProgress?.status === "running")
}
className="h-4 w-4 rounded border-muted-foreground/30 accent-amber-600"
/>
<span className="text-xs">
Include și parcelele fără geometrie
</span>
{noGeomScanning && (
<span className="text-[10px] text-muted-foreground">
(scanare în curs)
</span>
)}
</label>
)}
{/* Row 2: Background sync buttons */}
{session.connected && (
<div className="grid gap-2 sm:grid-cols-2">
<Button
variant="outline"
size="sm"
className="h-auto py-2.5 justify-start"
disabled={
exporting ||
(!!bgJobId && bgProgress?.status === "running")
}
onClick={() => void handleSyncBackground("base")}
>
{bgJobId &&
bgProgress?.status === "running" &&
!bgPhaseTrail.some((p) => p.includes("Îmbogățire")) ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ArrowDownToLine className="mr-2 h-4 w-4" />
)}
<div className="text-left">
<div className="text-xs font-semibold">
Sync fundal Bază
</div>
<div className="text-[10px] opacity-60 font-normal">
Terenuri + clădiri salvează în DB
</div>
</div>
</Button>
<Button
variant="outline"
size="sm"
className="h-auto py-2.5 justify-start border-teal-200 dark:border-teal-800 hover:bg-teal-50 dark:hover:bg-teal-950/30"
disabled={
exporting ||
(!!bgJobId && bgProgress?.status === "running")
}
onClick={() => void handleSyncBackground("magic")}
>
{bgJobId &&
bgProgress?.status === "running" &&
bgPhaseTrail.some((p) => p.includes("Îmbogățire")) ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin text-teal-600" />
) : (
<Sparkles className="mr-2 h-4 w-4 text-teal-600" />
)}
<div className="text-left">
<div className="text-xs font-semibold">
Sync fundal Magic
</div>
<div className="text-[10px] opacity-60 font-normal">
Sync + îmbogățire salvează în DB
</div>
</div>
</Button>
</div>
)}
{/* Row 3: Download from DB buttons */}
{dbTotalFeatures > 0 && (
<div className="grid gap-2 sm:grid-cols-2">
<Button
variant="outline"
size="sm"
className="h-auto py-2.5 justify-start"
disabled={downloadingFromDb}
onClick={() => void handleDownloadFromDb("base")}
>
{downloadingFromDb ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Database className="mr-2 h-4 w-4" />
)}
<div className="text-left">
<div className="text-xs font-semibold">
Descarcă din DB Bază
</div>
<div className="text-[10px] opacity-60 font-normal">
GPKG terenuri + clădiri (instant, fără eTerra)
</div>
</div>
</Button>
<Button
variant="outline"
size="sm"
className="h-auto py-2.5 justify-start border-teal-200 dark:border-teal-800 hover:bg-teal-50 dark:hover:bg-teal-950/30"
disabled={downloadingFromDb}
onClick={() => void handleDownloadFromDb("magic")}
>
{downloadingFromDb ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin text-teal-600" />
) : (
<Sparkles className="mr-2 h-4 w-4 text-teal-600" />
)}
<div className="text-left">
<div className="text-xs font-semibold">
Descarcă din DB Magic
</div>
<div className="text-[10px] opacity-60 font-normal">
GPKG + CSV + raport calitate (instant)
</div>
</div>
</Button>
</div>
)}
{!session.connected && dbTotalFeatures === 0 && (
<p className="text-xs text-muted-foreground ml-6">
Conectează-te la eTerra pentru a porni sincronizarea fundal,
sau sincronizează mai întâi date în baza locală.
</p>
)}
</CardContent>
</Card>
)}
{/* Background sync progress */}
{bgJobId && bgProgress && bgProgress.status !== "unknown" && (
<Card
className={cn(
"border-2 transition-colors",
bgProgress.status === "running" &&
"border-indigo-300 dark:border-indigo-700",
bgProgress.status === "error" &&
"border-rose-300 dark:border-rose-700",
bgProgress.status === "done" &&
"border-emerald-400 dark:border-emerald-600",
)}
>
<CardContent className="pt-4 space-y-3">
{/* Label */}
<div className="flex items-center gap-2 text-xs">
<HardDrive className="h-3.5 w-3.5 text-indigo-500" />
<span className="font-semibold text-indigo-700 dark:text-indigo-400">
Sincronizare fundal
</span>
{bgProgress.status === "running" && (
<span className="text-muted-foreground">
(poți închide pagina)
</span>
)}
</div>
{/* Phase trail */}
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
{bgPhaseTrail.map((p, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <span className="opacity-40"></span>}
<span
className={cn(
i === bgPhaseTrail.length - 1
? "font-semibold text-foreground"
: "opacity-60",
)}
>
{p}
</span>
</span>
))}
</div>
{/* Progress info */}
<div className="flex items-center gap-3">
{bgProgress.status === "running" && (
<Loader2 className="h-5 w-5 text-indigo-600 animate-spin shrink-0" />
)}
{bgProgress.status === "done" && (
<CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0" />
)}
{bgProgress.status === "error" && (
<XCircle className="h-5 w-5 text-rose-500 shrink-0" />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{bgProgress.phase}</p>
{bgProgress.note && (
<p className="text-xs text-muted-foreground">
{bgProgress.note}
</p>
)}
{bgProgress.message && (
<p
className={cn(
"text-xs mt-0.5",
bgProgress.status === "error"
? "text-rose-500"
: "text-muted-foreground",
)}
>
{bgProgress.message}
</p>
)}
</div>
<span className="text-sm font-mono font-semibold tabular-nums shrink-0">
{bgProgress.total && bgProgress.total > 0
? Math.round(
(bgProgress.downloaded / bgProgress.total) * 100,
)
: 0}
%
</span>
</div>
{/* Bar */}
<div className="h-2.5 w-full rounded-full bg-muted">
<div
className={cn(
"h-2.5 rounded-full transition-all duration-300",
bgProgress.status === "running" && "bg-indigo-500",
bgProgress.status === "done" && "bg-emerald-500",
bgProgress.status === "error" && "bg-rose-500",
)}
style={{
width: `${Math.max(2, bgProgress.total && bgProgress.total > 0 ? Math.round((bgProgress.downloaded / bgProgress.total) * 100) : 0)}%`,
}}
/>
</div>
{/* Done — show download from DB button */}
{bgProgress.status === "done" && (
<div className="flex gap-2 pt-1">
<Button
variant="outline"
size="sm"
className="text-xs border-teal-200 dark:border-teal-800"
disabled={downloadingFromDb}
onClick={() => void handleDownloadFromDb("magic")}
>
{downloadingFromDb ? (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
) : (
<Database className="mr-1.5 h-3 w-3" />
)}
Descarcă din DB (Magic)
</Button>
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() => {
setBgJobId(null);
setBgProgress(null);
setBgPhaseTrail([]);
}}
>
Închide
</Button>
</div>
)}
</CardContent>
</Card>
)}
{/* Progress bar */}
{exportProgress &&
exportProgress.status !== "unknown" &&
exportJobId && (
<Card
className={cn(
"border-2 transition-colors",
exportProgress.status === "running" &&
"border-emerald-300 dark:border-emerald-700",
exportProgress.status === "error" &&
"border-rose-300 dark:border-rose-700",
exportProgress.status === "done" &&
"border-emerald-400 dark:border-emerald-600",
)}
>
<CardContent className="pt-4 space-y-3">
{/* Phase trail */}
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
{phaseTrail.map((p, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <span className="opacity-40"></span>}
<span
className={cn(
i === phaseTrail.length - 1
? "font-semibold text-foreground"
: "opacity-60",
)}
>
{p}
</span>
</span>
))}
</div>
{/* Progress info */}
<div className="flex items-center gap-3">
{exportProgress.status === "running" && (
<Loader2 className="h-5 w-5 text-emerald-600 animate-spin shrink-0" />
)}
{exportProgress.status === "done" && (
<CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0" />
)}
{exportProgress.status === "error" && (
<XCircle className="h-5 w-5 text-rose-500 shrink-0" />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{exportProgress.phase}
{exportProgress.phaseCurrent != null &&
exportProgress.phaseTotal
? `${exportProgress.phaseCurrent} / ${exportProgress.phaseTotal}`
: ""}
</p>
{exportProgress.note && (
<p className="text-xs text-muted-foreground">
{exportProgress.note}
</p>
)}
{exportProgress.message && (
<p
className={cn(
"text-xs mt-0.5",
exportProgress.status === "error"
? "text-rose-500"
: "text-muted-foreground",
)}
>
{exportProgress.message}
</p>
)}
</div>
<span className="text-sm font-mono font-semibold tabular-nums shrink-0">
{progressPct}%
</span>
</div>
{/* Bar */}
<div className="h-2.5 w-full rounded-full bg-muted">
<div
className={cn(
"h-2.5 rounded-full transition-all duration-300",
exportProgress.status === "running" && "bg-emerald-500",
exportProgress.status === "done" && "bg-emerald-500",
exportProgress.status === "error" && "bg-rose-500",
)}
style={{ width: `${Math.max(2, progressPct)}%` }}
/>
</div>
</CardContent>
</Card>
)}
</TabsContent>
{/* ═══════════════════════════════════════════════════════ */}
{/* Tab 4: Baza de Date */}
{/* ═══════════════════════════════════════════════════════ */}
<TabsContent value="database" className="space-y-3">
{dbSummaryLoading && !dbSummary ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Loader2 className="h-8 w-8 mx-auto mb-3 animate-spin opacity-40" />
<p>Se încarcă datele din baza de date</p>
</CardContent>
</Card>
) : !dbSummary || dbSummary.totalFeatures === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Database className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="font-medium">Nicio dată în baza de date</p>
<p className="text-xs mt-1">
Folosește tab-ul Export pentru a sincroniza date din eTerra.
</p>
</CardContent>
</Card>
) : (
<>
{/* Header row */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{dbSummary.totalFeatures.toLocaleString("ro-RO")} entități
</span>
<span className="text-xs text-muted-foreground">
din {dbSummary.totalUats} UAT-uri
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={dbSummaryLoading}
onClick={() => void fetchDbSummary()}
>
{dbSummaryLoading ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<RefreshCw className="h-3 w-3 mr-1" />
)}
Reîncarcă
</Button>
</div>
{/* UAT cards */}
{dbSummary.uats.map((uat) => {
const catCounts: Record<string, number> = {};
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 (
<div key={uat.siruta} className="space-y-3">
<Card
className={cn(
"transition-colors",
isCurrentUat && "ring-1 ring-emerald-400/50",
)}
>
<CardContent className="py-3 px-4 space-y-2">
{/* UAT header row */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold">
{uat.uatName}
</span>
{uat.county && (
<span className="text-[10px] text-muted-foreground">
({uat.county})
</span>
)}
<span className="text-[10px] font-mono text-muted-foreground">
#{uat.siruta}
</span>
{isCurrentUat && (
<Badge
variant="outline"
className="text-[9px] h-4 text-emerald-600 border-emerald-300 dark:text-emerald-400 dark:border-emerald-700"
>
selectat
</Badge>
)}
<span className="ml-auto flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="h-5 px-2 text-[10px] gap-1 text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 hover:bg-indigo-50 dark:hover:bg-indigo-950"
onClick={() =>
setDashboardSiruta(
dashboardSiruta === uat.siruta
? null
: uat.siruta,
)
}
>
<BarChart3 className="h-3 w-3" />
Dashboard
</Button>
<span className="text-xs text-muted-foreground">
{oldestSync ? relativeTime(oldestSync) : "—"}
</span>
</span>
</div>
{/* Category counts in a single compact row */}
<div className="flex items-center gap-2 flex-wrap text-xs">
{(
Object.entries(LAYER_CATEGORY_LABELS) as [
LayerCategory,
string,
][]
).map(([cat, label]) => {
const count = catCounts[cat] ?? 0;
if (count === 0) return null;
return (
<span
key={cat}
className="inline-flex items-center gap-1"
>
<span className="text-muted-foreground">
{label}:
</span>
<span className="font-medium tabular-nums">
{count.toLocaleString("ro-RO")}
</span>
</span>
);
})}
{enrichedTotal > 0 && (
<span className="inline-flex items-center gap-1">
<Sparkles className="h-3 w-3 text-teal-600 dark:text-teal-400" />
<span className="text-muted-foreground">
Magic:
</span>
<span className="font-medium tabular-nums text-teal-700 dark:text-teal-400">
{enrichedTotal.toLocaleString("ro-RO")}
</span>
</span>
)}
{noGeomTotal > 0 && (
<span className="inline-flex items-center gap-1">
<span className="text-muted-foreground">
Fără geom:
</span>
<span className="font-medium tabular-nums text-amber-600 dark:text-amber-400">
{noGeomTotal.toLocaleString("ro-RO")}
</span>
</span>
)}
</div>
{/* Layer detail pills */}
<div className="flex gap-1.5 flex-wrap">
{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 (
<span
key={layer.layerId}
className={cn(
"inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[10px]",
isEnriched
? "border-teal-200 bg-teal-50/50 dark:border-teal-800 dark:bg-teal-950/30"
: "border-muted bg-muted/30",
)}
title={`${label}: ${layer.count} entități${isEnriched ? `, ${layer.enrichedCount} îmbogățite` : ""}${layer.lastSynced ? `, sync: ${new Date(layer.lastSynced).toLocaleDateString("ro-RO")}` : ""}`}
>
<span className="truncate max-w-[120px]">
{label}
</span>
<span className="font-medium tabular-nums">
{layer.count.toLocaleString("ro-RO")}
</span>
{isEnriched && (
<Sparkles className="h-2.5 w-2.5 text-teal-600 dark:text-teal-400" />
)}
</span>
);
})}
</div>
</CardContent>
</Card>
{/* Dashboard panel (expanded below card) */}
{dashboardSiruta === uat.siruta && (
<UatDashboard
siruta={uat.siruta}
uatName={uat.uatName}
onClose={() => setDashboardSiruta(null)}
/>
)}
</div>
);
})}
</>
)}
</TabsContent>
{/* ═══════════════════════════════════════════════════════ */}
{/* Tab 5: Extrase CF */}
{/* ═══════════════════════════════════════════════════════ */}
<TabsContent value="extracts" className="space-y-4">
<EpayTab />
</TabsContent>
</Tabs>
);
}