Files
ArchiTools/src/modules/parcel-sync/components/tabs/export-tab.tsx
T
AI Assistant 8b6d6ba1d0 fix(parcel-sync): add intravilan to primary layers + tooltip on stale badge
- LIMITE_INTRAV_DYNAMIC added to primary layers checked for freshness
- Auto-refresh scheduler and weekend sync now also sync intravilan
- "X vechi" badge shows tooltip with exact layer names and dates
- "Proaspete" badge also shows tooltip with layer details

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:10:26 +02:00

1544 lines
62 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import {
Loader2,
FileDown,
CheckCircle2,
XCircle,
Wifi,
MapPin,
Sparkles,
RefreshCw,
Database,
HardDrive,
Clock,
ArrowDownToLine,
AlertTriangle,
Moon,
} from "lucide-react";
import Link from "next/link";
import { Button } from "@/shared/components/ui/button";
import { Badge } from "@/shared/components/ui/badge";
import { Card, CardContent } from "@/shared/components/ui/card";
import { cn } from "@/shared/lib/utils";
import {
LAYER_CATALOG,
} from "../../services/eterra-layers";
import type {
SessionStatus,
SyncRunInfo,
ExportProgress,
} from "../parcel-sync-types";
import { relativeTime } from "../parcel-sync-types";
/* ------------------------------------------------------------------ */
/* Props */
/* ------------------------------------------------------------------ */
export type ExportTabProps = {
siruta: string;
workspacePk: number | null;
sirutaValid: boolean;
session: SessionStatus;
syncLocalCounts: Record<string, number>;
syncRuns: SyncRunInfo[];
syncingSiruta: string;
exporting: boolean;
setExporting: (v: boolean) => void;
onSyncRefresh: () => void;
onDbRefresh: () => void;
};
/* ------------------------------------------------------------------ */
/* No-geom scan result type */
/* ------------------------------------------------------------------ */
type NoGeomScanResult = {
totalImmovables: number;
withGeometry: number;
remoteGisCount: number;
remoteCladiriCount: number;
noGeomCount: number;
matchedByRef: number;
matchedById: number;
qualityBreakdown: {
withCadRef: number;
withPaperCad: number;
withPaperLb: number;
withLandbook: number;
withArea: number;
withActiveStatus: number;
useful: number;
empty: number;
};
localDbTotal: number;
localDbWithGeom: number;
localDbNoGeom: number;
localDbEnriched: number;
localDbEnrichedComplete: number;
localSyncFresh: boolean;
scannedAt: string;
};
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function ExportTab({
siruta,
workspacePk,
sirutaValid,
session,
syncLocalCounts,
syncRuns,
syncingSiruta,
exporting,
setExporting,
onSyncRefresh,
onDbRefresh,
}: ExportTabProps) {
/* ── Export state ──────────────────────────────────────────── */
const [exportJobId, setExportJobId] = useState<string | null>(null);
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(
null,
);
const [phaseTrail, setPhaseTrail] = useState<string[]>([]);
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
/* ── No-geometry state ────────────────────────────────────── */
const [includeNoGeom, setIncludeNoGeom] = useState(false);
const [noGeomScanning, setNoGeomScanning] = useState(false);
const [noGeomScan, setNoGeomScan] = useState<NoGeomScanResult | null>(null);
const [noGeomScanSiruta, setNoGeomScanSiruta] = useState("");
/* ── 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);
/* ══════════════════════════════════════════════════════════ */
/* Derived data */
/* ══════════════════════════════════════════════════════════ */
const dbLayersSummary = useMemo(() => {
if (!sirutaValid || syncingSiruta !== siruta) return [];
return LAYER_CATALOG.filter((l) => (syncLocalCounts[l.id] ?? 0) > 0).map(
(l) => {
const count = syncLocalCounts[l.id] ?? 0;
const lastRun = syncRuns.find(
(r) => r.layerId === l.id && r.status === "done",
);
const lastSynced = lastRun?.completedAt
? new Date(lastRun.completedAt)
: null;
const ageMs = lastSynced ? Date.now() - lastSynced.getTime() : null;
const isFresh = ageMs !== null ? ageMs < 168 * 60 * 60 * 1000 : false;
return { ...l, count, lastSynced, isFresh };
},
);
}, [sirutaValid, syncingSiruta, siruta, syncLocalCounts, syncRuns]);
const dbTotalFeatures = dbLayersSummary.reduce((sum, l) => sum + l.count, 0);
// Primary layers synced by background jobs — these determine freshness
const PRIMARY_LAYERS = ["TERENURI_ACTIVE", "CLADIRI_ACTIVE", "LIMITE_INTRAV_DYNAMIC"];
const primaryLayers = dbLayersSummary.filter((l) =>
PRIMARY_LAYERS.includes(l.id),
);
const allFresh =
primaryLayers.length > 0 && primaryLayers.every((l) => l.isFresh);
const hasData = dbTotalFeatures > 0;
const canExportLocal = allFresh && hasData;
const oldestSyncDate = primaryLayers.reduce(
(oldest, l) => {
if (!l.lastSynced) return oldest;
if (!oldest || l.lastSynced < oldest) return l.lastSynced;
return oldest;
},
null as Date | null,
);
const progressPct =
exportProgress?.total && exportProgress.total > 0
? Math.round((exportProgress.downloaded / exportProgress.total) * 100)
: 0;
/* ══════════════════════════════════════════════════════════ */
/* Progress polling */
/* ══════════════════════════════════════════════════════════ */
const startPolling = useCallback((jid: string) => {
if (pollingRef.current) clearInterval(pollingRef.current);
pollingRef.current = setInterval(async () => {
try {
const res = await fetch(
`/api/eterra/progress?jobId=${encodeURIComponent(jid)}`,
);
const data = (await res.json()) as ExportProgress;
setExportProgress(data);
if (data.phase) {
setPhaseTrail((prev) => {
if (prev[prev.length - 1] === data.phase) return prev;
return [...prev, data.phase!];
});
}
if (data.status === "done" || data.status === "error") {
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
}
} catch {
/* ignore polling errors */
}
}, 1000);
}, []);
useEffect(() => {
return () => {
if (pollingRef.current) clearInterval(pollingRef.current);
};
}, []);
/* ══════════════════════════════════════════════════════════ */
/* Export bundle (base / magic) */
/* ══════════════════════════════════════════════════════════ */
const handleExportBundle = useCallback(
async (mode: "base" | "magic") => {
if (!siruta || exporting) return;
const jobId = crypto.randomUUID();
setExportJobId(jobId);
setExportProgress(null);
setPhaseTrail([]);
setExporting(true);
startPolling(jobId);
try {
const res = await fetch("/api/eterra/export-bundle", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
siruta,
jobId,
mode,
includeNoGeometry: includeNoGeom,
}),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(err.error ?? `HTTP ${res.status}`);
}
const blob = await res.blob();
const cd = res.headers.get("Content-Disposition") ?? "";
const match = /filename="?([^"]+)"?/.exec(cd);
const filename =
match?.[1] ??
(mode === "magic"
? `eterra_uat_${siruta}_magic.zip`
: `eterra_uat_${siruta}_terenuri_cladiri.zip`);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
// Mark progress as done after successful download
setExportProgress((prev) =>
prev
? {
...prev,
status: "done",
phase: "Finalizat",
downloaded: prev.total ?? 100,
total: prev.total ?? 100,
message: `Descă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
onSyncRefresh();
},
[siruta, exporting, startPolling, includeNoGeom, setExporting, onSyncRefresh],
);
/* ══════════════════════════════════════════════════════════ */
/* No-geometry scan */
/* ══════════════════════════════════════════════════════════ */
const handleNoGeomScan = useCallback(
async (targetSiruta?: string) => {
const s = targetSiruta ?? siruta;
if (!s) return;
setNoGeomScanning(true);
setNoGeomScan(null);
setNoGeomScanSiruta(s);
const emptyQuality = {
withCadRef: 0,
withPaperCad: 0,
withPaperLb: 0,
withLandbook: 0,
withArea: 0,
withActiveStatus: 0,
useful: 0,
empty: 0,
};
const emptyResult: NoGeomScanResult = {
totalImmovables: 0,
withGeometry: 0,
remoteGisCount: 0,
remoteCladiriCount: 0,
noGeomCount: 0,
matchedByRef: 0,
matchedById: 0,
qualityBreakdown: emptyQuality,
localDbTotal: 0,
localDbWithGeom: 0,
localDbNoGeom: 0,
localDbEnriched: 0,
localDbEnrichedComplete: 0,
localSyncFresh: false,
scannedAt: "",
};
try {
// 2min timeout — scan is informational, should not block the page
const scanAbort = new AbortController();
const scanTimer = setTimeout(() => scanAbort.abort(), 120_000);
const res = await fetch("/api/eterra/no-geom-scan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
siruta: s,
workspacePk: workspacePk ?? undefined,
}),
signal: scanAbort.signal,
});
clearTimeout(scanTimer);
const data = (await res.json()) as Record<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]);
/* ══════════════════════════════════════════════════════════ */
/* Background sync polling */
/* ══════════════════════════════════════════════════════════ */
const startBgPolling = useCallback(
(jid: string) => {
if (bgPollingRef.current) clearInterval(bgPollingRef.current);
bgPollingRef.current = setInterval(async () => {
try {
const res = await fetch(
`/api/eterra/progress?jobId=${encodeURIComponent(jid)}`,
);
const data = (await res.json()) as ExportProgress;
setBgProgress(data);
if (data.phase) {
setBgPhaseTrail((prev) => {
if (prev[prev.length - 1] === data.phase) return prev;
return [...prev, data.phase!];
});
}
if (data.status === "done" || data.status === "error") {
if (bgPollingRef.current) {
clearInterval(bgPollingRef.current);
bgPollingRef.current = null;
}
// Clean localStorage marker
try {
localStorage.removeItem("parcel-sync:bg-job");
} catch {
/* */
}
// Refresh sync status and DB summary
onSyncRefresh();
onDbRefresh();
}
} catch {
/* ignore polling errors */
}
}, 1500);
},
[onSyncRefresh, onDbRefresh],
);
// Cleanup bg polling on unmount
useEffect(() => {
return () => {
if (bgPollingRef.current) clearInterval(bgPollingRef.current);
};
}, []);
// Recover background job from localStorage on mount
useEffect(() => {
try {
const raw = localStorage.getItem("parcel-sync:bg-job");
if (!raw) return;
const saved = JSON.parse(raw) as {
jobId: string;
siruta: string;
startedAt: string;
};
// Ignore jobs older than 8 hours
const age = Date.now() - new Date(saved.startedAt).getTime();
if (age > 8 * 60 * 60 * 1000) {
localStorage.removeItem("parcel-sync:bg-job");
return;
}
// Check if job is still running
void (async () => {
try {
const res = await fetch(
`/api/eterra/progress?jobId=${encodeURIComponent(saved.jobId)}`,
);
const data = (await res.json()) as ExportProgress;
if (data.status === "running") {
setBgJobId(saved.jobId);
setBgProgress(data);
if (data.phase) setBgPhaseTrail([data.phase]);
startBgPolling(saved.jobId);
} else if (data.status === "done") {
setBgJobId(saved.jobId);
setBgProgress(data);
if (data.phase) setBgPhaseTrail(["Sincronizare completă"]);
localStorage.removeItem("parcel-sync:bg-job");
} else {
localStorage.removeItem("parcel-sync:bg-job");
}
} catch {
localStorage.removeItem("parcel-sync:bg-job");
}
})();
} catch {
/* */
}
}, [startBgPolling]);
/* ══════════════════════════════════════════════════════════ */
/* Background sync — fire-and-forget server-side processing */
/* ══════════════════════════════════════════════════════════ */
const handleSyncBackground = useCallback(
async (mode: "base" | "magic") => {
if (!siruta || exporting) return;
try {
const res = await fetch("/api/eterra/sync-background", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
siruta,
mode,
includeNoGeometry: includeNoGeom,
}),
});
const data = (await res.json()) as { jobId?: string; error?: string };
if (!res.ok || data.error) {
// Transient error — ignored in the extracted component
// (parent's syncProgress state is not available here)
console.warn("[sync-background]", data.error ?? `Eroare ${res.status}`);
return;
}
const jid = data.jobId!;
setBgJobId(jid);
setBgProgress({
jobId: jid,
downloaded: 0,
total: 100,
status: "running",
phase: "Pornire sincronizare fundal",
});
setBgPhaseTrail(["Pornire sincronizare fundal"]);
// Persist in localStorage so we can recover on page refresh
try {
localStorage.setItem(
"parcel-sync:bg-job",
JSON.stringify({
jobId: jid,
siruta,
startedAt: new Date().toISOString(),
}),
);
} catch {
/* */
}
startBgPolling(jid);
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare rețea";
console.warn("[sync-background]", msg);
}
},
[siruta, exporting, includeNoGeom, startBgPolling],
);
/* ══════════════════════════════════════════════════════════ */
/* Download from DB */
/* ══════════════════════════════════════════════════════════ */
const handleDownloadFromDb = useCallback(
async (mode: "base" | "magic") => {
if (!siruta || downloadingFromDb) return;
setDownloadingFromDb(true);
try {
const res = await fetch("/api/eterra/export-local", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ siruta, mode }),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(err.error ?? `HTTP ${res.status}`);
}
const blob = await res.blob();
const cd = res.headers.get("Content-Disposition") ?? "";
const match = /filename="?([^"]+)"?/.exec(cd);
const filename = match?.[1] ?? `eterra_uat_${siruta}_${mode}_local.zip`;
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (error) {
const msg =
error instanceof Error ? error.message : "Eroare descărcare";
console.warn("[download-from-db]", msg);
}
setDownloadingFromDb(false);
},
[siruta, downloadingFromDb],
);
/* ══════════════════════════════════════════════════════════ */
/* Render */
/* ══════════════════════════════════════════════════════════ */
return (
<div 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 staleLayers = primaryLayers.filter((l) => !l.isFresh);
const freshLayers = primaryLayers.filter((l) => l.isFresh);
const newestSync = primaryLayers.reduce(
(newest, l) => {
if (!l.lastSynced) return newest;
if (!newest || l.lastSynced > newest) return l.lastSynced;
return newest;
},
null as Date | null,
);
// Tooltip: list which layers are stale/fresh with dates
const staleTooltip = staleLayers.length > 0
? `Vechi: ${staleLayers.map((l) => `${l.label} (${l.lastSynced ? relativeTime(l.lastSynced) : "nesincronizat"})`).join(", ")}`
: "";
const freshTooltip = freshLayers.length > 0
? `Proaspete: ${freshLayers.map((l) => `${l.label} (${l.lastSynced ? relativeTime(l.lastSynced) : ""})`).join(", ")}`
: "";
const fullTooltip = [staleTooltip, freshTooltip].filter(Boolean).join("\n");
return (
<>
{staleLayers.length === 0 && primaryLayers.length > 0 ? (
<Badge
variant="outline"
className="text-emerald-600 border-emerald-200 dark:text-emerald-400 dark:border-emerald-800 cursor-default"
title={fullTooltip}
>
<CheckCircle2 className="h-3 w-3 mr-1" />
Proaspete
</Badge>
) : staleLayers.length > 0 ? (
<Badge
variant="outline"
className="text-amber-600 border-amber-200 dark:text-amber-400 dark:border-amber-800 cursor-default"
title={fullTooltip}
>
<Clock className="h-3 w-3 mr-1" />
{staleLayers.length} vechi
</Badge>
) : null}
{newestSync && (
<span className="text-xs text-muted-foreground">
Ultima sincronizare: {relativeTime(newestSync)}
</span>
)}
</>
);
})()}
</div>
</CardContent>
</Card>
)}
{/* Hero buttons */}
{sirutaValid && (session.connected || canExportLocal) ? (
<div className="space-y-2">
<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 || downloadingFromDb}
onClick={() =>
canExportLocal
? void handleDownloadFromDb("base")
: void handleExportBundle("base")
}
>
{(exporting || downloadingFromDb) &&
exportProgress?.phase !== "Detalii parcele" ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : canExportLocal ? (
<Database className="mr-2 h-5 w-5" />
) : (
<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">
{canExportLocal
? `Din DB (sync ${oldestSyncDate ? relativeTime(oldestSyncDate) : "recent"})`
: hasData
? "Sync incremental + GPKG"
: "Sync complet + GPKG"}
</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 || downloadingFromDb}
onClick={() =>
canExportLocal
? void handleDownloadFromDb("magic")
: void handleExportBundle("magic")
}
>
{(exporting || downloadingFromDb) &&
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">
{canExportLocal
? `GPKG + CSV din DB (sync ${oldestSyncDate ? relativeTime(oldestSyncDate) : "recent"})`
: "Sync + îmbogățire (CF, proprietari, adresă) + GPKG + CSV"}
</div>
</div>
</Button>
</div>
{canExportLocal && session.connected && (
<div className="text-center">
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground underline transition-colors"
disabled={exporting}
onClick={() => void handleExportBundle("base")}
>
<RefreshCw className="inline h-3 w-3 mr-1 -mt-0.5" />
Re-sincronizează de pe eTerra
</button>
</div>
)}
</div>
) : (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
{!session.connected && !canExportLocal ? (
<>
<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("\u00cembogăț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("\u00cembogăț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>
)}
{!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>
)}
{/* Weekend Deep Sync hint */}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Moon className="h-3.5 w-3.5 shrink-0" />
<span>
Municipii mari cu Magic complet?{" "}
<Link
href="/wds"
className="underline hover:text-foreground transition-colors"
>
Weekend Deep Sync
</Link>
{" "} sincronizare automata Vin/Sam/Dum noaptea.
</span>
</div>
{/* 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([]);
onSyncRefresh();
onDbRefresh();
}}
>
Închide
</Button>
</div>
)}
</CardContent>
</Card>
)}
{/* Progress bar for bundle export */}
{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>
)}
</div>
);
}