From 99a673de3d5c4d3a4de2a58d9207d27bbb5188e2 Mon Sep 17 00:00:00 2001 From: Claude VM Date: Mon, 18 May 2026 08:32:36 +0300 Subject: [PATCH] feat(geoportal): Faza E v2 thin client (PMTiles + gis.ac) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New geoportal module flag-gated by session.useGisAc. Legacy code path preserved as GeoportalV1Legacy (rename only — same logic). When session.useGisAc=true (Infisical USE_GIS_AC=1 OR email in GIS_AC_PILOT_USERS), the page renders GeoportalV2 instead. V2 layout (851 LOC across 5 files): - map-viewer.tsx (~295 LOC): MapLibre + PMTiles src `pmtiles://pmtiles.gis.ac/overview.pmtiles`. Layers: UAT boundaries (z5, z8), parcels (gis_terenuri line + invisible hit-test fill), buildings (gis_cladiri fill+line). Click → resolves layerId from sourceLayer, emits ClickedFeatureLite (id, siruta, cadastralRef, layerId). - search-bar.tsx (~160 LOC): debounced 300ms, calls /api/gis/search, dropdown grouped by UATs / Parcele. - feature-info-panel.tsx (~270 LOC): fetches /api/gis/parcela/[id], renders enrichment block (scope-aware — 403 shown explicitly as "permisiuni insuficiente"). Buttons: "Citește din ANCPI" (POST /api/gis/parcel/tech force:true), "Export GeoPackage" (deep-link to eterra.live/harta?…&autoexport=geopackage), "Comandă CF" placeholder pending Faza F. - basemap-switcher.tsx (~40 LOC): liberty / dark / satellite / google. Dropped orto + topo50/25 (require ANCPI session — orto/topo via raster.gis.ac is TBD Sprint 2). - geoportal-v2.tsx (~85 LOC): wraps MapViewer + SearchBar + BasemapSwitcher + FeatureInfoPanel. API routes (90 LOC across 3 files): - GET /api/gis/search → gisApi.search - GET /api/gis/parcela/[id] → gisApi.parcela.get - POST /api/gis/parcel/tech → gisApi.parcel.tech (refresh ANCPI) All routes 401 if no NextAuth session, forward GisApiError status+code, hit api.gis.ac with the Authentik access_token from session. Per project_audit_correlation_echo memory: no correlationId set on client side (gis-api overwrites server-side). Cutover-bottom-right badge "gis.ac · v2" visible until full rollout for ops visibility. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/gis/parcel/tech/route.ts | 40 +++ src/app/api/gis/parcela/[id]/route.ts | 34 ++ src/app/api/gis/search/route.ts | 35 +++ .../geoportal/components/geoportal-module.tsx | 14 + src/modules/geoportal/v2/basemap-switcher.tsx | 41 +++ .../geoportal/v2/feature-info-panel.tsx | 270 ++++++++++++++++ src/modules/geoportal/v2/geoportal-v2.tsx | 85 +++++ src/modules/geoportal/v2/map-viewer.tsx | 295 ++++++++++++++++++ src/modules/geoportal/v2/search-bar.tsx | 160 ++++++++++ 9 files changed, 974 insertions(+) create mode 100644 src/app/api/gis/parcel/tech/route.ts create mode 100644 src/app/api/gis/parcela/[id]/route.ts create mode 100644 src/app/api/gis/search/route.ts create mode 100644 src/modules/geoportal/v2/basemap-switcher.tsx create mode 100644 src/modules/geoportal/v2/feature-info-panel.tsx create mode 100644 src/modules/geoportal/v2/geoportal-v2.tsx create mode 100644 src/modules/geoportal/v2/map-viewer.tsx create mode 100644 src/modules/geoportal/v2/search-bar.tsx diff --git a/src/app/api/gis/parcel/tech/route.ts b/src/app/api/gis/parcel/tech/route.ts new file mode 100644 index 0000000..1194c6f --- /dev/null +++ b/src/app/api/gis/parcel/tech/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server"; +import { getAuthSession } from "@/core/auth/require-auth"; +import { gisApi, GisApiError, type ParcelRefBody } from "@/lib/gis-api-client"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + const session = await getAuthSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: ParcelRefBody; + try { + body = (await request.json()) as ParcelRefBody; + } catch { + return NextResponse.json({ error: "invalid_body" }, { status: 400 }); + } + + if (!body?.siruta || !body?.cadastralRef) { + return NextResponse.json( + { error: "missing_fields", required: ["siruta", "cadastralRef"] }, + { status: 400 }, + ); + } + + try { + const data = await gisApi.parcel.tech(body); + return NextResponse.json(data); + } catch (err) { + if (err instanceof GisApiError) { + return NextResponse.json( + { error: err.code, status: err.status, body: err.body }, + { status: err.status }, + ); + } + return NextResponse.json({ error: "internal_error" }, { status: 500 }); + } +} diff --git a/src/app/api/gis/parcela/[id]/route.ts b/src/app/api/gis/parcela/[id]/route.ts new file mode 100644 index 0000000..3817e5a --- /dev/null +++ b/src/app/api/gis/parcela/[id]/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { getAuthSession } from "@/core/auth/require-auth"; +import { gisApi, GisApiError } from "@/lib/gis-api-client"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await getAuthSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id } = await params; + if (!id) { + return NextResponse.json({ error: "missing_id" }, { status: 400 }); + } + + try { + const data = await gisApi.parcela.get(id); + return NextResponse.json(data); + } catch (err) { + if (err instanceof GisApiError) { + return NextResponse.json( + { error: err.code, status: err.status }, + { status: err.status }, + ); + } + return NextResponse.json({ error: "internal_error" }, { status: 500 }); + } +} diff --git a/src/app/api/gis/search/route.ts b/src/app/api/gis/search/route.ts new file mode 100644 index 0000000..473a4ef --- /dev/null +++ b/src/app/api/gis/search/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { getAuthSession } from "@/core/auth/require-auth"; +import { gisApi, GisApiError } from "@/lib/gis-api-client"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const session = await getAuthSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const q = searchParams.get("q")?.trim() ?? ""; + const limitRaw = Number(searchParams.get("limit") ?? "20"); + const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, 100) : 20; + + if (q.length < 2) { + return NextResponse.json({ q, uats: [], features: [] }); + } + + try { + const data = await gisApi.search(q, limit); + return NextResponse.json(data); + } catch (err) { + if (err instanceof GisApiError) { + return NextResponse.json( + { error: err.code, status: err.status }, + { status: err.status }, + ); + } + return NextResponse.json({ error: "internal_error" }, { status: 500 }); + } +} diff --git a/src/modules/geoportal/components/geoportal-module.tsx b/src/modules/geoportal/components/geoportal-module.tsx index 15537b7..f997af8 100644 --- a/src/modules/geoportal/components/geoportal-module.tsx +++ b/src/modules/geoportal/components/geoportal-module.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useCallback } from "react"; import dynamic from "next/dynamic"; +import { useSession } from "next-auth/react"; import { LayerPanel, getDefaultVisibility } from "./layer-panel"; import { BasemapSwitcher } from "./basemap-switcher"; import { SearchBar } from "./search-bar"; @@ -12,6 +13,7 @@ import type { MapViewerHandle } from "./map-viewer"; import type { BasemapId, ClickedFeature, LayerVisibility, SearchResult, SelectedFeature, } from "../types"; +import { GeoportalV2 } from "../v2/geoportal-v2"; const MapViewer = dynamic( () => import("./map-viewer").then((m) => ({ default: m.MapViewer })), @@ -26,6 +28,18 @@ const MapViewer = dynamic( ); export function GeoportalModule() { + // Faza C cutover — branch on session.useGisAc (server-decided per request, + // exposed via NextAuth session callback in src/core/auth/auth-options.ts). + // Flag flip = Infisical USE_GIS_AC=1 + container force-recreate. + const { data: session } = useSession(); + const useGisAc = Boolean((session as { useGisAc?: boolean } | null)?.useGisAc); + if (useGisAc) { + return ; + } + return ; +} + +function GeoportalV1Legacy() { const mapHandleRef = useRef(null); const [basemap, setBasemap] = useState("liberty"); const [layerVisibility, setLayerVisibility] = useState(getDefaultVisibility); diff --git a/src/modules/geoportal/v2/basemap-switcher.tsx b/src/modules/geoportal/v2/basemap-switcher.tsx new file mode 100644 index 0000000..a96a89a --- /dev/null +++ b/src/modules/geoportal/v2/basemap-switcher.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { cn } from "@/shared/lib/utils"; + +export type BasemapId = "liberty" | "dark" | "satellite" | "google"; + +const OPTIONS: Array<{ id: BasemapId; label: string }> = [ + { id: "liberty", label: "Liberty" }, + { id: "dark", label: "Întunecat" }, + { id: "satellite", label: "Satelit" }, + { id: "google", label: "Google" }, +]; + +interface Props { + value: BasemapId; + onChange: (id: BasemapId) => void; +} + +export function BasemapSwitcher({ value, onChange }: Props) { + return ( +
+
+ {OPTIONS.map((opt) => ( + + ))} +
+
+ ); +} diff --git a/src/modules/geoportal/v2/feature-info-panel.tsx b/src/modules/geoportal/v2/feature-info-panel.tsx new file mode 100644 index 0000000..f04bd76 --- /dev/null +++ b/src/modules/geoportal/v2/feature-info-panel.tsx @@ -0,0 +1,270 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + X, RefreshCw, Loader2, FileText, Download, AlertCircle, +} from "lucide-react"; +import { cn } from "@/shared/lib/utils"; + +export interface ClickedFeatureLite { + id: string; + siruta: string; + cadastralRef: string; + layerId: string; + areaValue?: number; +} + +interface ParcelDetail { + id: string; + layerId?: string; + siruta?: string; + cadastralRef?: string; + areaValue?: number; + isActive?: boolean; + enrichment?: Record; + enrichedAt?: string; + [k: string]: unknown; +} + +interface Props { + feature: ClickedFeatureLite; + onClose: () => void; +} + +const LABEL = (key: string): string => { + const map: Record = { + PROPRIETARI: "Proprietari", + PROPRIETARI_VECHI: "Proprietari vechi", + NR_CF: "Nr. CF", + NR_CF_VECHI: "Nr. CF vechi", + DOC: "Documente", + SUPRAFATA: "Suprafață", + CATEGORIE_FOLOSINTA: "Categorie folosință", + INTRAVILAN: "Intravilan", + UAT: "UAT", + UAT_SIRUTA: "SIRUTA", + }; + return map[key] ?? key; +}; + +function formatValue(val: unknown): string { + if (val == null) return "-"; + if (Array.isArray(val)) return val.join(", "); + if (typeof val === "object") return JSON.stringify(val); + return String(val); +} + +export function FeatureInfoPanel({ feature, onClose }: Props) { + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + + // Fetch detail when feature changes + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + setDetail(null); + fetch(`/api/gis/parcela/${encodeURIComponent(feature.id)}`) + .then(async (res) => { + if (cancelled) return; + if (res.status === 403) { + setError("forbidden"); + return; + } + if (!res.ok) { + setError("fetch_failed"); + return; + } + const data: ParcelDetail = await res.json(); + if (!cancelled) setDetail(data); + }) + .catch(() => { + if (!cancelled) setError("network_error"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [feature.id]); + + const refreshFromAncpi = async () => { + setRefreshing(true); + setError(null); + try { + const res = await fetch("/api/gis/parcel/tech", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + siruta: feature.siruta, + cadastralRef: feature.cadastralRef, + force: true, + }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + setError(body.error || "refresh_failed"); + return; + } + // Re-fetch parcela detail + const updated = await fetch( + `/api/gis/parcela/${encodeURIComponent(feature.id)}`, + ); + if (updated.ok) setDetail(await updated.json()); + } catch { + setError("network_error"); + } finally { + setRefreshing(false); + } + }; + + const exportGpkg = () => { + const url = `https://eterra.live/harta?siruta=${encodeURIComponent( + feature.siruta, + )}&cad=${encodeURIComponent(feature.cadastralRef)}&autoexport=geopackage`; + window.open(url, "_blank", "noopener,noreferrer"); + }; + + const enrichment = (detail?.enrichment ?? {}) as Record; + const enrichmentEntries = Object.entries(enrichment).filter( + ([, v]) => v != null && (Array.isArray(v) ? v.length > 0 : v !== ""), + ); + + return ( +
+
+
+
+ {feature.cadastralRef} +
+
+ {feature.layerId.replace("_ACTIVE", "").toLowerCase()} + {feature.areaValue != null && ( + · {feature.areaValue.toFixed(0)} m² + )} +
+
+ +
+ +
+ {loading && ( +
+ + Se încarcă… +
+ )} + + {error === "forbidden" && ( +
+ + Nu ai permisiuni de citire detaliată (scope insuficient). +
+ )} + + {error && error !== "forbidden" && ( +
+ + Eroare: {error} +
+ )} + + {detail && !loading && ( + <> +
+
+
SIRUTA
+
{detail.siruta ?? "-"}
+
+
+
Suprafață
+
+ {detail.areaValue != null + ? `${detail.areaValue.toFixed(0)} m²` + : "-"} +
+
+
+
Activă
+
{detail.isActive === false ? "nu" : "da"}
+
+
+ + {enrichmentEntries.length > 0 && ( + <> +
+
+ Date eTerra +
+
+ {enrichmentEntries.map(([k, v]) => ( +
+
+ {LABEL(k)} +
+
+ {formatValue(v)} +
+
+ ))} +
+ + )} + + {enrichmentEntries.length === 0 && ( +
+ Fără date eTerra. Apasă „Citește din ANCPI" pentru a încărca. +
+ )} + + )} +
+ +
+ + + +
+
+ ); +} diff --git a/src/modules/geoportal/v2/geoportal-v2.tsx b/src/modules/geoportal/v2/geoportal-v2.tsx new file mode 100644 index 0000000..c248ed9 --- /dev/null +++ b/src/modules/geoportal/v2/geoportal-v2.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useRef, useState, useCallback } from "react"; +import dynamic from "next/dynamic"; +import { BasemapSwitcher, type BasemapId } from "./basemap-switcher"; +import { SearchBar, type FeatureHit, type UatHit } from "./search-bar"; +import { + FeatureInfoPanel, + type ClickedFeatureLite, +} from "./feature-info-panel"; +import type { MapViewerHandle } from "./map-viewer"; + +const MapViewer = dynamic( + () => import("./map-viewer").then((m) => ({ default: m.MapViewer })), + { + ssr: false, + loading: () => ( +
+

Se încarcă harta…

+
+ ), + }, +); + +export function GeoportalV2() { + const mapRef = useRef(null); + const [basemap, setBasemap] = useState("liberty"); + const [clicked, setClicked] = useState(null); + + const handleFeatureClick = useCallback((f: ClickedFeatureLite | null) => { + setClicked(f); + }, []); + + const handleUatSelect = useCallback((uat: UatHit) => { + // Search response doesn't carry bbox/centroid yet — leave panel + let user pan. + // Future: enrich search response with uat bounding box, then flyTo. + console.info("UAT selected:", uat.siruta, uat.name); + }, []); + + const handleFeatureSelect = useCallback((f: FeatureHit) => { + // Show panel directly (feature ID is known) + setClicked({ + id: f.id, + siruta: "", + cadastralRef: f.cadastralRef, + layerId: f.layerId, + areaValue: f.areaValue, + }); + }, []); + + return ( +
+ + + {/* Top-left: search */} +
+ +
+ + {/* Top-right: basemap + panel */} +
+ + {clicked && ( + setClicked(null)} + /> + )} +
+ + {/* Bottom-right: cutover badge (visible until full rollout) */} +
+ gis.ac · v2 +
+
+ ); +} diff --git a/src/modules/geoportal/v2/map-viewer.tsx b/src/modules/geoportal/v2/map-viewer.tsx new file mode 100644 index 0000000..246372c --- /dev/null +++ b/src/modules/geoportal/v2/map-viewer.tsx @@ -0,0 +1,295 @@ +"use client"; + +import { + useEffect, useRef, useState, useImperativeHandle, forwardRef, useCallback, +} from "react"; +import maplibregl from "maplibre-gl"; +import { Protocol as PmtilesProtocol } from "pmtiles"; +import { cn } from "@/shared/lib/utils"; +import type { BasemapId } from "./basemap-switcher"; +import type { ClickedFeatureLite } from "./feature-info-panel"; + +// Ensure MapLibre CSS loaded (static import fails in standalone build) +if (typeof document !== "undefined") { + const LINK_ID = "maplibre-gl-css"; + if (!document.getElementById(LINK_ID)) { + const link = document.createElement("link"); + link.id = LINK_ID; + link.rel = "stylesheet"; + link.href = "/maplibre-gl.css"; + document.head.appendChild(link); + } +} + +// Register PMTiles protocol once +if (typeof window !== "undefined") { + const w = window as typeof window & { __pmtilesRegistered?: boolean }; + if (!w.__pmtilesRegistered) { + const proto = new PmtilesProtocol(); + maplibregl.addProtocol("pmtiles", proto.tile); + w.__pmtilesRegistered = true; + } +} + +const DEFAULT_CENTER: [number, number] = [25.0, 46.0]; +const DEFAULT_ZOOM = 6; + +const PMTILES_URL = + process.env.NEXT_PUBLIC_PMTILES_URL || + "https://pmtiles.gis.ac/overview.pmtiles"; + +type BasemapDef = + | { type: "style"; url: string } + | { type: "raster"; tiles: string[]; attribution: string; tileSize: number; maxzoom?: number }; + +const BASEMAPS: Record = { + liberty: { type: "style", url: "https://tiles.openfreemap.org/styles/liberty" }, + dark: { type: "style", url: "https://tiles.openfreemap.org/styles/dark" }, + satellite: { + type: "raster", + tiles: [ + "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", + ], + attribution: '© Esri, Maxar', + tileSize: 256, + }, + google: { + type: "raster", + tiles: ["https://mt0.google.com/vt/lyrs=s&x={x}&y={y}&z={z}"], + attribution: "© Google", + tileSize: 256, + maxzoom: 20, + }, +}; + +const PM_SRC = "gis-pmtiles"; + +function buildStyle(b: BasemapDef): maplibregl.StyleSpecification | string { + if (b.type === "style") return b.url; + return { + version: 8 as const, + sources: { + basemap: { + type: "raster" as const, + tiles: b.tiles, + tileSize: b.tileSize, + attribution: b.attribution, + }, + }, + layers: [ + { + id: "basemap-tiles", + type: "raster" as const, + source: "basemap", + minzoom: 0, + maxzoom: b.maxzoom ?? 19, + }, + ], + }; +} + +export type MapViewerHandle = { + flyTo: (center: [number, number], zoom?: number) => void; + getMap: () => maplibregl.Map | null; +}; + +interface Props { + basemap: BasemapId; + onFeatureClick: (f: ClickedFeatureLite | null) => void; + className?: string; +} + +export const MapViewer = forwardRef(function MapViewer( + { basemap, onFeatureClick, className }, + ref, +) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const viewStateRef = useRef({ center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM }); + const [ready, setReady] = useState(false); + + const addGisLayers = useCallback((map: maplibregl.Map) => { + if (map.getSource(PM_SRC)) return; + + map.addSource(PM_SRC, { + type: "vector", + url: `pmtiles://${PMTILES_URL}`, + }); + + // UAT boundaries — visible at every zoom, more prominent on low/mid zoom + map.addLayer({ + id: "v2-uats-z5", + type: "line", + source: PM_SRC, + "source-layer": "gis_uats_z5", + minzoom: 0, + maxzoom: 8, + paint: { + "line-color": "#9ca3af", + "line-width": 0.8, + "line-opacity": 0.6, + }, + }); + map.addLayer({ + id: "v2-uats-z8", + type: "line", + source: PM_SRC, + "source-layer": "gis_uats_z8", + minzoom: 8, + maxzoom: 13, + paint: { + "line-color": "#6b7280", + "line-width": 1.2, + "line-opacity": 0.7, + }, + }); + + // Buildings — fill + outline + map.addLayer({ + id: "v2-cladiri-fill", + type: "fill", + source: PM_SRC, + "source-layer": "gis_cladiri", + minzoom: 13, + paint: { + "fill-color": "#fb923c", + "fill-opacity": 0.25, + }, + }); + map.addLayer({ + id: "v2-cladiri-line", + type: "line", + source: PM_SRC, + "source-layer": "gis_cladiri", + minzoom: 13, + paint: { + "line-color": "#ea580c", + "line-width": 0.6, + }, + }); + + // Parcels (terenuri) — line outline only + map.addLayer({ + id: "v2-terenuri-line", + type: "line", + source: PM_SRC, + "source-layer": "gis_terenuri", + minzoom: 13, + paint: { + "line-color": "#0ea5e9", + "line-width": 0.8, + "line-opacity": 0.85, + }, + }); + // Invisible fill for click hit-testing + map.addLayer({ + id: "v2-terenuri-hit", + type: "fill", + source: PM_SRC, + "source-layer": "gis_terenuri", + minzoom: 13, + paint: { + "fill-color": "#0ea5e9", + "fill-opacity": 0.001, + }, + }); + }, []); + + // Initialize / rebuild on basemap change + useEffect(() => { + if (!containerRef.current) return; + const def = BASEMAPS[basemap]; + setReady(false); + + const map = new maplibregl.Map({ + container: containerRef.current, + style: buildStyle(def), + center: viewStateRef.current.center, + zoom: viewStateRef.current.zoom, + maxZoom: 19, + attributionControl: { compact: true }, + }); + + mapRef.current = map; + + map.on("moveend", () => { + const c = map.getCenter(); + viewStateRef.current = { + center: [c.lng, c.lat], + zoom: map.getZoom(), + }; + }); + + map.on("load", () => { + addGisLayers(map); + setReady(true); + }); + + // Click handler — prioritize terenuri, then cladiri + const onClick = (e: maplibregl.MapMouseEvent) => { + const feats = map.queryRenderedFeatures(e.point, { + layers: ["v2-terenuri-hit", "v2-cladiri-fill"], + }); + if (feats.length === 0) { + onFeatureClick(null); + return; + } + const f = feats[0]; + if (!f?.properties) { + onFeatureClick(null); + return; + } + const p = f.properties as Record; + const id = (p.id as string) ?? ""; + const siruta = String(p.siruta ?? ""); + const cadastralRef = String(p.cadastral_ref ?? ""); + const sourceLayer = + (f as unknown as { sourceLayer?: string }).sourceLayer ?? ""; + const layerId = + sourceLayer === "gis_cladiri" ? "CLADIRI_ACTIVE" : "TERENURI_ACTIVE"; + if (!id || !cadastralRef) { + onFeatureClick(null); + return; + } + onFeatureClick({ + id, + siruta, + cadastralRef, + layerId, + areaValue: + typeof p.area_value === "number" ? (p.area_value as number) : undefined, + }); + }; + map.on("click", onClick); + map.getCanvas().style.cursor = "default"; + + return () => { + map.off("click", onClick); + map.remove(); + mapRef.current = null; + }; + }, [basemap, addGisLayers, onFeatureClick]); + + useImperativeHandle( + ref, + () => ({ + flyTo: (center, zoom) => { + mapRef.current?.flyTo({ + center, + zoom: zoom ?? mapRef.current.getZoom(), + essential: true, + }); + }, + getMap: () => mapRef.current, + }), + [], + ); + + return ( +
+ ); +}); diff --git a/src/modules/geoportal/v2/search-bar.tsx b/src/modules/geoportal/v2/search-bar.tsx new file mode 100644 index 0000000..e4c1b25 --- /dev/null +++ b/src/modules/geoportal/v2/search-bar.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useState, useCallback, useRef, useEffect } from "react"; +import { Search, Loader2, MapPin, Hash } from "lucide-react"; +import { cn } from "@/shared/lib/utils"; + +export interface UatHit { + siruta: string; + name: string; + county: string; +} + +export interface FeatureHit { + id: string; + layerId: string; + cadastralRef: string; + areaValue?: number; +} + +interface SearchResponse { + q: string; + uats?: UatHit[]; + features?: FeatureHit[]; + error?: string; +} + +interface Props { + onUatSelect: (uat: UatHit) => void; + onFeatureSelect: (feature: FeatureHit) => void; +} + +export function SearchBar({ onUatSelect, onFeatureSelect }: Props) { + const [q, setQ] = useState(""); + const [loading, setLoading] = useState(false); + const [results, setResults] = useState(null); + const [open, setOpen] = useState(false); + const debounceRef = useRef | null>(null); + const wrapperRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + const onClick = (e: MouseEvent) => { + if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", onClick); + return () => document.removeEventListener("mousedown", onClick); + }, []); + + const runSearch = useCallback(async (query: string) => { + if (query.trim().length < 2) { + setResults(null); + return; + } + setLoading(true); + try { + const res = await fetch( + `/api/gis/search?q=${encodeURIComponent(query)}&limit=10`, + ); + const data: SearchResponse = await res.json(); + setResults(data); + setOpen(true); + } catch { + setResults({ q: query, error: "search_failed" }); + } finally { + setLoading(false); + } + }, []); + + const handleChange = (e: React.ChangeEvent) => { + const val = e.target.value; + setQ(val); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => runSearch(val), 300); + }; + + const totalHits = + (results?.uats?.length ?? 0) + (results?.features?.length ?? 0); + + return ( +
+
+ + results && setOpen(true)} + placeholder="Caută parcelă sau UAT…" + className="w-full rounded-md border bg-background/95 py-1.5 pl-8 pr-8 text-sm shadow-sm backdrop-blur focus:outline-none focus:ring-1 focus:ring-primary" + /> + {loading && ( + + )} +
+ + {open && results && totalHits > 0 && ( +
+ {results.uats && results.uats.length > 0 && ( +
+
+ UAT-uri +
+ {results.uats.map((u) => ( + + ))} +
+ )} + {results.features && results.features.length > 0 && ( +
+
+ Parcele +
+ {results.features.map((f) => ( + + ))} +
+ )} +
+ )} + + {open && results && totalHits === 0 && !loading && ( +
+ {results.error ? "Eroare la căutare" : "Niciun rezultat"} +
+ )} +
+ ); +}