8b6d6ba1d0
- 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>
1544 lines
62 KiB
TypeScript
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ă să 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 & 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>
|
|
);
|
|
}
|