Files
ArchiTools/src/modules/parcel-sync/components/parcel-sync-module.tsx
T

2571 lines
105 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import {
Search,
Download,
CheckCircle2,
XCircle,
Loader2,
MapPin,
Layers,
Sparkles,
ChevronDown,
ChevronUp,
FileDown,
LogOut,
Wifi,
WifiOff,
ClipboardCopy,
Trash2,
Plus,
RefreshCw,
Database,
HardDrive,
Clock,
ArrowDownToLine,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Badge } from "@/shared/components/ui/badge";
import { Card, CardContent } from "@/shared/components/ui/card";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/shared/components/ui/tabs";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu";
import { cn } from "@/shared/lib/utils";
import {
LAYER_CATALOG,
LAYER_CATEGORY_LABELS,
findLayerById,
type LayerCategory,
type LayerCatalogItem,
} from "../services/eterra-layers";
import type { ParcelDetail } from "@/app/api/eterra/search/route";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type UatEntry = {
siruta: string;
name: string;
county?: string;
workspacePk?: number;
};
type SessionStatus = {
connected: boolean;
username?: string;
connectedAt?: string;
activeJobCount: number;
activeJobPhase?: string;
};
type ExportProgress = {
jobId: string;
downloaded: number;
total?: number;
status: "running" | "done" | "error" | "unknown";
phase?: string;
message?: string;
note?: string;
phaseCurrent?: number;
phaseTotal?: number;
};
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const normalizeText = (text: string) =>
text
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim();
function formatDate(iso?: string | null) {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("ro-RO", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function formatArea(val?: number | null) {
if (val == null) return "—";
return val.toLocaleString("ro-RO", { maximumFractionDigits: 2 }) + " mp";
}
/* ------------------------------------------------------------------ */
/* Connection Status Pill */
/* ------------------------------------------------------------------ */
function ConnectionPill({
session,
connecting,
connectionError,
onDisconnect,
}: {
session: SessionStatus;
connecting: boolean;
connectionError: string;
onDisconnect: () => void;
}) {
const elapsed = session.connectedAt
? Math.floor(
(Date.now() - new Date(session.connectedAt).getTime()) / 60_000,
)
: 0;
const elapsedLabel =
elapsed < 1
? "acum"
: elapsed < 60
? `${elapsed} min`
: `${Math.floor(elapsed / 60)}h ${elapsed % 60}m`;
return (
<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"
: 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>
) : 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"
: 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>
{/* Info when not connected */}
{!session.connected && !connectionError && (
<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 */}
{!session.connected && connectionError && (
<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;
lastSynced: string | null;
}[];
totalFeatures: number;
totalEnriched: 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 [searchResults, setSearchResults] = useState<ParcelDetail[]>([]);
const [searchList, setSearchList] = useState<ParcelDetail[]>([]);
const [featuresSearch, setFeaturesSearch] = useState("");
const [loadingFeatures, setLoadingFeatures] = useState(false);
const [searchError, setSearchError] = useState("");
/* ════════════════════════════════════════════════════════════ */
/* Load UAT data + check server session on mount */
/* ════════════════════════════════════════════════════════════ */
const fetchSession = useCallback(async () => {
try {
const res = await fetch("/api/eterra/session");
const data = (await res.json()) as SessionStatus;
setSession(data);
if (data.connected) setConnectionError("");
return data;
} catch {
return null;
}
}, []);
useEffect(() => {
// Load UATs from local DB (fast — no eTerra needed)
fetch("/api/eterra/uats")
.then((res) => res.json())
.then((data: { uats?: UatEntry[]; total?: number }) => {
if (data.uats && data.uats.length > 0) {
setUatData(data.uats);
} else {
// DB empty — seed from uat.json via POST, then load from uat.json
fetch("/api/eterra/uats", { method: "POST" }).catch(() => {});
fetch("/uat.json")
.then((res) => res.json())
.then((fallback: UatEntry[]) => setUatData(fallback))
.catch(() => {});
}
})
.catch(() => {
// API failed — fall back to static uat.json
fetch("/uat.json")
.then((res) => res.json())
.then((fallback: UatEntry[]) => setUatData(fallback))
.catch(() => {});
});
// Check existing server session on mount
void fetchSession();
// Poll session every 30s to stay in sync with other clients
sessionPollRef.current = setInterval(() => void fetchSession(), 30_000);
return () => {
if (sessionPollRef.current) clearInterval(sessionPollRef.current);
};
}, [fetchSession]);
/* ── Fetch global DB summary ─────────────────────────────────── */
const fetchDbSummary = useCallback(async () => {
setDbSummaryLoading(true);
try {
const res = await fetch("/api/eterra/db-summary");
const data = (await res.json()) as DbSummary;
if (data.uats) setDbSummary(data);
} catch {
// silent
}
setDbSummaryLoading(false);
}, []);
useEffect(() => {
void fetchDbSummary();
}, [fetchDbSummary]);
/* ════════════════════════════════════════════════════════════ */
/* (Sync effect removed — POST seeds from uat.json, no */
/* eTerra nomenclature needed. Workspace resolved lazily.) */
/* ════════════════════════════════════════════════════════════ */
/* ════════════════════════════════════════════════════════════ */
/* UAT autocomplete filter */
/* ════════════════════════════════════════════════════════════ */
useEffect(() => {
const raw = uatQuery.trim();
if (raw.length < 2) {
setUatResults([]);
return;
}
const isDigit = /^\d+$/.test(raw);
const query = normalizeText(raw);
const results = uatData
.filter((item) => {
if (isDigit) return item.siruta.startsWith(raw);
// Match UAT name or county name
if (normalizeText(item.name).includes(query)) return true;
if (item.county && normalizeText(item.county).includes(query))
return true;
return false;
})
.slice(0, 12);
setUatResults(results);
}, [uatQuery, uatData]);
/* ════════════════════════════════════════════════════════════ */
/* Auto-connect: trigger on first UAT keystroke */
/* ════════════════════════════════════════════════════════════ */
const triggerAutoConnect = useCallback(async () => {
if (session.connected || connecting || autoConnectAttempted.current) return;
autoConnectAttempted.current = true;
setConnecting(true);
setConnectionError("");
try {
const res = await fetch("/api/eterra/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "connect" }),
});
const data = (await res.json()) as {
success?: boolean;
error?: string;
};
if (data.success) {
await fetchSession();
} else {
setConnectionError(data.error ?? "Eroare conectare");
}
} catch {
setConnectionError("Eroare rețea");
}
setConnecting(false);
}, [session.connected, connecting, fetchSession]);
/* ════════════════════════════════════════════════════════════ */
/* Disconnect */
/* ════════════════════════════════════════════════════════════ */
const handleDisconnect = useCallback(async () => {
try {
const res = await fetch("/api/eterra/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "disconnect" }),
});
const data = (await res.json()) as {
success?: boolean;
error?: string;
};
if (data.success) {
setSession({ connected: false, activeJobCount: 0 });
autoConnectAttempted.current = false;
} else {
// Jobs are running — show warning
setConnectionError(data.error ?? "Nu se poate deconecta");
}
} catch {
setConnectionError("Eroare rețea");
}
}, []);
/* ════════════════════════════════════════════════════════════ */
/* Progress polling */
/* ════════════════════════════════════════════════════════════ */
const startPolling = useCallback((jid: string) => {
if (pollingRef.current) clearInterval(pollingRef.current);
pollingRef.current = setInterval(async () => {
try {
const res = await fetch(
`/api/eterra/progress?jobId=${encodeURIComponent(jid)}`,
);
const data = (await res.json()) as ExportProgress;
setExportProgress(data);
if (data.phase) {
setPhaseTrail((prev) => {
if (prev[prev.length - 1] === data.phase) return prev;
return [...prev, data.phase!];
});
}
if (data.status === "done" || data.status === "error") {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
}
} catch {
/* ignore polling errors */
}
}, 1000);
}, []);
useEffect(() => {
return () => {
if (pollingRef.current) clearInterval(pollingRef.current);
};
}, []);
/* ════════════════════════════════════════════════════════════ */
/* Export bundle (base / magic) */
/* ════════════════════════════════════════════════════════════ */
const handleExportBundle = useCallback(
async (mode: "base" | "magic") => {
if (!siruta || exporting) return;
const jobId = crypto.randomUUID();
setExportJobId(jobId);
setExportProgress(null);
setPhaseTrail([]);
setExporting(true);
startPolling(jobId);
try {
const res = await fetch("/api/eterra/export-bundle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
siruta,
jobId,
mode,
}),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(err.error ?? `HTTP ${res.status}`);
}
const blob = await res.blob();
const cd = res.headers.get("Content-Disposition") ?? "";
const match = /filename="?([^"]+)"?/.exec(cd);
const filename =
match?.[1] ??
(mode === "magic"
? `eterra_uat_${siruta}_magic.zip`
: `eterra_uat_${siruta}_terenuri_cladiri.zip`);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
// Mark progress as done after successful download
setExportProgress((prev) =>
prev
? {
...prev,
status: "done",
phase: "Finalizat",
downloaded: prev.total ?? 100,
total: prev.total ?? 100,
message: `Descărcare completă — ${filename}`,
note: undefined,
}
: null,
);
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare export";
setExportProgress((prev) =>
prev
? { ...prev, status: "error", message: msg }
: {
jobId,
downloaded: 0,
status: "error",
message: msg,
},
);
}
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
setExporting(false);
// Refresh sync status — data was synced to DB
refreshSyncRef.current?.();
},
[siruta, exporting, startPolling],
);
/* ════════════════════════════════════════════════════════════ */
/* Layer feature counts */
/* ════════════════════════════════════════════════════════════ */
// Load history from localStorage on mount
useEffect(() => {
try {
const raw = localStorage.getItem("parcel-sync:layer-history");
if (raw) {
const parsed = JSON.parse(raw) as typeof layerHistory;
// Only keep today's entries
const today = new Date().toISOString().slice(0, 10);
const todayEntries = parsed.filter(
(e) => e.time.slice(0, 10) === today,
);
setLayerHistory(todayEntries);
}
} catch {
// ignore
}
}, []);
const fetchLayerCounts = useCallback(async () => {
if (!siruta || countingLayers) return;
setCountingLayers(true);
try {
const res = await fetch("/api/eterra/layers/summary", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ siruta }),
});
const data = (await res.json()) as {
counts?: Record<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],
);
// 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]);
// No auto-search — user clicks button or presses Enter
const handleSearchKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
void handleSearch();
}
},
[handleSearch],
);
// Add result(s) to list for CSV export
const addToList = useCallback((item: ParcelDetail) => {
setSearchList((prev) => {
if (
prev.some(
(p) => p.nrCad === item.nrCad && p.immovablePk === item.immovablePk,
)
)
return prev;
return [...prev, item];
});
}, []);
const removeFromList = useCallback((nrCad: string) => {
setSearchList((prev) => prev.filter((p) => p.nrCad !== nrCad));
}, []);
// CSV export — all fields quoted to handle commas in values (e.g. nrTopo)
const csvEscape = useCallback((val: string | number | null | undefined) => {
const s = val != null ? String(val) : "";
return `"${s.replace(/"/g, '""')}"`;
}, []);
const downloadCSV = useCallback(() => {
const items = searchList.length > 0 ? searchList : searchResults;
if (items.length === 0) return;
const headers = [
"NR_CAD",
"NR_CF",
"NR_CF_VECHI",
"NR_TOPO",
"SUPRAFATA",
"INTRAVILAN",
"CATEGORIE_FOLOSINTA",
"ADRESA",
"PROPRIETARI_ACTUALI",
"PROPRIETARI_VECHI",
"SOLICITANT",
];
const rows = items.map((p) => [
csvEscape(p.nrCad),
csvEscape(p.nrCF),
csvEscape(p.nrCFVechi),
csvEscape(p.nrTopo),
csvEscape(p.suprafata),
csvEscape(p.intravilan),
csvEscape(p.categorieFolosinta),
csvEscape(p.adresa),
csvEscape(p.proprietariActuali ?? p.proprietari),
csvEscape(p.proprietariVechi),
csvEscape(p.solicitant),
]);
const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n");
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `parcele_${siruta}_${Date.now()}.csv`;
a.click();
URL.revokeObjectURL(url);
}, [searchList, searchResults, siruta, csvEscape]);
/* ════════════════════════════════════════════════════════════ */
/* Derived data */
/* ════════════════════════════════════════════════════════════ */
const layersByCategory = useMemo(() => {
const grouped: Record<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}), ${item.county}`
: `${item.name} (${item.siruta})`;
setUatQuery(label);
setSiruta(item.siruta);
setWorkspacePk(item.workspacePk ?? null);
setShowUatResults(false);
setSearchResults([]);
}}
>
<span>
<span className="font-medium">{item.name}</span>
<span className="text-muted-foreground ml-1.5">
({item.siruta})
</span>
{item.county && (
<span className="text-muted-foreground">
,{" "}
<span className="font-medium text-foreground/70">
{item.county}
</span>
</span>
)}
</span>
<span className="text-xs text-muted-foreground font-mono ml-2 shrink-0">
{item.siruta}
</span>
</button>
))}
</div>
)}
</div>
{/* Connection pill */}
<ConnectionPill
session={session}
connecting={connecting}
connectionError={connectionError}
onDisconnect={handleDisconnect}
/>
</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>
</TabsList>
</div>
{/* ═══════════════════════════════════════════════════════ */}
{/* Tab 1: Parcel search */}
{/* ═══════════════════════════════════════════════════════ */}
<TabsContent value="search" className="space-y-4">
{!sirutaValid || !session.connected ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Search 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 mai sus pentru a căuta parcele."}
</p>
</CardContent>
</Card>
) : (
<>
{/* Search input */}
<Card>
<CardContent className="pt-4">
<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}
/>
</div>
</div>
<Button
onClick={() => void handleSearch()}
disabled={loadingFeatures || !featuresSearch.trim()}
>
{loadingFeatures ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Search className="mr-2 h-4 w-4" />
)}
Caută
</Button>
</div>
{searchError && (
<p className="text-xs text-destructive mt-2">{searchError}</p>
)}
</CardContent>
</Card>
{/* 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="Copiază detalii"
onClick={() => {
const text = [
`Nr. Cad: ${p.nrCad}`,
`Nr. CF: ${p.nrCF || "—"}`,
p.nrCFVechi
? `CF vechi: ${p.nrCFVechi}`
: null,
p.nrTopo ? `Nr. Topo: ${p.nrTopo}` : null,
p.suprafata != null
? `Suprafață: ${p.suprafata.toLocaleString("ro-RO")} mp`
: null,
`Intravilan: ${p.intravilan || "—"}`,
p.categorieFolosinta
? `Categorie: ${p.categorieFolosinta}`
: null,
p.adresa ? `Adresă: ${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>
</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 */}
{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>
)}
{/* Saved list */}
{searchList.length > 0 && (
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">
Lista mea ({searchList.length} parcele)
</h3>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setSearchList([])}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
Golește
</Button>
<Button size="sm" onClick={downloadCSV}>
<FileDown className="mr-1 h-3.5 w-3.5" />
CSV din listă
</Button>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40">
<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">
Suprafață
</th>
<th className="px-3 py-2 text-left font-medium hidden md:table-cell">
Proprietari
</th>
<th className="px-3 py-2 w-8"></th>
</tr>
</thead>
<tbody>
{searchList.map((p) => (
<tr
key={`list-${p.nrCad}-${p.immovablePk}`}
className="border-b hover:bg-muted/30 transition-colors"
>
<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 || "—"}
</td>
<td className="px-3 py-2 text-right hidden sm:table-cell tabular-nums text-xs">
{p.suprafata != null
? formatArea(p.suprafata)
: "—"}
</td>
<td className="px-3 py-2 hidden md:table-cell text-xs truncate max-w-[300px]">
{p.proprietari || "—"}
</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>
)}
{/* 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 oldestSync: Date | null = null;
for (const layer of uat.layers) {
const cat =
findLayerById(layer.layerId)?.category ?? "administrativ";
catCounts[cat] = (catCounts[cat] ?? 0) + layer.count;
enrichedTotal += layer.enrichedCount;
if (layer.lastSynced) {
const d = new Date(layer.lastSynced);
if (!oldestSync || d < oldestSync) oldestSync = d;
}
}
const isCurrentUat = sirutaValid && uat.siruta === siruta;
return (
<Card
key={uat.siruta}
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 text-xs text-muted-foreground">
{oldestSync ? relativeTime(oldestSync) : "—"}
</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>
)}
</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>
);
})}
</>
)}
</TabsContent>
</Tabs>
);
}