feat(parcel-sync): import eTerra immovables without geometry

- Add geometrySource field to GisFeature (NO_GEOMETRY marker)
- New no-geom-sync service: scan + import parcels missing from GIS layer
- Uses negative immovablePk as objectId to avoid @@unique collision
- New /api/eterra/no-geom-scan endpoint for counting
- Export-bundle: includeNoGeometry flag, imports before enrich
- CSV export: new HAS_GEOMETRY column (0/1)
- GPKG: still geometry-only (unchanged)
- UI: checkbox + scan button on Export tab
- Baza de Date tab: shows no-geometry counts per UAT
- db-summary API: includes noGeomCount per layer
This commit is contained in:
AI Assistant
2026-03-07 12:58:10 +02:00
parent d50b9ea0e2
commit 30915e8628
6 changed files with 604 additions and 22 deletions
@@ -351,10 +351,12 @@ export function ParcelSyncModule() {
layerId: string;
count: number;
enrichedCount: number;
noGeomCount: number;
lastSynced: string | null;
}[];
totalFeatures: number;
totalEnriched: number;
totalNoGeom: number;
};
type DbSummary = {
uats: DbUatSummary[];
@@ -380,6 +382,16 @@ export function ParcelSyncModule() {
const [loadingFeatures, setLoadingFeatures] = useState(false);
const [searchError, setSearchError] = useState("");
/* ── No-geometry import option ──────────────────────────────── */
const [includeNoGeom, setIncludeNoGeom] = useState(false);
const [noGeomScanning, setNoGeomScanning] = useState(false);
const [noGeomScan, setNoGeomScan] = useState<{
totalImmovables: number;
totalInDb: number;
noGeomCount: number;
} | null>(null);
const [noGeomScanSiruta, setNoGeomScanSiruta] = useState(""); // siruta for which scan was done
/* ════════════════════════════════════════════════════════════ */
/* Load UAT data + check server session on mount */
/* ════════════════════════════════════════════════════════════ */
@@ -594,6 +606,7 @@ export function ParcelSyncModule() {
siruta,
jobId,
mode,
includeNoGeometry: includeNoGeom,
}),
});
@@ -658,9 +671,45 @@ export function ParcelSyncModule() {
// Refresh sync status — data was synced to DB
refreshSyncRef.current?.();
},
[siruta, exporting, startPolling],
[siruta, exporting, startPolling, includeNoGeom],
);
/* ════════════════════════════════════════════════════════════ */
/* No-geometry scan */
/* ════════════════════════════════════════════════════════════ */
const handleNoGeomScan = useCallback(async () => {
if (!siruta || noGeomScanning) return;
setNoGeomScanning(true);
setNoGeomScan(null);
try {
const res = await fetch("/api/eterra/no-geom-scan", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ siruta }),
});
const data = (await res.json()) as {
totalImmovables?: number;
totalInDb?: number;
noGeomCount?: number;
error?: string;
};
if (data.error) {
setNoGeomScan(null);
} else {
setNoGeomScan({
totalImmovables: data.totalImmovables ?? 0,
totalInDb: data.totalInDb ?? 0,
noGeomCount: data.noGeomCount ?? 0,
});
setNoGeomScanSiruta(siruta);
}
} catch {
setNoGeomScan(null);
}
setNoGeomScanning(false);
}, [siruta, noGeomScanning]);
/* ════════════════════════════════════════════════════════════ */
/* Layer feature counts */
/* ════════════════════════════════════════════════════════════ */
@@ -2295,6 +2344,71 @@ export function ParcelSyncModule() {
</Card>
)}
{/* No-geometry option */}
{sirutaValid && session.connected && (
<Card className="border-dashed">
<CardContent className="py-3 px-4">
<div className="flex items-center gap-3 flex-wrap">
<label className="flex items-center gap-2 cursor-pointer select-none">
<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">
Include parcele{" "}
<span className="font-medium">fără geometrie</span>
</span>
</label>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
disabled={noGeomScanning || exporting}
onClick={() => void handleNoGeomScan()}
>
{noGeomScanning ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Search className="h-3 w-3 mr-1" />
)}
Scanare
</Button>
{noGeomScan && noGeomScanSiruta === siruta && (
<span className="text-xs text-muted-foreground">
{noGeomScan.noGeomCount > 0 ? (
<>
<span className="font-semibold text-amber-600 dark:text-amber-400">
{noGeomScan.noGeomCount.toLocaleString("ro-RO")}
</span>{" "}
parcele fără geometrie
<span className="ml-1 opacity-60">
(din{" "}
{noGeomScan.totalImmovables.toLocaleString("ro-RO")}{" "}
total eTerra,{" "}
{noGeomScan.totalInDb.toLocaleString("ro-RO")} în DB)
</span>
</>
) : (
<span className="text-emerald-600 dark:text-emerald-400">
Toate parcelele au geometrie
</span>
)}
</span>
)}
</div>
{includeNoGeom && (
<p className="text-[11px] text-muted-foreground mt-1.5 ml-6">
Parcelele fără geometrie vor apărea doar în CSV (coloana
HAS_GEOMETRY=0), nu în GPKG.
</p>
)}
</CardContent>
</Card>
)}
{/* Progress bar */}
{exportProgress &&
exportProgress.status !== "unknown" &&
@@ -2442,12 +2556,14 @@ export function ParcelSyncModule() {
{dbSummary.uats.map((uat) => {
const catCounts: Record<string, number> = {};
let enrichedTotal = 0;
let noGeomTotal = 0;
let oldestSync: Date | null = null;
for (const layer of uat.layers) {
const cat =
findLayerById(layer.layerId)?.category ?? "administrativ";
catCounts[cat] = (catCounts[cat] ?? 0) + layer.count;
enrichedTotal += layer.enrichedCount;
noGeomTotal += layer.noGeomCount ?? 0;
if (layer.lastSynced) {
const d = new Date(layer.lastSynced);
if (!oldestSync || d < oldestSync) oldestSync = d;
@@ -2523,6 +2639,16 @@ export function ParcelSyncModule() {
</span>
</span>
)}
{noGeomTotal > 0 && (
<span className="inline-flex items-center gap-1">
<span className="text-muted-foreground">
Fără geom:
</span>
<span className="font-medium tabular-nums text-amber-600 dark:text-amber-400">
{noGeomTotal.toLocaleString("ro-RO")}
</span>
</span>
)}
</div>
{/* Layer detail pills */}