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:
AI Assistant
2026-03-08 01:53:24 +02:00
parent bcc7a54325
commit c43082baee
3 changed files with 1167 additions and 33 deletions
@@ -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 &amp; 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" &&