refactor(parcel-sync): split 4800-line module into 9 files + Harta tab + enrichment views

Split parcel-sync-module.tsx (4800 lines) into modular files:
- Orchestrator (452 lines): shared state (session, UAT, sync) + tab routing
- Types + helpers, ConnectionPill, 6 tab components (search, layers, export, database, cf, map)

New ParcelSync Harta tab:
- UAT-scoped map: zoom to extent, filter parcels/buildings by siruta
- Data-driven styling via gis_terenuri_status enrichment overlay
  (green=no enrichment, dark green=enriched, blue outline=building, red=no legal docs)
- Reuses Geoportal components (MapViewer, SelectionToolbar, FeatureInfoPanel, BasemapSwitcher)
- Export DXF/GPKG for selection, legend

New PostGIS views (gis_terenuri_status, gis_cladiri_status):
- has_enrichment, has_building, build_legal columns from enrichment JSON
- Auto-created via /api/geoportal/setup-enrichment-views
- Does not modify existing Geoportal views

New API: /api/geoportal/uat-bounds (WGS84 bbox from PostGIS geometry)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-24 15:02:01 +02:00
parent 3fcf7e2a67
commit d48a2bbf5d
11 changed files with 5199 additions and 4452 deletions
@@ -0,0 +1,396 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import dynamic from "next/dynamic";
import { Map as MapIcon, Loader2 } from "lucide-react";
import { Card, CardContent } from "@/shared/components/ui/card";
import { BasemapSwitcher } from "@/modules/geoportal/components/basemap-switcher";
import {
SelectionToolbar,
type SelectionMode,
} from "@/modules/geoportal/components/selection-toolbar";
import { FeatureInfoPanel } from "@/modules/geoportal/components/feature-info-panel";
import type { MapViewerHandle } from "@/modules/geoportal/components/map-viewer";
import type {
BasemapId,
ClickedFeature,
LayerVisibility,
SelectedFeature,
} from "@/modules/geoportal/types";
/* MapLibre uses WebGL — must disable SSR */
const MapViewer = dynamic(
() =>
import("@/modules/geoportal/components/map-viewer").then((m) => ({
default: m.MapViewer,
})),
{
ssr: false,
loading: () => (
<div className="flex items-center justify-center h-64 bg-muted/30 rounded-lg">
<p className="text-sm text-muted-foreground">Se incarca harta...</p>
</div>
),
},
);
/* ------------------------------------------------------------------ */
/* Layer IDs — must match map-viewer.tsx LAYER_IDS */
/* ------------------------------------------------------------------ */
const BASE_LAYERS = [
"l-terenuri-fill",
"l-terenuri-line",
"l-terenuri-label",
"l-cladiri-fill",
"l-cladiri-line",
];
/* ------------------------------------------------------------------ */
/* Props */
/* ------------------------------------------------------------------ */
type MapTabProps = {
siruta: string;
sirutaValid: boolean;
};
/* ------------------------------------------------------------------ */
/* Helpers — typed map operations */
/* ------------------------------------------------------------------ */
type MapLike = {
getLayer(id: string): unknown;
getSource(id: string): unknown;
addSource(id: string, source: Record<string, unknown>): void;
addLayer(layer: Record<string, unknown>, before?: string): void;
setFilter(id: string, filter: unknown[] | null): void;
setLayoutProperty(id: string, prop: string, value: unknown): void;
fitBounds(bounds: [number, number, number, number], opts?: Record<string, unknown>): void;
isStyleLoaded(): boolean;
};
function asMap(handle: MapViewerHandle | null): MapLike | null {
const m = handle?.getMap();
return m ? (m as unknown as MapLike) : null;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function MapTab({ siruta, sirutaValid }: MapTabProps) {
const mapHandleRef = useRef<MapViewerHandle>(null);
const [basemap, setBasemap] = useState<BasemapId>("liberty");
const [clickedFeature, setClickedFeature] =
useState<ClickedFeature | null>(null);
const [selectionMode, setSelectionMode] = useState<SelectionMode>("off");
const [selectedFeatures, setSelectedFeatures] = useState<SelectedFeature[]>(
[],
);
const [boundsLoading, setBoundsLoading] = useState(false);
const [flyTarget, setFlyTarget] = useState<
{ center: [number, number]; zoom?: number } | undefined
>();
const [mapReady, setMapReady] = useState(false);
const [viewsReady, setViewsReady] = useState<boolean | null>(null);
const appliedSirutaRef = useRef("");
/* Layer visibility: show terenuri + cladiri, hide admin */
const [layerVisibility] = useState<LayerVisibility>({
terenuri: true,
cladiri: true,
administrativ: false,
});
/* ── Check if enrichment views exist, create if not ────────── */
useEffect(() => {
fetch("/api/geoportal/setup-enrichment-views")
.then((r) => r.json())
.then((data: { ready?: boolean }) => {
if (data.ready) {
setViewsReady(true);
} else {
// Auto-create views
fetch("/api/geoportal/setup-enrichment-views", { method: "POST" })
.then((r) => r.json())
.then((res: { status?: string }) => {
setViewsReady(res.status === "ok");
})
.catch(() => setViewsReady(false));
}
})
.catch(() => setViewsReady(false));
}, []);
/* ── Detect when map is ready ──────────────────────────────── */
useEffect(() => {
if (!sirutaValid) return;
const check = setInterval(() => {
const map = asMap(mapHandleRef.current);
if (map && map.isStyleLoaded()) {
setMapReady(true);
clearInterval(check);
}
}, 200);
return () => clearInterval(check);
}, [sirutaValid]);
/* ── Apply siruta filter on base map layers ────────────────── */
useEffect(() => {
if (!mapReady || !sirutaValid || !siruta) return;
if (appliedSirutaRef.current === siruta) return;
const map = asMap(mapHandleRef.current);
if (!map) return;
appliedSirutaRef.current = siruta;
const filter = ["==", ["get", "siruta"], siruta];
for (const layerId of BASE_LAYERS) {
try {
if (!map.getLayer(layerId)) continue;
map.setFilter(layerId, filter);
} catch {
/* layer may not exist */
}
}
}, [mapReady, siruta, sirutaValid]);
/* ── Add enrichment overlay source + layers ────────────────── */
useEffect(() => {
if (!mapReady || !viewsReady || !sirutaValid || !siruta) return;
const map = asMap(mapHandleRef.current);
if (!map) return;
const martinBase = typeof window !== "undefined"
? `${window.location.origin}/tiles`
: "/tiles";
// Add gis_terenuri_status source (only once)
if (!map.getSource("gis_terenuri_status")) {
map.addSource("gis_terenuri_status", {
type: "vector",
tiles: [`${martinBase}/gis_terenuri_status/{z}/{x}/{y}`],
minzoom: 10,
maxzoom: 18,
});
// Data-driven fill: color by enrichment status
map.addLayer(
{
id: "l-ps-terenuri-fill",
type: "fill",
source: "gis_terenuri_status",
"source-layer": "gis_terenuri_status",
minzoom: 13,
filter: ["==", ["get", "siruta"], siruta],
paint: {
"fill-color": [
"case",
// Enriched parcels: darker green
["==", ["get", "has_enrichment"], 1],
"#15803d",
// No enrichment: lighter green
"#86efac",
],
"fill-opacity": 0.25,
},
},
"l-terenuri-line", // insert before line layer
);
// Data-driven outline
map.addLayer(
{
id: "l-ps-terenuri-line",
type: "line",
source: "gis_terenuri_status",
"source-layer": "gis_terenuri_status",
minzoom: 13,
filter: ["==", ["get", "siruta"], siruta],
paint: {
"line-color": [
"case",
// Has building without legal docs: red
[
"all",
["==", ["get", "has_building"], 1],
["==", ["get", "build_legal"], 0],
],
"#ef4444",
// Has building with legal: blue
["==", ["get", "has_building"], 1],
"#3b82f6",
// Default: green
"#15803d",
],
"line-width": [
"case",
["==", ["get", "has_building"], 1],
1.8,
0.8,
],
},
},
"l-cladiri-fill",
);
} else {
// Source already exists — just update filters for new siruta
const sirutaFilter = ["==", ["get", "siruta"], siruta];
try {
if (map.getLayer("l-ps-terenuri-fill"))
map.setFilter("l-ps-terenuri-fill", sirutaFilter);
if (map.getLayer("l-ps-terenuri-line"))
map.setFilter("l-ps-terenuri-line", sirutaFilter);
} catch {
/* noop */
}
}
// Hide the base terenuri-fill (we replaced it with enrichment-aware version)
try {
if (map.getLayer("l-terenuri-fill"))
map.setLayoutProperty("l-terenuri-fill", "visibility", "none");
} catch {
/* noop */
}
}, [mapReady, viewsReady, siruta, sirutaValid]);
/* ── Fetch UAT bounds and zoom ─────────────────────────────── */
const prevBoundsSirutaRef = useRef("");
useEffect(() => {
if (!sirutaValid || !siruta) return;
if (prevBoundsSirutaRef.current === siruta) return;
prevBoundsSirutaRef.current = siruta;
setBoundsLoading(true);
fetch(`/api/geoportal/uat-bounds?siruta=${siruta}`)
.then((r) => (r.ok ? r.json() : null))
.then(
(data: {
bounds?: [[number, number], [number, number]];
} | null) => {
if (data?.bounds) {
const [[minLng, minLat], [maxLng, maxLat]] = data.bounds;
const centerLng = (minLng + maxLng) / 2;
const centerLat = (minLat + maxLat) / 2;
setFlyTarget({ center: [centerLng, centerLat], zoom: 13 });
// Fit bounds if map is already ready
const map = asMap(mapHandleRef.current);
if (map) {
map.fitBounds([minLng, minLat, maxLng, maxLat], {
padding: 40,
duration: 1500,
});
}
}
},
)
.catch(() => {})
.finally(() => setBoundsLoading(false));
}, [siruta, sirutaValid]);
/* ── Feature click handler ─────────────────────────────────── */
const handleFeatureClick = useCallback(
(feature: ClickedFeature | null) => {
if (!feature || !feature.properties) {
setClickedFeature(null);
return;
}
setClickedFeature(feature);
},
[],
);
/* ── Selection mode handler ────────────────────────────────── */
const handleSelectionModeChange = useCallback((mode: SelectionMode) => {
if (mode === "off") {
mapHandleRef.current?.clearSelection();
setSelectedFeatures([]);
}
setSelectionMode(mode);
}, []);
/* ── Render ─────────────────────────────────────────────────── */
if (!sirutaValid) {
return (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<MapIcon className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>Selecteaz\u0103 un UAT din lista de mai sus</p>
</CardContent>
</Card>
);
}
return (
<div className="relative h-[600px] rounded-lg border overflow-hidden">
{boundsLoading && (
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-2 rounded-full bg-background/90 border px-3 py-1.5 text-xs shadow-sm">
<Loader2 className="h-3 w-3 animate-spin" />
Se \u00eencarc\u0103 zona UAT...
</div>
)}
<MapViewer
ref={mapHandleRef}
className="h-full w-full"
basemap={basemap}
selectionType={selectionMode}
onFeatureClick={handleFeatureClick}
onSelectionChange={setSelectedFeatures}
layerVisibility={layerVisibility}
center={flyTarget?.center}
zoom={flyTarget?.zoom}
/>
{/* Top-right: basemap switcher + feature panel */}
<div className="absolute top-3 right-3 z-10 flex flex-col items-end gap-2">
<BasemapSwitcher value={basemap} onChange={setBasemap} />
{clickedFeature && selectionMode === "off" && (
<FeatureInfoPanel
feature={clickedFeature}
onClose={() => setClickedFeature(null)}
/>
)}
</div>
{/* Bottom-left: selection toolbar */}
<div className="absolute bottom-8 left-3 z-10">
<SelectionToolbar
selectedFeatures={selectedFeatures}
selectionMode={selectionMode}
onSelectionModeChange={handleSelectionModeChange}
onClearSelection={() => {
mapHandleRef.current?.clearSelection();
setSelectedFeatures([]);
}}
/>
</div>
{/* Bottom-right: legend */}
<div className="absolute bottom-3 right-3 z-10 rounded-lg bg-background/90 border p-2 text-[10px] space-y-1">
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm border" style={{ backgroundColor: "rgba(134,239,172,0.25)", borderColor: "#15803d" }} />
F\u0103r\u0103 enrichment
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm border" style={{ backgroundColor: "rgba(21,128,61,0.25)", borderColor: "#15803d" }} />
Cu enrichment
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm" style={{ border: "2px solid #3b82f6" }} />
Cu cl\u0103dire
</div>
<div className="flex items-center gap-1.5">
<span className="inline-block h-3 w-3 rounded-sm" style={{ border: "2px solid #ef4444" }} />
Cl\u0103dire f\u0103r\u0103 acte
</div>
</div>
</div>
);
}