feat(parcel-sync): scan shows local DB context + Magic workflow preview

- NoGeomScanResult now includes: localDbTotal, localDbWithGeom, localDbNoGeom,
  localDbEnriched, localSyncFresh (parallel DB queries, fast)
- Scan card shows 'Baza de date locala: X cu geometrie + Y fara + Z imbogatite'
- Workflow preview shows numbered steps with smart estimates:
  step 1 shows 'skip (date proaspete)' when sync is fresh
  step 2 shows '~N noi de importat' or 'deja importate' for no-geom
  step 3 shows '~N de procesat (~M min)' or 'deja imbogatite' for enrichment
- All-geometry card also shows local DB summary
- User can see exactly what will happen before pressing Magic
This commit is contained in:
AI Assistant
2026-03-07 17:50:34 +02:00
parent b01ea9fc37
commit 96859dde4f
2 changed files with 195 additions and 12 deletions
@@ -390,6 +390,11 @@ export function ParcelSyncModule() {
totalImmovables: number; totalImmovables: number;
withGeometry: number; withGeometry: number;
noGeomCount: number; noGeomCount: number;
localDbTotal: number;
localDbWithGeom: number;
localDbNoGeom: number;
localDbEnriched: number;
localSyncFresh: boolean;
} | null>(null); } | null>(null);
const [noGeomScanSiruta, setNoGeomScanSiruta] = useState(""); // siruta for which scan was done const [noGeomScanSiruta, setNoGeomScanSiruta] = useState(""); // siruta for which scan was done
@@ -686,6 +691,16 @@ export function ParcelSyncModule() {
setNoGeomScanning(true); setNoGeomScanning(true);
setNoGeomScan(null); setNoGeomScan(null);
setNoGeomScanSiruta(s); setNoGeomScanSiruta(s);
const emptyResult = {
totalImmovables: 0,
withGeometry: 0,
noGeomCount: 0,
localDbTotal: 0,
localDbWithGeom: 0,
localDbNoGeom: 0,
localDbEnriched: 0,
localSyncFresh: false,
};
try { try {
const res = await fetch("/api/eterra/no-geom-scan", { const res = await fetch("/api/eterra/no-geom-scan", {
method: "POST", method: "POST",
@@ -695,25 +710,24 @@ export function ParcelSyncModule() {
workspacePk: workspacePk ?? undefined, workspacePk: workspacePk ?? undefined,
}), }),
}); });
const data = (await res.json()) as { const data = (await res.json()) as Record<string, unknown>;
totalImmovables?: number;
withGeometry?: number;
noGeomCount?: number;
error?: string;
};
if (data.error) { if (data.error) {
console.warn("[no-geom-scan]", data.error); console.warn("[no-geom-scan]", data.error);
setNoGeomScan({ totalImmovables: 0, withGeometry: 0, noGeomCount: 0 }); setNoGeomScan(emptyResult);
} else { } else {
setNoGeomScan({ setNoGeomScan({
totalImmovables: data.totalImmovables ?? 0, totalImmovables: Number(data.totalImmovables ?? 0),
withGeometry: data.withGeometry ?? 0, withGeometry: Number(data.withGeometry ?? 0),
noGeomCount: data.noGeomCount ?? 0, noGeomCount: Number(data.noGeomCount ?? 0),
localDbTotal: Number(data.localDbTotal ?? 0),
localDbWithGeom: Number(data.localDbWithGeom ?? 0),
localDbNoGeom: Number(data.localDbNoGeom ?? 0),
localDbEnriched: Number(data.localDbEnriched ?? 0),
localSyncFresh: Boolean(data.localSyncFresh),
}); });
} }
} catch { } catch {
// Show zero result on network error setNoGeomScan(emptyResult);
setNoGeomScan({ totalImmovables: 0, withGeometry: 0, noGeomCount: 0 });
} }
setNoGeomScanning(false); setNoGeomScanning(false);
}, },
@@ -2385,6 +2399,112 @@ export function ParcelSyncModule() {
</Card> </Card>
); );
// Helper: local DB status line
const localDbLine = scanDone && noGeomScan.localDbTotal > 0 && (
<div className="flex items-center gap-1.5 flex-wrap text-[11px] text-muted-foreground mt-1">
<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
</>
)}
{noGeomScan.localSyncFresh && (
<span className="text-emerald-600 dark:text-emerald-400 ml-1">
(proaspăt)
</span>
)}
</span>
</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>
{noGeomScan.localSyncFresh && noGeomScan.localDbWithGeom > 0
? "Sync terenuri + clădiri — "
: "Sync terenuri + clădiri — "}
<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.withGeometry.toLocaleString("ro-RO")} features`}
</span>
</li>
{includeNoGeom && (
<li>
Import parcele fără geometrie {" "}
<span className="font-medium text-amber-600 dark:text-amber-400">
{(() => {
const newNoGeom = Math.max(
0,
noGeomScan.noGeomCount - noGeomScan.localDbNoGeom,
);
return newNoGeom > 0
? `~${newNoGeom.toLocaleString("ro-RO")} noi de importat`
: "deja importate";
})()}
</span>
</li>
)}
<li>
Îmbogățire CF, proprietari, adrese {" "}
<span className="font-medium text-teal-600 dark:text-teal-400">
{(() => {
const totalToEnrich =
noGeomScan.localDbTotal +
(includeNoGeom
? Math.max(
0,
noGeomScan.noGeomCount -
noGeomScan.localDbNoGeom,
)
: 0);
const remaining =
totalToEnrich - noGeomScan.localDbEnriched;
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 // No-geometry parcels found
if (hasNoGeomParcels) if (hasNoGeomParcels)
return ( return (
@@ -2419,6 +2539,7 @@ export function ParcelSyncModule() {
Cele fără geometrie există în baza de date eTerra dar Cele fără geometrie există în baza de date eTerra dar
nu au contur desenat în layerul GIS. nu au contur desenat în layerul GIS.
</p> </p>
{localDbLine}
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
@@ -2449,6 +2570,7 @@ export function ParcelSyncModule() {
HAS_GEOMETRY=0). Nu apar în GPKG. HAS_GEOMETRY=0). Nu apar în GPKG.
</p> </p>
)} )}
{workflowPreview}
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -2466,6 +2588,15 @@ export function ParcelSyncModule() {
{noGeomScan.totalImmovables.toLocaleString("ro-RO")}{" "} {noGeomScan.totalImmovables.toLocaleString("ro-RO")}{" "}
imobile din eTerra au geometrie nimic de importat imobile din eTerra au geometrie nimic de importat
suplimentar. 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.localSyncFresh && ", proaspăt"})
</span>
)}
</> </>
) : ( ) : (
<> <>
@@ -103,6 +103,16 @@ export type NoGeomScanResult = {
paperCadNo?: string; paperCadNo?: string;
paperCfNo?: string; paperCfNo?: string;
}>; }>;
/** Total features already in local DB (geometry + no-geom) */
localDbTotal: number;
/** Geometry features already synced in local DB */
localDbWithGeom: number;
/** No-geometry features already imported in local DB */
localDbNoGeom: number;
/** How many are already enriched (magic) in local DB */
localDbEnriched: number;
/** Whether local sync is fresh (< 7 days) */
localSyncFresh: boolean;
/** Error message if workspace couldn't be resolved */ /** Error message if workspace couldn't be resolved */
error?: string; error?: string;
}; };
@@ -141,6 +151,11 @@ export async function scanNoGeometryParcels(
withGeometry: 0, withGeometry: 0,
noGeomCount: 0, noGeomCount: 0,
samples: [], samples: [],
localDbTotal: 0,
localDbWithGeom: 0,
localDbNoGeom: 0,
localDbEnriched: 0,
localSyncFresh: false,
error: `Nu s-a putut determina workspace-ul (județul) pentru SIRUTA ${siruta}`, error: `Nu s-a putut determina workspace-ul (județul) pentru SIRUTA ${siruta}`,
}; };
} }
@@ -206,11 +221,48 @@ export async function scanNoGeometryParcels(
}); });
} }
// 4. Query local DB for context (what's already synced/imported)
const [localTotal, localNoGeom, localEnriched, lastSyncRun] =
await Promise.all([
prisma.gisFeature.count({
where: { layerId: "TERENURI_ACTIVE", siruta },
}),
prisma.gisFeature.count({
where: {
layerId: "TERENURI_ACTIVE",
siruta,
geometrySource: "NO_GEOMETRY",
},
}),
prisma.gisFeature.count({
where: {
layerId: "TERENURI_ACTIVE",
siruta,
enrichedAt: { not: null },
},
}),
prisma.gisSyncRun.findFirst({
where: { siruta, layerId: "TERENURI_ACTIVE", status: "done" },
orderBy: { completedAt: "desc" },
select: { completedAt: true },
}),
]);
const localWithGeom = localTotal - localNoGeom;
const syncFresh = lastSyncRun?.completedAt
? Date.now() - lastSyncRun.completedAt.getTime() < 168 * 60 * 60 * 1000
: false;
return { return {
totalImmovables: allImmovables.length, totalImmovables: allImmovables.length,
withGeometry: remoteFeatures.length, withGeometry: remoteFeatures.length,
noGeomCount: noGeomItems.length, noGeomCount: noGeomItems.length,
samples: noGeomItems.slice(0, 20), samples: noGeomItems.slice(0, 20),
localDbTotal: localTotal,
localDbWithGeom: localWithGeom,
localDbNoGeom: localNoGeom,
localDbEnriched: localEnriched,
localSyncFresh: syncFresh,
}; };
} }