feat(parcel-sync): background sync + download from DB
- New POST /api/eterra/sync-background: fire-and-forget server-side processing Starts sync + optional enrichment in background, returns 202 immediately. Progress tracked via existing /api/eterra/progress polling. Work continues in Node.js event loop even if browser is closed. Progress persists 1 hour for background jobs (vs 60s for normal). - Enhanced POST /api/eterra/export-local: base/magic mode support mode=base: ZIP with terenuri.gpkg + cladiri.gpkg from local DB mode=magic: adds terenuri_magic.gpkg (enrichment merged, includes no-geom), terenuri_complet.csv, raport_calitate.txt, export_report.json All from PostgreSQL — zero eTerra API calls, instant download. - UI: background sync section in Export tab 'Sync fundal Baza/Magic' buttons: start background processing 'Descarc─â din DB Baza/Magic' buttons: instant download from local DB Background job progress card with indigo theme (distinct from export) localStorage job recovery: resume polling after page refresh 'Descarc─â din DB' button shown on completion
This commit is contained in:
@@ -414,6 +414,13 @@ export function ParcelSyncModule() {
|
||||
} | null>(null);
|
||||
const [noGeomScanSiruta, setNoGeomScanSiruta] = useState(""); // siruta for which scan was done
|
||||
|
||||
/* ── Background sync state ──────────────────────────────────── */
|
||||
const [bgJobId, setBgJobId] = useState<string | null>(null);
|
||||
const [bgProgress, setBgProgress] = useState<ExportProgress | null>(null);
|
||||
const [bgPhaseTrail, setBgPhaseTrail] = useState<string[]>([]);
|
||||
const bgPollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [downloadingFromDb, setDownloadingFromDb] = useState(false);
|
||||
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
/* Load UAT data + check server session on mount */
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
@@ -988,6 +995,192 @@ export function ParcelSyncModule() {
|
||||
[siruta, exportingLocal],
|
||||
);
|
||||
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
/* Background sync — fire-and-forget server-side processing */
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
|
||||
const startBgPolling = useCallback(
|
||||
(jid: string) => {
|
||||
if (bgPollingRef.current) clearInterval(bgPollingRef.current);
|
||||
bgPollingRef.current = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/eterra/progress?jobId=${encodeURIComponent(jid)}`,
|
||||
);
|
||||
const data = (await res.json()) as ExportProgress;
|
||||
setBgProgress(data);
|
||||
if (data.phase) {
|
||||
setBgPhaseTrail((prev) => {
|
||||
if (prev[prev.length - 1] === data.phase) return prev;
|
||||
return [...prev, data.phase!];
|
||||
});
|
||||
}
|
||||
if (data.status === "done" || data.status === "error") {
|
||||
if (bgPollingRef.current) {
|
||||
clearInterval(bgPollingRef.current);
|
||||
bgPollingRef.current = null;
|
||||
}
|
||||
// Clean localStorage marker
|
||||
try {
|
||||
localStorage.removeItem("parcel-sync:bg-job");
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
// Refresh sync status and DB summary
|
||||
refreshSyncRef.current?.();
|
||||
void fetchDbSummary();
|
||||
}
|
||||
} catch {
|
||||
/* ignore polling errors */
|
||||
}
|
||||
}, 1500);
|
||||
},
|
||||
[fetchDbSummary],
|
||||
);
|
||||
|
||||
// Cleanup bg polling on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (bgPollingRef.current) clearInterval(bgPollingRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Recover background job from localStorage on mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem("parcel-sync:bg-job");
|
||||
if (!raw) return;
|
||||
const saved = JSON.parse(raw) as {
|
||||
jobId: string;
|
||||
siruta: string;
|
||||
startedAt: string;
|
||||
};
|
||||
// Ignore jobs older than 2 hours
|
||||
const age = Date.now() - new Date(saved.startedAt).getTime();
|
||||
if (age > 2 * 60 * 60 * 1000) {
|
||||
localStorage.removeItem("parcel-sync:bg-job");
|
||||
return;
|
||||
}
|
||||
// Check if job is still running
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/eterra/progress?jobId=${encodeURIComponent(saved.jobId)}`,
|
||||
);
|
||||
const data = (await res.json()) as ExportProgress;
|
||||
if (data.status === "running") {
|
||||
setBgJobId(saved.jobId);
|
||||
setBgProgress(data);
|
||||
if (data.phase) setBgPhaseTrail([data.phase]);
|
||||
startBgPolling(saved.jobId);
|
||||
} else if (data.status === "done") {
|
||||
setBgJobId(saved.jobId);
|
||||
setBgProgress(data);
|
||||
if (data.phase) setBgPhaseTrail(["Sincronizare completă"]);
|
||||
localStorage.removeItem("parcel-sync:bg-job");
|
||||
} else {
|
||||
localStorage.removeItem("parcel-sync:bg-job");
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem("parcel-sync:bg-job");
|
||||
}
|
||||
})();
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
}, [startBgPolling]);
|
||||
|
||||
const handleSyncBackground = useCallback(
|
||||
async (mode: "base" | "magic") => {
|
||||
if (!siruta || exporting) return;
|
||||
try {
|
||||
const res = await fetch("/api/eterra/sync-background", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
siruta,
|
||||
mode,
|
||||
includeNoGeometry: includeNoGeom,
|
||||
}),
|
||||
});
|
||||
const data = (await res.json()) as { jobId?: string; error?: string };
|
||||
if (!res.ok || data.error) {
|
||||
setSyncProgress(data.error ?? `Eroare ${res.status}`);
|
||||
setTimeout(() => setSyncProgress(""), 5_000);
|
||||
return;
|
||||
}
|
||||
const jid = data.jobId!;
|
||||
setBgJobId(jid);
|
||||
setBgProgress({
|
||||
jobId: jid,
|
||||
downloaded: 0,
|
||||
total: 100,
|
||||
status: "running",
|
||||
phase: "Pornire sincronizare fundal",
|
||||
});
|
||||
setBgPhaseTrail(["Pornire sincronizare fundal"]);
|
||||
// Persist in localStorage so we can recover on page refresh
|
||||
try {
|
||||
localStorage.setItem(
|
||||
"parcel-sync:bg-job",
|
||||
JSON.stringify({
|
||||
jobId: jid,
|
||||
siruta,
|
||||
startedAt: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
startBgPolling(jid);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare rețea";
|
||||
setSyncProgress(msg);
|
||||
setTimeout(() => setSyncProgress(""), 5_000);
|
||||
}
|
||||
},
|
||||
[siruta, exporting, includeNoGeom, startBgPolling],
|
||||
);
|
||||
|
||||
const handleDownloadFromDb = useCallback(
|
||||
async (mode: "base" | "magic") => {
|
||||
if (!siruta || downloadingFromDb) return;
|
||||
setDownloadingFromDb(true);
|
||||
try {
|
||||
const res = await fetch("/api/eterra/export-local", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ siruta, mode }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
};
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const cd = res.headers.get("Content-Disposition") ?? "";
|
||||
const match = /filename="?([^"]+)"?/.exec(cd);
|
||||
const filename = match?.[1] ?? `eterra_uat_${siruta}_${mode}_local.zip`;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
const msg =
|
||||
error instanceof Error ? error.message : "Eroare descărcare";
|
||||
setSyncProgress(msg);
|
||||
setTimeout(() => setSyncProgress(""), 5_000);
|
||||
}
|
||||
setDownloadingFromDb(false);
|
||||
},
|
||||
[siruta, downloadingFromDb],
|
||||
);
|
||||
|
||||
// Sync multiple layers sequentially (for "sync all" / "sync category")
|
||||
const [syncQueue, setSyncQueue] = useState<string[]>([]);
|
||||
const syncQueueRef = useRef<string[]>([]);
|
||||
@@ -2815,6 +3008,273 @@ export function ParcelSyncModule() {
|
||||
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>
|
||||
|
||||
{/* Row 2: Background sync buttons */}
|
||||
{session.connected && (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto py-2.5 justify-start"
|
||||
disabled={
|
||||
exporting ||
|
||||
(!!bgJobId && bgProgress?.status === "running")
|
||||
}
|
||||
onClick={() => void handleSyncBackground("base")}
|
||||
>
|
||||
{bgJobId &&
|
||||
bgProgress?.status === "running" &&
|
||||
!bgPhaseTrail.some((p) => p.includes("Îmbogățire")) ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowDownToLine className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<div className="text-xs font-semibold">
|
||||
Sync fundal — Bază
|
||||
</div>
|
||||
<div className="text-[10px] opacity-60 font-normal">
|
||||
Terenuri + clădiri → salvează în DB
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto py-2.5 justify-start border-teal-200 dark:border-teal-800 hover:bg-teal-50 dark:hover:bg-teal-950/30"
|
||||
disabled={
|
||||
exporting ||
|
||||
(!!bgJobId && bgProgress?.status === "running")
|
||||
}
|
||||
onClick={() => void handleSyncBackground("magic")}
|
||||
>
|
||||
{bgJobId &&
|
||||
bgProgress?.status === "running" &&
|
||||
bgPhaseTrail.some((p) => p.includes("Îmbogățire")) ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin text-teal-600" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4 text-teal-600" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<div className="text-xs font-semibold">
|
||||
Sync fundal — Magic
|
||||
</div>
|
||||
<div className="text-[10px] opacity-60 font-normal">
|
||||
Sync + îmbogățire → salvează în DB
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 3: Download from DB buttons */}
|
||||
{dbTotalFeatures > 0 && (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto py-2.5 justify-start"
|
||||
disabled={downloadingFromDb}
|
||||
onClick={() => void handleDownloadFromDb("base")}
|
||||
>
|
||||
{downloadingFromDb ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Database className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<div className="text-xs font-semibold">
|
||||
Descarcă din DB — Bază
|
||||
</div>
|
||||
<div className="text-[10px] opacity-60 font-normal">
|
||||
GPKG terenuri + clădiri (instant, fără eTerra)
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto py-2.5 justify-start border-teal-200 dark:border-teal-800 hover:bg-teal-50 dark:hover:bg-teal-950/30"
|
||||
disabled={downloadingFromDb}
|
||||
onClick={() => void handleDownloadFromDb("magic")}
|
||||
>
|
||||
{downloadingFromDb ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin text-teal-600" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4 text-teal-600" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<div className="text-xs font-semibold">
|
||||
Descarcă din DB — Magic
|
||||
</div>
|
||||
<div className="text-[10px] opacity-60 font-normal">
|
||||
GPKG + CSV + raport calitate (instant)
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!session.connected && dbTotalFeatures === 0 && (
|
||||
<p className="text-xs text-muted-foreground ml-6">
|
||||
Conectează-te la eTerra pentru a porni sincronizarea fundal,
|
||||
sau sincronizează mai întâi date în baza locală.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Background sync progress */}
|
||||
{bgJobId && bgProgress && bgProgress.status !== "unknown" && (
|
||||
<Card
|
||||
className={cn(
|
||||
"border-2 transition-colors",
|
||||
bgProgress.status === "running" &&
|
||||
"border-indigo-300 dark:border-indigo-700",
|
||||
bgProgress.status === "error" &&
|
||||
"border-rose-300 dark:border-rose-700",
|
||||
bgProgress.status === "done" &&
|
||||
"border-emerald-400 dark:border-emerald-600",
|
||||
)}
|
||||
>
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
{/* Label */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<HardDrive className="h-3.5 w-3.5 text-indigo-500" />
|
||||
<span className="font-semibold text-indigo-700 dark:text-indigo-400">
|
||||
Sincronizare fundal
|
||||
</span>
|
||||
{bgProgress.status === "running" && (
|
||||
<span className="text-muted-foreground">
|
||||
(poți închide pagina)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phase trail */}
|
||||
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
|
||||
{bgPhaseTrail.map((p, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="opacity-40">→</span>}
|
||||
<span
|
||||
className={cn(
|
||||
i === bgPhaseTrail.length - 1
|
||||
? "font-semibold text-foreground"
|
||||
: "opacity-60",
|
||||
)}
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Progress info */}
|
||||
<div className="flex items-center gap-3">
|
||||
{bgProgress.status === "running" && (
|
||||
<Loader2 className="h-5 w-5 text-indigo-600 animate-spin shrink-0" />
|
||||
)}
|
||||
{bgProgress.status === "done" && (
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 shrink-0" />
|
||||
)}
|
||||
{bgProgress.status === "error" && (
|
||||
<XCircle className="h-5 w-5 text-rose-500 shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{bgProgress.phase}</p>
|
||||
{bgProgress.note && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{bgProgress.note}
|
||||
</p>
|
||||
)}
|
||||
{bgProgress.message && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs mt-0.5",
|
||||
bgProgress.status === "error"
|
||||
? "text-rose-500"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{bgProgress.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-mono font-semibold tabular-nums shrink-0">
|
||||
{bgProgress.total && bgProgress.total > 0
|
||||
? Math.round(
|
||||
(bgProgress.downloaded / bgProgress.total) * 100,
|
||||
)
|
||||
: 0}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bar */}
|
||||
<div className="h-2.5 w-full rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2.5 rounded-full transition-all duration-300",
|
||||
bgProgress.status === "running" && "bg-indigo-500",
|
||||
bgProgress.status === "done" && "bg-emerald-500",
|
||||
bgProgress.status === "error" && "bg-rose-500",
|
||||
)}
|
||||
style={{
|
||||
width: `${Math.max(2, bgProgress.total && bgProgress.total > 0 ? Math.round((bgProgress.downloaded / bgProgress.total) * 100) : 0)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Done — show download from DB button */}
|
||||
{bgProgress.status === "done" && (
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-teal-200 dark:border-teal-800"
|
||||
disabled={downloadingFromDb}
|
||||
onClick={() => void handleDownloadFromDb("magic")}
|
||||
>
|
||||
{downloadingFromDb ? (
|
||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Database className="mr-1.5 h-3 w-3" />
|
||||
)}
|
||||
Descarcă din DB (Magic)
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
setBgJobId(null);
|
||||
setBgProgress(null);
|
||||
setBgPhaseTrail([]);
|
||||
}}
|
||||
>
|
||||
Închide
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
{exportProgress &&
|
||||
exportProgress.status !== "unknown" &&
|
||||
|
||||
Reference in New Issue
Block a user