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;
withGeometry: number;
noGeomCount: number;
localDbTotal: number;
localDbWithGeom: number;
localDbNoGeom: number;
localDbEnriched: number;
localSyncFresh: boolean;
} | null>(null);
const [noGeomScanSiruta, setNoGeomScanSiruta] = useState(""); // siruta for which scan was done
@@ -686,6 +691,16 @@ export function ParcelSyncModule() {
setNoGeomScanning(true);
setNoGeomScan(null);
setNoGeomScanSiruta(s);
const emptyResult = {
totalImmovables: 0,
withGeometry: 0,
noGeomCount: 0,
localDbTotal: 0,
localDbWithGeom: 0,
localDbNoGeom: 0,
localDbEnriched: 0,
localSyncFresh: false,
};
try {
const res = await fetch("/api/eterra/no-geom-scan", {
method: "POST",
@@ -695,25 +710,24 @@ export function ParcelSyncModule() {
workspacePk: workspacePk ?? undefined,
}),
});
const data = (await res.json()) as {
totalImmovables?: number;
withGeometry?: number;
noGeomCount?: number;
error?: string;
};
const data = (await res.json()) as Record<string, unknown>;
if (data.error) {
console.warn("[no-geom-scan]", data.error);
setNoGeomScan({ totalImmovables: 0, withGeometry: 0, noGeomCount: 0 });
setNoGeomScan(emptyResult);
} else {
setNoGeomScan({
totalImmovables: data.totalImmovables ?? 0,
withGeometry: data.withGeometry ?? 0,
noGeomCount: data.noGeomCount ?? 0,
totalImmovables: Number(data.totalImmovables ?? 0),
withGeometry: Number(data.withGeometry ?? 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 {
// Show zero result on network error
setNoGeomScan({ totalImmovables: 0, withGeometry: 0, noGeomCount: 0 });
setNoGeomScan(emptyResult);
}
setNoGeomScanning(false);
},
@@ -2385,6 +2399,112 @@ export function ParcelSyncModule() {
</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
if (hasNoGeomParcels)
return (
@@ -2419,6 +2539,7 @@ export function ParcelSyncModule() {
Cele fără geometrie există în baza de date eTerra dar
nu au contur desenat în layerul GIS.
</p>
{localDbLine}
</div>
<Button
variant="ghost"
@@ -2449,6 +2570,7 @@ export function ParcelSyncModule() {
HAS_GEOMETRY=0). Nu apar în GPKG.
</p>
)}
{workflowPreview}
</CardContent>
</Card>
);
@@ -2466,6 +2588,15 @@ export function ParcelSyncModule() {
{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.localSyncFresh && ", proaspăt"})
</span>
)}
</>
) : (
<>
@@ -103,6 +103,16 @@ export type NoGeomScanResult = {
paperCadNo?: 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?: string;
};
@@ -141,6 +151,11 @@ export async function scanNoGeometryParcels(
withGeometry: 0,
noGeomCount: 0,
samples: [],
localDbTotal: 0,
localDbWithGeom: 0,
localDbNoGeom: 0,
localDbEnriched: 0,
localSyncFresh: false,
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 {
totalImmovables: allImmovables.length,
withGeometry: remoteFeatures.length,
noGeomCount: noGeomItems.length,
samples: noGeomItems.slice(0, 20),
localDbTotal: localTotal,
localDbWithGeom: localWithGeom,
localDbNoGeom: localNoGeom,
localDbEnriched: localEnriched,
localSyncFresh: syncFresh,
};
}