From 4f38fd1070203e0f407a31b3495930de9ee3878e Mon Sep 17 00:00:00 2001 From: Claude VM Date: Wed, 20 May 2026 06:57:27 +0300 Subject: [PATCH] feat(geoportal-v2): compact eterra.live-style layout + buildings list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Significant restructure of the parcel info panel based on Marius's side-by-side comparison with eterra.live. Same data + same workflow, much denser layout. Layout changes: 1. HEADER — status dot + cadref + small uat/area/SIRUTA line. Removed the redundant "Activ"/"Inactiv" chip (the dot is the signal). Building suffixes (C1, C2…) still resolve via the search-by-cadref path; header shows the full cadref ("354686-C1") so the user sees both parent and suffix. 2. CARACTERISTICI — chips row only; tighter padding (px-2 py-1.5 vs px-3 py-2.5). Same intravilan / categorie / corpuri. 3. METRIC STRIP — new. Three cells in a single divided pill: GIS / 2D eTerra / Legală. Same pattern eterra.live uses. Saves a whole section worth of vertical space. 4. DATE ETERRA CARD — wrapped in a bordered subtle-bg container with the refresh button INLINE in the section header (vs at the bottom of the panel). Shows "acum X min" relative time when enriched. Two-column NR. CF + Nr. topo. Adresă with pin icon. Solicitant with user icon. Proprietari as InfoBlock (multi-line preserved). Foști proprietari as
collapsible (closed by default). Înscriere group (tip / data / act) as a small subsection. 5. CONSTRUCȚII LIST — new. For TERENURI parcels, fetches the building siblings via gisApi.search(parentCadref) + filter on "-" prefix. Renders BuildingRow per cladire: - Icon (Home / Building2 / Factory / Warehouse from destinatie) - C1/C2/C3… suffix (mono, font-semibold) - Area - "Cu acte" (green) / "Fără acte" (amber) / "Necunoscut" pill from BUILD_LEGAL / PARCEL_HAS_LANDBOOK enrichment Click row → onSelectFeature switches panel to that building. Lazy isLegal hydration: row first shows "Necunoscut", then parallel parcela.get for each building fills the pill (5ms per cache hit, no blocking). 6. APARTAMENTE — same content as before (for CLADIRI clicks), now sits beside Construcții in the same flow. Header consistent with the other section labels. 7. LOCALIZARE — moved to a single tight strip (lat/lng + Google Maps link). Removed the SIRUTA repetition since it's already in the header. 8. ACTIONS toolbar — compressed. Removed the in-toolbar "Citește din ANCPI" button since the refresh button is now inside the Date eTerra card header (where it belongs contextually). Kept Export GPKG + Comandă CF. GeoportalV2 wires onSelectFeature={setClicked} so building rows propagate. Building clicks reuse the same panel + same auto-enrich flow — the second feature.layerId === CLADIRI_ACTIVE branch in the condo-owners useEffect kicks in for buildings. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../geoportal/v2/feature-info-panel.tsx | 1159 +++++++++-------- src/modules/geoportal/v2/geoportal-v2.tsx | 1 + 2 files changed, 646 insertions(+), 514 deletions(-) diff --git a/src/modules/geoportal/v2/feature-info-panel.tsx b/src/modules/geoportal/v2/feature-info-panel.tsx index a07697f..009a634 100644 --- a/src/modules/geoportal/v2/feature-info-panel.tsx +++ b/src/modules/geoportal/v2/feature-info-panel.tsx @@ -4,26 +4,21 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { signIn } from "next-auth/react"; import { X, RefreshCw, Loader2, FileText, Download, AlertCircle, - Home, Building, MapPin, ChevronDown, ChevronRight, Users, + Home, Building, Building2, MapPin, ChevronRight, Users, + Sparkles, User, ShieldCheck, AlertTriangle, HelpCircle, + Factory, Warehouse, } from "lucide-react"; import { cn } from "@/shared/lib/utils"; -// Once-per-tab marker: when we trigger a silent re-grant via signIn() the -// page reloads through Authentik. After the round-trip we land back on the -// same panel; we don't want to retry the re-grant in an infinite loop if -// Authentik actually can't extend the scope, so we set a sessionStorage -// flag before redirecting and check it on mount. const AUTH_RETRY_KEY = "gis_panel_auth_retry"; export interface ClickedFeatureLite { - /** GisFeature uuid — typically empty from PMTiles overview, falls back to search-by-cadref */ id: string; objectId?: string; siruta: string; cadastralRef: string; layerId: string; areaValue?: number; - /** Click point lat/lng — used by Localizare section */ lat?: number; lng?: number; } @@ -33,6 +28,7 @@ interface ParcelDetail { layerId?: string; siruta?: string; cadastralRef?: string; + objectId?: number | string; areaValue?: number; isActive?: boolean; enrichment?: Record; @@ -40,6 +36,16 @@ interface ParcelDetail { [k: string]: unknown; } +interface BuildingItem { + id?: string; + cadastralRef: string; + areaValue?: number; + isLegal?: number | null; + destinatie?: string | null; + isCondo?: boolean; + unitCount?: number; +} + interface CondoOwner { unitNo?: string; apartmentNo?: string; @@ -52,39 +58,33 @@ interface CondoOwner { interface Props { feature: ClickedFeatureLite; onClose: () => void; - /** When true, render only header + cadastralRef + suprafață (restricted users) */ + /** Switch the panel to a different feature (e.g., user clicked a + * building row from the parcel's buildings list). Mirrors map-click + * behaviour but without going through the map's queryRenderedFeatures. */ + onSelectFeature?: (f: ClickedFeatureLite) => void; basic?: boolean; } -// Human-readable labels for every enrichment key the orchestrator -// exposes. Anything not in this map renders the raw key (last-resort -// — should always be filled in). const LABEL: Record = { - // CF / titlu - NR_CF: "Carte funciară", + NR_CF: "Nr. CF", NR_CF_VECHI: "CF vechi", PARCEL_LANDBOOK_NO: "Nr. CF (tehnic)", ACT_PROPRIETATE: "Act de proprietate", - // Cadastru NR_CAD: "Nr. cadastral", NR_TOPO: "Nr. topografic", PARCEL_TOPO_NO: "Nr. topo (alt)", - // Suprafețe SUPRAFATA: "Suprafață", SUPRAFATA_R: "Suprafață reală", SUPRAFATA_2D: "Suprafață 2D", PARCEL_LEGAL_AREA: "Suprafață legală", - // Adresă ADRESA: "Adresă", PARCEL_POSTAL_NO: "Nr. poștal", - // Proprietari + înscriere PROPRIETARI: "Proprietari", PROPRIETARI_VECHI: "Proprietari anteriori", SOLICITANT: "Solicitant", DATA_CERERE: "Data cererii", TIP_INSCRIERE: "Tip înscriere", DOC: "Documente", - // Caracteristici CATEGORIE_FOLOSINTA: "Categorie folosință", INTRAVILAN: "Intravilan", HAS_BUILDING: "Are clădire", @@ -95,42 +95,12 @@ const LABEL: Record = { PARCEL_IS_CONDOMINIUM: "Condominium", TARLA: "Tarla", PARCELA: "Parcelă", - PARCELE_DETAILS: "Parcele (detalii)", - // UAT UAT: "UAT", UAT_SIRUTA: "SIRUTA", - // Tehnic PARCEL_TECH_ENRICHED_AT: "Actualizat tehnic", }; -// Keys rendered in CARACTERISTICI chips at the top — never repeated in -// the structured sections below. -const CARACTERISTICI_KEYS = new Set([ - "INTRAVILAN", - "CATEGORIE_FOLOSINTA", - "HAS_BUILDING", - "BUILD_LEGAL", - "NR_CORPURI", - "NR_CORPURI_LEGALE", - "UAT", - "UAT_SIRUTA", - "PARCELE_DETAILS", -]); - -// Section definitions — ordered top→bottom in the panel. Keys not -// listed anywhere fall into TEHNIC (collapsed by default). -const SECTIONS: Array<{ id: string; title: string; keys: readonly string[] }> = [ - { id: "cf", title: "Carte funciară", keys: ["NR_CF", "NR_CF_VECHI", "PARCEL_LANDBOOK_NO", "ACT_PROPRIETATE"] }, - { id: "adresa", title: "Adresă", keys: ["ADRESA", "PARCEL_POSTAL_NO"] }, - { id: "proprietari", title: "Proprietari", keys: ["PROPRIETARI", "PROPRIETARI_VECHI"] }, - { id: "cadastru", title: "Cadastru", keys: ["NR_CAD", "NR_TOPO", "PARCEL_TOPO_NO"] }, - { id: "suprafete", title: "Suprafețe", keys: ["SUPRAFATA_R", "SUPRAFATA_2D", "PARCEL_LEGAL_AREA", "SUPRAFATA"] }, - { id: "inscriere", title: "Înscriere", keys: ["SOLICITANT", "TIP_INSCRIERE", "DATA_CERERE", "DOC"] }, -]; - -const SECTION_KEYS = new Set(SECTIONS.flatMap((s) => s.keys)); - -const PII_KEYS = new Set(["PROPRIETARI", "PROPRIETARI_VECHI", "NR_CF", "NR_CF_VECHI", "DOC"]); +// ────────────────────────────────────────────────────────── formatters function formatNum(v: unknown, fractionDigits = 0): string { if (typeof v !== "number" || !Number.isFinite(v)) return String(v ?? "-"); @@ -140,90 +110,55 @@ function formatNum(v: unknown, fractionDigits = 0): string { }); } -function formatValue(val: unknown): string { - if (val == null || val === "") return "-"; - if (Array.isArray(val)) return val.join(", "); - if (typeof val === "object") return JSON.stringify(val); - return String(val); -} - -// Suprafețe: parse "456" / "456.06" / "456 mp" → number + "m²" suffix. -function formatAreaValue(val: unknown): string { - if (val == null || val === "") return "-"; - const n = - typeof val === "number" - ? val - : typeof val === "string" - ? parseFloat(val.replace(/[^\d.,-]/g, "").replace(",", ".")) - : NaN; - if (!Number.isFinite(n)) return formatValue(val); - const d = n % 1 === 0 ? 0 : 2; - return `${n.toLocaleString("ro-RO", { minimumFractionDigits: d, maximumFractionDigits: d })} m²`; -} - -// Boolean-ish flag values (0/1, "yes"/"no", "da"/"nu") → friendly label. -function formatFlag(val: unknown): string { - const s = String(val ?? "").trim().toLowerCase(); - if (s === "1" || s === "true" || s === "yes" || s === "da") return "Da"; - if (s === "0" || s === "false" || s === "no" || s === "nu") return "Nu"; - return formatValue(val); -} - -// Romanian-locale date for ISO timestamp strings; falls through for -// values that don't parse. -function formatDate(val: unknown): string { - if (typeof val !== "string") return formatValue(val); - const d = new Date(val); - if (Number.isNaN(d.getTime())) return val; - return d.toLocaleString("ro-RO"); -} - -// Split "MATHBOUT MOHAMED MAHER, DR." / "OWNER1; OWNER2" / array → list. -function parseOwners(val: unknown): string[] { - if (val == null || val === "") return []; - if (Array.isArray(val)) return val.map((s) => String(s).trim()).filter(Boolean); +function parseArea(val: unknown): number | null { + if (val == null || val === "") return null; + if (typeof val === "number") return Number.isFinite(val) ? val : null; if (typeof val === "string") { - return val.split(/\s*[;|]\s*|\s*,\s*(?=[A-ZȘȚĂÎÂ])/).map((s) => s.trim()).filter(Boolean); + const n = parseFloat(val.replace(/[^\d.,-]/g, "").replace(",", ".")); + return Number.isFinite(n) ? n : null; } - return [String(val)]; + return null; } -// Per-key value renderer with sensible defaults. -function renderValue(key: string, val: unknown): React.ReactNode { - if (val == null || val === "" || (Array.isArray(val) && val.length === 0)) { - return ; - } - if ( - key === "SUPRAFATA_R" || - key === "SUPRAFATA_2D" || - key === "SUPRAFATA" || - key === "PARCEL_LEGAL_AREA" - ) { - return formatAreaValue(val); - } - if ( - key === "PARCEL_HAS_LANDBOOK" || - key === "PARCEL_IS_CONDOMINIUM" || - key === "HAS_BUILDING" || - key === "BUILD_LEGAL" - ) { - return formatFlag(val); - } - if (key === "DATA_CERERE" || key === "PARCEL_TECH_ENRICHED_AT") { - return formatDate(val); - } - if (key === "PROPRIETARI" || key === "PROPRIETARI_VECHI") { - const owners = parseOwners(val); - if (owners.length === 0) return formatValue(val); - return ( -
    - {owners.map((o, i) => ( -
  • {o}
  • - ))} -
- ); - } - return formatValue(val); +function formatArea(val: unknown): string | null { + const n = parseArea(val); + if (n == null) return null; + return `${Math.round(n).toLocaleString("ro-RO")} mp`; +} + +function formatRelativeTime(iso: string | null | undefined): string | null { + if (!iso) return null; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return null; + const secs = Math.max(0, Math.round((Date.now() - d.getTime()) / 1000)); + if (secs < 60) return "acum câteva secunde"; + const mins = Math.round(secs / 60); + if (mins < 60) return `acum ${mins} min`; + const hours = Math.round(mins / 60); + if (hours < 24) return `acum ${hours} ${hours === 1 ? "oră" : "ore"}`; + const days = Math.round(hours / 24); + if (days < 30) return `acum ${days} ${days === 1 ? "zi" : "zile"}`; + return d.toLocaleDateString("ro-RO"); +} + +// Building cadref "354686-C1" → "C1". Top-level parcel → keep full. +function buildingSuffix(cadref: string): string { + const m = /-([^-]+)$/.exec(cadref); + return m ? m[1]! : cadref; +} + +// ────────────────────────────────────────────────────────── primitives + +function StatusDot({ active }: { active: boolean }) { + return ( + + ); } function Chip({ @@ -239,8 +174,8 @@ function Chip({ }) { const toneClass = { default: "border-border bg-background text-foreground", - success: "border-emerald-500/30 bg-emerald-50 text-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-200", - warning: "border-amber-500/30 bg-amber-50 text-amber-900 dark:bg-amber-950/30 dark:text-amber-200", + success: "border-emerald-500/30 bg-emerald-500/10 text-emerald-900 dark:text-emerald-200", + warning: "border-amber-500/30 bg-amber-500/10 text-amber-900 dark:text-amber-200", danger: "border-destructive/30 bg-destructive/10 text-destructive", muted: "border-dashed border-border text-muted-foreground", }[tone]; @@ -248,7 +183,7 @@ function Chip({ @@ -258,43 +193,209 @@ function Chip({ ); } -function SectionHeader({ children }: { children: React.ReactNode }) { +function MetricCell({ + label, + value, + hint, +}: { + label: string; + value: string | null; + hint?: string; +}) { return ( -

- {children} -

- ); -} - -function KeyValue({ k, v }: { k: string; v: React.ReactNode }) { - return ( -
-
{k}
-
{v}
+
+

+ {label} +

+

{value ?? "—"}

); } -export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) { +function InfoRow({ + label, + value, + mono, +}: { + label: string; + value: string | number | null | undefined; + mono?: boolean; +}) { + if (value === null || value === undefined || value === "") return null; + return ( +
+ {label} + + {String(value)} + +
+ ); +} + +function InfoBlock({ + label, + value, +}: { + label: string; + value: string | null | undefined; +}) { + if (!value) return null; + return ( +
+

+ {label} +

+

+ {value} +

+
+ ); +} + +function CollapsibleInfoBlock({ + label, + value, +}: { + label: string; + value: string | null | undefined; +}) { + if (!value) return null; + return ( +
+ + +

+ {label} +

+
+

+ {value} +

+
+ ); +} + +// ────────────────────────────────────────────────────────── buildings + +function buildingIcon(destinatie: string | null | undefined, isCondo?: boolean) { + if (isCondo) return Building2; + const d = (destinatie ?? "").toLowerCase(); + if (/industrial|edilitar|hala|atelier|comerc/.test(d)) return Factory; + if (/anex|magazie|depozit|garaj/.test(d)) return Warehouse; + return Home; +} + +function buildingTipLabel(b: BuildingItem): string { + if (b.isCondo && b.unitCount) { + return `Bloc · ${b.unitCount} ${b.unitCount === 1 ? "unitate" : "unități"}`; + } + if (b.destinatie) return b.destinatie; + return "Construcție"; +} + +function BuildingRow({ + b, + onSelect, +}: { + b: BuildingItem; + onSelect: (b: BuildingItem) => void; +}) { + const Icon = buildingIcon(b.destinatie, b.isCondo); + const status = + b.isLegal === 1 + ? { Icon: ShieldCheck, label: "Cu acte", tone: "ok" as const } + : b.isLegal === 0 + ? { Icon: AlertTriangle, label: "Fără acte", tone: "warn" as const } + : { Icon: HelpCircle, label: "Necunoscut", tone: "muted" as const }; + const areaStr = b.areaValue != null && b.areaValue > 0 + ? `${Math.round(b.areaValue).toLocaleString("ro-RO")} mp` + : null; + return ( + + ); +} + +// ────────────────────────────────────────────────────────── owner parsing + +function parseOwners(val: unknown): string[] { + if (val == null || val === "") return []; + if (Array.isArray(val)) return val.map((s) => String(s).trim()).filter(Boolean); + if (typeof val === "string") { + return val + .split(/\s*[;|]\s*|\s*,\s*(?=[A-ZȘȚĂÎÂ])/) + .map((s) => s.trim()) + .filter(Boolean); + } + return [String(val)]; +} + +// ────────────────────────────────────────────────────────── main panel + +export function FeatureInfoPanel({ feature, onClose, onSelectFeature, basic = false }: Props) { const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [refreshing, setRefreshing] = useState(false); + const [buildings, setBuildings] = useState(null); const [condoOwners, setCondoOwners] = useState(null); const [condoLoading, setCondoLoading] = useState(false); - const [condoExpanded, setCondoExpanded] = useState(false); - // Tracks whether we've already triggered a silent re-grant for this tab, - // so a real Authentik scope misconfig falls through to the muted error - // state instead of cycling forever through signIn(). + const authRetriedRef = useRef( typeof window !== "undefined" && sessionStorage.getItem(AUTH_RETRY_KEY) === "1", ); const isCladiri = feature.layerId === "CLADIRI_ACTIVE"; + const isTerenuri = feature.layerId === "TERENURI_ACTIVE"; - // Hydrate detail. If tile carries the uuid → fast path. Otherwise look it - // up via /api/gis/search by cadastralRef and pick the match on layerId. + // ── Hydrate detail ────────────────────────────────────── useEffect(() => { if (basic) return; let cancelled = false; @@ -306,11 +407,8 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) { try { let r: Response; if (feature.id) { - // Fast path: tile carried the uuid. r = await fetch(`/api/gis/parcela/${encodeURIComponent(feature.id)}`); } else { - // PMTiles overview: no uuid → use the server-side lookup that - // resolves siruta+cadref+layerId across candidate matches. r = await fetch( `/api/gis/parcela/find?siruta=${encodeURIComponent(feature.siruta)}` + `&cad=${encodeURIComponent(feature.cadastralRef)}` + @@ -319,38 +417,26 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) { } if (cancelled) return; if (r.status === 404) { - // Parcel not in central DB yet — show header only; user can hit "Citește din ANCPI" setLoading(false); return; } if (r.status === 403) { - // Token reaches gis-api but lacks enrichment_scope. Trigger a - // silent fresh OIDC grant — for SSO'd users Authentik is a - // sub-second redirect, the panel re-mounts with full scope and - // shows real data. authRetriedRef guards against infinite loop - // if Authentik genuinely can't grant the scope. if (!authRetriedRef.current && typeof window !== "undefined") { authRetriedRef.current = true; sessionStorage.setItem(AUTH_RETRY_KEY, "1"); void signIn("authentik", { callbackUrl: window.location.href }); - // Keep "loading" while the browser navigates — no message - // needed; the redirect itself is the feedback. return; } setError("forbidden"); setLoading(false); return; } - // Successful fetch (200) — clear the retry marker so a future 403 - // can re-trigger silently. - if (typeof window !== "undefined") { - sessionStorage.removeItem(AUTH_RETRY_KEY); - } if (!r.ok) { setError("fetch_failed"); setLoading(false); return; } + if (typeof window !== "undefined") sessionStorage.removeItem(AUTH_RETRY_KEY); const data = (await r.json()) as ParcelDetail; if (!cancelled) { setDetail(data); @@ -369,7 +455,100 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) { }; }, [feature.id, feature.siruta, feature.cadastralRef, feature.layerId, basic]); - // For CLADIRI parcels, fetch condo owners (apartment list). + // ── Buildings on a parcel ─────────────────────────────── + // For TERENURI parcels: search by parent cadref and filter for + // CLADIRI siblings whose cadref starts with "-". The search + // trigram is loose enough that one call covers all building suffixes + // on the parcel. + useEffect(() => { + if (basic || !isTerenuri || !feature.cadastralRef) return; + let cancelled = false; + setBuildings(null); + const run = async () => { + try { + const r = await fetch( + `/api/gis/search?q=${encodeURIComponent(feature.cadastralRef)}&limit=100`, + ); + if (!r.ok || cancelled) return; + const sd = (await r.json()) as { + features?: Array<{ + id: string; + layerId: string; + cadastralRef: string; + areaValue?: number; + }>; + }; + const prefix = feature.cadastralRef + "-"; + const buildings = (sd.features ?? []) + .filter( + (f) => + f.layerId === "CLADIRI_ACTIVE" && + f.cadastralRef.startsWith(prefix), + ) + .map((f) => ({ + id: f.id, + cadastralRef: f.cadastralRef, + areaValue: f.areaValue, + isLegal: null, + })) + .sort((a, b) => a.cadastralRef.localeCompare(b.cadastralRef)); + if (!cancelled) setBuildings(buildings); + } catch { + if (!cancelled) setBuildings([]); + } + }; + void run(); + return () => { + cancelled = true; + }; + }, [basic, isTerenuri, feature.cadastralRef]); + + // ── Hydrate isLegal/destinatie for each listed building ─ + // After we have the bare list, fetch enrichment for each (parallel) + // so the status pill can show Cu acte / Fără acte. Bounded concurrency + // — gis-api cache-hits are 5ms so even 10 in parallel is fine. + useEffect(() => { + if (!buildings || buildings.length === 0) return; + let cancelled = false; + const needHydrate = buildings.some((b) => b.isLegal === null && b.id); + if (!needHydrate) return; + (async () => { + const next = await Promise.all( + buildings.map(async (b) => { + if (!b.id || b.isLegal !== null) return b; + try { + const r = await fetch(`/api/gis/parcela/${encodeURIComponent(b.id)}`); + if (!r.ok) return b; + const d = (await r.json()) as { enrichment?: Record }; + const e = d.enrichment ?? {}; + const isLegalRaw = e.BUILD_LEGAL ?? e.PARCEL_HAS_LANDBOOK; + const isLegal = + isLegalRaw == null || isLegalRaw === "" + ? null + : Number(isLegalRaw) === 1 + ? 1 + : Number(isLegalRaw) === 0 + ? 0 + : null; + const destinatie = + (typeof e.CATEGORIE_FOLOSINTA === "string" + ? e.CATEGORIE_FOLOSINTA + : null) ?? null; + const isCondo = Number(e.PARCEL_IS_CONDOMINIUM ?? 0) === 1; + return { ...b, isLegal, destinatie, isCondo }; + } catch { + return b; + } + }), + ); + if (!cancelled) setBuildings(next); + })(); + return () => { + cancelled = true; + }; + }, [buildings]); + + // ── Condo owners for buildings ────────────────────────── useEffect(() => { if (basic || !isCladiri || !feature.siruta || !feature.cadastralRef) return; let cancelled = false; @@ -390,7 +569,9 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) { setCondoLoading(false); return; } - const body = (await r.json()) as { data?: { owners?: CondoOwner[] } } | { owners?: CondoOwner[] }; + const body = (await r.json()) as + | { data?: { owners?: CondoOwner[] } } + | { owners?: CondoOwner[] }; const inner = (body as { data?: { owners?: CondoOwner[] } }).data ?? body; const owners = Array.isArray((inner as { owners?: CondoOwner[] }).owners) ? (inner as { owners: CondoOwner[] }).owners @@ -409,97 +590,79 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) { }; }, [isCladiri, feature.siruta, feature.cadastralRef, basic]); - const refreshFromAncpi = useCallback(async (opts: { manual?: boolean } = {}) => { - if (!feature.siruta || !feature.cadastralRef) { - setError("missing_siruta_or_cad"); - return; - } - setRefreshing(true); - setError(null); - try { - // PR3 deep-enrich path: gis-api orchestrates the eTerra round-trip - // and persists NR_CF / ADRESA / PROPRIETARI + tech fields in gis_core - // (30-day cache; force=true bypasses cache). After this returns the - // central record is canonical — we re-fetch it via parcela.get or - // parcela.find so the panel sees what's actually in gis_core. - // - // manualOverride=true is set when the user explicitly pressed the - // "Citește din ANCPI" button (vs the auto-trigger that fires on - // sparse-data load). gis-api/orchestrator can treat this as a - // separate-quota bucket so casual map browsing doesn't starve a - // user who needs to fetch 20-30 specific parcels in a working - // session. Until orchestrator supports it the flag is ignored. - const enrichResp = await fetch("/api/gis/parcel/enrich", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - siruta: feature.siruta, - cadastralRef: feature.cadastralRef, - force: true, - ...(opts.manual ? { manualOverride: true } : {}), - }), - }); - if (!enrichResp.ok) { - const body = await enrichResp.json().catch(() => ({})); - setError(body.error || `enrich_failed_${enrichResp.status}`); + // ── Manual + auto refresh (deep enrich) ───────────────── + const refreshFromAncpi = useCallback( + async (opts: { manual?: boolean } = {}) => { + if (!feature.siruta || !feature.cadastralRef) { + setError("missing_siruta_or_cad"); return; } - const enriched = (await enrichResp.json().catch(() => null)) as - | { siruta?: string; cadastralRef?: string; enrichment?: Record; enrichedAt?: string } - | null; - - // Re-fetch canonical record so the panel matches what other clients - // would see (and so we get isActive / layerId / etc.). - const id = detail?.id ?? feature.id; - let updated: Response | null = null; - if (id) { - updated = await fetch(`/api/gis/parcela/${encodeURIComponent(id)}`); - } else { - updated = await fetch( - `/api/gis/parcela/find?siruta=${encodeURIComponent(feature.siruta)}` + - `&cad=${encodeURIComponent(feature.cadastralRef)}` + - `&layerId=${encodeURIComponent(feature.layerId)}`, - ); - } - if (updated && updated.ok) { - setDetail(await updated.json()); - } else if (enriched?.enrichment) { - // Fallback: project the enrich response directly when the - // canonical re-fetch can't run. - setDetail({ - siruta: enriched.siruta ?? feature.siruta, - cadastralRef: enriched.cadastralRef ?? feature.cadastralRef, - areaValue: feature.areaValue, - layerId: feature.layerId, - enrichment: enriched.enrichment, - enrichedAt: enriched.enrichedAt, + setRefreshing(true); + setError(null); + try { + const enrichResp = await fetch("/api/gis/parcel/enrich", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + siruta: feature.siruta, + cadastralRef: feature.cadastralRef, + force: true, + ...(opts.manual ? { manualOverride: true } : {}), + }), }); + if (!enrichResp.ok) { + const body = await enrichResp.json().catch(() => ({})); + setError(body.error || `enrich_failed_${enrichResp.status}`); + return; + } + const enriched = (await enrichResp.json().catch(() => null)) as + | { siruta?: string; cadastralRef?: string; enrichment?: Record; enrichedAt?: string } + | null; + const id = detail?.id ?? feature.id; + let updated: Response | null = null; + if (id) { + updated = await fetch(`/api/gis/parcela/${encodeURIComponent(id)}`); + } else { + updated = await fetch( + `/api/gis/parcela/find?siruta=${encodeURIComponent(feature.siruta)}` + + `&cad=${encodeURIComponent(feature.cadastralRef)}` + + `&layerId=${encodeURIComponent(feature.layerId)}`, + ); + } + if (updated && updated.ok) { + setDetail(await updated.json()); + } else if (enriched?.enrichment) { + setDetail({ + siruta: enriched.siruta ?? feature.siruta, + cadastralRef: enriched.cadastralRef ?? feature.cadastralRef, + areaValue: feature.areaValue, + layerId: feature.layerId, + enrichment: enriched.enrichment, + enrichedAt: enriched.enrichedAt, + }); + } + } catch { + setError("network_error"); + } finally { + setRefreshing(false); } - } catch { - setError("network_error"); - } finally { - setRefreshing(false); - } - }, [ - feature.id, - feature.siruta, - feature.cadastralRef, - feature.layerId, - feature.areaValue, - detail?.id, - ]); + }, + [ + feature.id, + feature.siruta, + feature.cadastralRef, + feature.layerId, + feature.areaValue, + detail?.id, + ], + ); - // Auto-fetch from ANCPI when the central DB has only tech-level keys - // (no NR_CF / ADRESA / PROPRIETARI). The orchestrator caches its results - // for 30 days so repeat clicks on the same parcel don't re-spend quota. - // Dedupe per parcel-key via sessionStorage so a remount or React - // strict-mode double-fire doesn't double the call. + // Auto-enrich on sparse data useEffect(() => { if (basic || !detail || refreshing) return; const e = (detail.enrichment ?? {}) as Record; const richPresent = Boolean(e.NR_CF || e.ADRESA || e.PROPRIETARI); if (richPresent) return; - const parcelKey = String(detail.id ?? "") || `${feature.siruta}:${feature.cadastralRef}:${feature.layerId}`; @@ -507,7 +670,6 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) { if (typeof sessionStorage === "undefined") return; if (sessionStorage.getItem(ssKey)) return; sessionStorage.setItem(ssKey, "1"); - void refreshFromAncpi(); }, [ basic, @@ -519,126 +681,120 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) { refreshFromAncpi, ]); - 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 orderCf = () => { - const cad = encodeURIComponent(feature.cadastralRef); - const url = `/parcel-sync?tab=epay&cad=${cad}`; - window.open(url, "_blank", "noopener,noreferrer"); + const handleBuildingSelect = (b: BuildingItem) => { + if (!onSelectFeature) return; + onSelectFeature({ + id: b.id ?? "", + siruta: feature.siruta, + cadastralRef: b.cadastralRef, + layerId: "CLADIRI_ACTIVE", + areaValue: b.areaValue, + lat: feature.lat, + lng: feature.lng, + }); }; + // ── derive view-model ─────────────────────────────────── const enrichment = (detail?.enrichment ?? {}) as Record; - - // Build section data + leftover "Tehnic" bucket. Skip empty values - // everywhere so the panel never shows "-" rows. - const isPresent = (v: unknown) => - v != null && v !== "" && !(Array.isArray(v) && v.length === 0); - - const sectionData = useMemo( - () => - SECTIONS.map((s) => ({ - ...s, - rows: s.keys - .map((k) => [k, enrichment[k]] as const) - .filter(([, v]) => isPresent(v)), - })).filter((s) => s.rows.length > 0), + const hasEnrich = useMemo( + () => Object.keys(enrichment).length > 0, [enrichment], ); - const technicalEntries = useMemo( - () => - Object.entries(enrichment).filter( - ([k, v]) => - !CARACTERISTICI_KEYS.has(k) && !SECTION_KEYS.has(k) && isPresent(v), - ), - [enrichment], - ); - - const hasEnrich = sectionData.length > 0 || technicalEntries.length > 0; - const [techExpanded, setTechExpanded] = useState(false); - const nrCf = String(enrichment.NR_CF ?? "").trim(); const nrCfVechi = String(enrichment.NR_CF_VECHI ?? "").trim(); + const nrTopo = + String(enrichment.NR_TOPO ?? "").trim() || + String(enrichment.PARCEL_TOPO_NO ?? "").trim(); const adresa = String(enrichment.ADRESA ?? "").trim(); + const solicitant = String(enrichment.SOLICITANT ?? "").trim(); + const tipInscriere = String(enrichment.TIP_INSCRIERE ?? "").trim(); + const dataCererii = String(enrichment.DATA_CERERE ?? "").trim(); + const proprietari = String(enrichment.PROPRIETARI ?? "").trim(); + const proprietariVechi = String(enrichment.PROPRIETARI_VECHI ?? "").trim(); + const actProp = String(enrichment.ACT_PROPRIETATE ?? "").trim(); - const intravilanRaw = String(enrichment.INTRAVILAN ?? "").trim(); - const intravilanLower = intravilanRaw.toLowerCase(); + const areaGis = feature.areaValue ?? Number(detail?.areaValue ?? 0) ?? 0; + const area2D = formatArea(enrichment.SUPRAFATA_2D); + const areaLegala = + formatArea(enrichment.PARCEL_LEGAL_AREA) ?? formatArea(enrichment.SUPRAFATA_R); + const areaGisStr = areaGis > 0 ? `${Math.round(areaGis).toLocaleString("ro-RO")} mp` : null; + + const intravilanRaw = String(enrichment.INTRAVILAN ?? "").trim().toLowerCase(); const intravilanLabel = - intravilanLower === "da" + intravilanRaw === "da" ? "Intravilan" - : intravilanLower === "nu" + : intravilanRaw === "nu" ? "Extravilan" : hasEnrich ? "Extravilan (presupus)" : null; const intravilanTone = - intravilanLower === "da" + intravilanRaw === "da" ? "success" - : intravilanLower === "nu" || (hasEnrich && intravilanRaw === "") + : intravilanRaw === "nu" || (hasEnrich && intravilanRaw === "") ? "warning" : "default"; const categorie = String(enrichment.CATEGORIE_FOLOSINTA ?? "").trim(); const nrCorpuri = Number(enrichment.NR_CORPURI ?? 0) || 0; - const hasBuilding = Number(enrichment.HAS_BUILDING ?? 0) || 0; - const buildingsCount = nrCorpuri > 0 ? nrCorpuri : hasBuilding; + const hasBuildingFlag = Number(enrichment.HAS_BUILDING ?? 0) || 0; + const buildingsCount = + nrCorpuri > 0 + ? nrCorpuri + : Array.isArray(buildings) + ? buildings.length + : hasBuildingFlag; - const statusChip = - detail?.isActive === false - ? { label: "Inactiv", tone: "muted" as const } - : { label: "Activ", tone: "success" as const }; + const isActive = detail?.isActive !== false; + const cadrefHeader = feature.cadastralRef || feature.objectId || "—"; const layerLabel = feature.layerId.replace("_ACTIVE", "").toLowerCase(); + const enrichedAgo = formatRelativeTime(detail?.enrichedAt); + + // ──────────────────────────────────────────────────────── return ( -
+
{/* Header */} -
+
-
- {feature.cadastralRef || feature.objectId || "—"} +
+ {!basic && detail && } +

+ {cadrefHeader} +

-
- {layerLabel} +

+ {layerLabel} {feature.areaValue != null && ( - · {formatNum(feature.areaValue)} m² + · {formatNum(feature.areaValue)} m² )} - {!basic && detail && ( - - {statusChip.label} - + {feature.siruta && ( + · SIRUTA {feature.siruta} )} -

+

- {/* Basic mode: stop after header */} {basic && ( -
+
Acces restricționat — afișăm doar identificatorul cadastral și - suprafața GIS. Contactează administratorul pentru drepturi extinse. + suprafața GIS. Contactează administratorul pentru drepturi + extinse.
)} - {/* Full mode body */} {!basic && ( -
+
{loading && (
@@ -646,7 +802,6 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) {
)} - {/* Auto-enrich is running in background (sparse DB record → fetched from ANCPI) */} {refreshing && !loading && (
@@ -654,21 +809,18 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) {
)} - {/* Forbidden after a silent re-grant attempt already failed - once — likely Authentik scope misconfig. Stays discreet (no - call-to-action), only diag info for the operator. */} {error === "forbidden" && ( -
+
Datele detaliate nu pot fi încărcate momentan.
)} {error && error !== "forbidden" && ( -
+
{error === "no_available_account" - ? "Pool-ul ANCPI e temporar epuizat — încearcă din nou peste câteva minute (cota orară se resetează automat)." + ? "Pool-ul ANCPI e temporar epuizat — încearcă din nou peste câteva minute." : error === "no_immovable_match" ? "Parcela nu există în baza eTerra (cadref + SIRUTA nu se potrivesc)." : error === "parcel_not_found" @@ -680,192 +832,185 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) {
)} - {/* Caracteristici */} + {/* Caracteristici chips */} {!loading && detail && ( -
- Caracteristici +
{intravilanLabel ? ( } - title={ - intravilanRaw === "" && hasEnrich - ? "Extravilan presupus (ANCPI nu a completat câmpul)" - : `Intravilan: ${intravilanLabel}` - } + icon={} > {intravilanLabel} ) : ( Intravilan ? )} - {categorie && ( {categorie} )} - {buildingsCount > 0 && ( } - title={`${buildingsCount} corp${buildingsCount > 1 ? "uri" : ""} pe parcelă`} + icon={} + title={`${buildingsCount} construcție${buildingsCount > 1 ? "i" : ""} pe parcelă`} > {buildingsCount} corp{buildingsCount > 1 ? "uri" : ""} )} - - {!hasEnrich && !loading && ( - - apasă „Citește din ANCPI" pentru date eTerra - - )}
)} - {/* Hero: CF + Adresă at a glance */} - {!loading && detail && (nrCf || adresa) && ( -
- {nrCf && ( -
-

- Nr. Carte Funciară -

-

- {nrCf} -

- {nrCfVechi && nrCfVechi !== nrCf && ( -

- vechi: {nrCfVechi} -

+ {/* Metric strip — Suprafețe */} + {!loading && detail && (areaGisStr || area2D || areaLegala) && ( +
+
+ + + +
+
+ )} + + {/* Date eTerra card */} + {!loading && detail && ( +
+
+

+ + Date eTerra +

+
- )} - {adresa && ( -
- -

{adresa}

-
- )} -
- )} + > + {refreshing ? ( + + ) : ( + + )} + {hasEnrich ? "Actualizează" : "Încarcă"} + {enrichedAgo && hasEnrich && !refreshing && ( + + · {enrichedAgo} + + )} + +
- {/* Structured sections */} - {!loading && detail && sectionData.length > 0 && ( - <> - {sectionData - // Skip the CF + ADRESA sections — they're rendered as the - // hero block above. Keeps the bottom dl from duplicating. - .filter((s) => s.id !== "cf" && s.id !== "adresa") - .map((s) => ( -
- {s.title} -
- {s.rows.map(([k, v]) => ( - - {renderValue(k, v)} - - } - /> - ))} -
-
- ))} - - )} - - {/* Tehnic (collapsed). Anything that didn't fit into a named - section ends up here so we never silently drop a key. */} - {!loading && detail && technicalEntries.length > 0 && ( -
- - {techExpanded && ( -
- {technicalEntries.map(([k, v]) => ( - - ))} -
- )} -
- )} - {/* enrichedAt timestamp — always at the end of the data area */} - {!loading && detail && hasEnrich && detail.enrichedAt && ( -
- Actualizat din ANCPI: {new Date(detail.enrichedAt).toLocaleString("ro-RO")} -
- )} - - {/* Apartamente (condominium) */} - {!loading && isCladiri && (condoLoading || condoOwners) && ( -
- + + {nrCfVechi && nrCfVechi !== nrCf && ( + + )} + + {adresa && ( +
+ +

{adresa}

+
+ )} + + {solicitant && ( +
+ +

{solicitant}

+
+ )} + + {proprietari && ( + + )} + + {proprietariVechi && ( + + )} + + {(tipInscriere || dataCererii || actProp) && ( +
+ {tipInscriere && } + {dataCererii && } + {actProp && } +
+ )} +
+
+ )} + + {/* Construcții list (terenuri only) */} + {!loading && isTerenuri && buildings && buildings.length > 0 && ( +
+

+ Construcții ({buildings.length}) +

+
+ {buildings.map((b) => ( + + ))} +
+
+ )} + + {/* Apartamente (cladiri only) */} + {!loading && isCladiri && (condoLoading || (condoOwners && condoOwners.length > 0)) && ( +
+

+ + Apartamente + {condoOwners && condoOwners.length > 0 && ( + + ({condoOwners.length}) + + )} +

{condoLoading && ( -
+
se încarcă…
)} - {condoExpanded && condoOwners && condoOwners.length === 0 && ( -

- Fără apartamente înregistrate pentru această clădire. -

- )} - {condoExpanded && condoOwners && condoOwners.length > 0 && ( -
+ {condoOwners && condoOwners.length > 0 && ( +
{condoOwners.map((u, i) => (
{u.area != null && ( - {formatNum(u.area)} m² + {Math.round(u.area).toLocaleString("ro-RO")} mp )}
@@ -888,7 +1033,7 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) { )} {Array.isArray(u.owners) && u.owners.length > 0 && (
    - {u.owners.map((o, j) => ( + {parseOwners(u.owners).map((o, j) => (
  • {o}
  • ))}
@@ -902,11 +1047,10 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) { {/* Localizare */} {feature.lat != null && feature.lng != null && ( -
- Localizare +
- + {feature.lat.toFixed(5)}, {feature.lng.toFixed(5)}
-
- SIRUTA: {feature.siruta || "-"} -
)} - {/* Empty state */} {!loading && !detail && !error && ( -
- Parcela nu există încă în baza de date centrală. Apasă „Citește - din ANCPI" pentru a o încărca. +
+ Parcela nu există încă în baza de date centrală. Apasă + „Încarcă" în secțiunea Date eTerra pentru a o adăuga.
)}
)} {/* Actions toolbar */} -
+
-