From b5eff5acc1f8f9fd6ef5293c4a42bed7749b15d5 Mon Sep 17 00:00:00 2001 From: Claude VM Date: Tue, 19 May 2026 12:35:09 +0300 Subject: [PATCH] =?UTF-8?q?fix(geoportal-v2):=20rewrite=20info=20panel=20?= =?UTF-8?q?=E2=80=94=20auto-fetch=20+=20sections=20+=20condo=20+=20basic?= =?UTF-8?q?=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of B1 (panel showed "Apasă din ANCPI" even with full enrichment in DB): PMTiles overview tiles don't carry the GisFeature uuid, only siruta/cadastral_ref/object_id. The panel's useEffect bailed out at `!feature.id` and never fetched. So the data was there, the UI just refused to ask for it. Fix: when the click feature has no uuid, the panel now calls `/api/gis/search?q=`, filters by layerId match, and uses the returned id to do `parcela.get(id)`. One extra round trip (~50ms with the trigram-idx fix from 2026-05-18). For features arriving from the search dropdown the uuid is already known — that path is unchanged. Panel redesign — same data shape as eterra.live, ArchiTools styling (shadcn instead of HeroUI), single-file: - Header: cadref + layer + area + status chip + close - Caracteristici: intravilan + categorie folosință + nr corpuri (chips) - Date eTerra: all enrichment fields, PII passes through gis-api scope redaction (scope=basic → PROPRIETARI/NR_CF/DOC already null) - Apartamente (condominium): for CLADIRI_ACTIVE clicks, fetches /api/gis/building/condo-owners and renders units with owners + cf + area - Localizare: click lat/lng + Google Maps link + SIRUTA echo Two new proxy routes (thin wrappers over gis-api): - POST /api/gis/parcel/units-fetch - POST /api/gis/building/condo-owners Basic-panel mode for restricted users (per Marius: "for users I don't want to give full access to"): - New env BASIC_PANEL_USERS (csv emails) → session.basicPanel flag - Optional PANEL_BASIC_GLOBAL=1 to force-basic everyone - When true, panel renders only header + cadref + suprafață + a restriction notice; all sections + condo fetch are skipped - Defaults to off; pilot user Marius gets full panel as before map-viewer now forwards lngLat on click so the Localizare section has coordinates without a second lookup. Type-check clean. Production build (NODE_ENV=production npx next build) passes. The dev-mode prerender error on / page is pre-existing (Next 16 useContext-null on client component during static export, unrelated). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/gis/building/condo-owners/route.ts | 44 ++ src/app/api/gis/parcel/units-fetch/route.ts | 44 ++ src/core/auth/auth-options.ts | 2 + src/core/feature-flags/basic-panel.ts | 15 + .../geoportal/v2/feature-info-panel.tsx | 621 +++++++++++++----- src/modules/geoportal/v2/geoportal-v2.tsx | 4 + src/modules/geoportal/v2/map-viewer.tsx | 4 +- 7 files changed, 580 insertions(+), 154 deletions(-) create mode 100644 src/app/api/gis/building/condo-owners/route.ts create mode 100644 src/app/api/gis/parcel/units-fetch/route.ts create mode 100644 src/core/feature-flags/basic-panel.ts diff --git a/src/app/api/gis/building/condo-owners/route.ts b/src/app/api/gis/building/condo-owners/route.ts new file mode 100644 index 0000000..c9d323f --- /dev/null +++ b/src/app/api/gis/building/condo-owners/route.ts @@ -0,0 +1,44 @@ +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 { + return NextResponse.json(await gisApi.building.condoOwners(body)); + } catch (err) { + if (err instanceof GisApiError) { + return NextResponse.json( + { error: err.code, status: err.status, body: err.body }, + { status: err.status }, + ); + } + const msg = err instanceof Error ? err.message : String(err); + console.error("[gis-building-condo-owners] internal:", msg); + return NextResponse.json( + { error: "internal_error", hint: msg.slice(0, 200) }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/gis/parcel/units-fetch/route.ts b/src/app/api/gis/parcel/units-fetch/route.ts new file mode 100644 index 0000000..fd481ec --- /dev/null +++ b/src/app/api/gis/parcel/units-fetch/route.ts @@ -0,0 +1,44 @@ +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 { + return NextResponse.json(await gisApi.parcel.unitsFetch(body)); + } catch (err) { + if (err instanceof GisApiError) { + return NextResponse.json( + { error: err.code, status: err.status, body: err.body }, + { status: err.status }, + ); + } + const msg = err instanceof Error ? err.message : String(err); + console.error("[gis-parcel-units-fetch] internal:", msg); + return NextResponse.json( + { error: "internal_error", hint: msg.slice(0, 200) }, + { status: 500 }, + ); + } +} diff --git a/src/core/auth/auth-options.ts b/src/core/auth/auth-options.ts index 14ab33d..cfa750d 100644 --- a/src/core/auth/auth-options.ts +++ b/src/core/auth/auth-options.ts @@ -2,6 +2,7 @@ import type { NextAuthOptions } from "next-auth"; import type { JWT } from "next-auth/jwt"; import AuthentikProvider from "next-auth/providers/authentik"; import { useGisAcFlag } from "@/core/feature-flags/use-gis-ac"; +import { useBasicPanelFlag } from "@/core/feature-flags/basic-panel"; /** * Refresh the Authentik access_token using the stored refresh_token. @@ -194,6 +195,7 @@ export const authOptions: NextAuthOptions = { // branch the same way server routes do (env-driven, evaluated per // request so flag flip + container restart picks up without rebuild). (session as any).useGisAc = useGisAcFlag(session.user?.email); + (session as any).basicPanel = useBasicPanelFlag(session.user?.email); return session; }, }, diff --git a/src/core/feature-flags/basic-panel.ts b/src/core/feature-flags/basic-panel.ts new file mode 100644 index 0000000..168a045 --- /dev/null +++ b/src/core/feature-flags/basic-panel.ts @@ -0,0 +1,15 @@ +// Panel-density flag for the V2 geoportal info panel. +// Users in BASIC_PANEL_USERS see ONLY cadastralRef + suprafață + status — +// no enrichment, no PII, no buildings. Everyone else gets the full panel. +// Set in Infisical /architools: BASIC_PANEL_USERS=foo@x,bar@y + +const BASIC_USERS = (process.env.BASIC_PANEL_USERS || "") + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + +export function useBasicPanelFlag(userEmail?: string | null): boolean { + if (process.env.PANEL_BASIC_GLOBAL === "1") return true; + if (!userEmail) return false; + return BASIC_USERS.includes(userEmail.toLowerCase()); +} diff --git a/src/modules/geoportal/v2/feature-info-panel.tsx b/src/modules/geoportal/v2/feature-info-panel.tsx index cb0ccf1..51f2547 100644 --- a/src/modules/geoportal/v2/feature-info-panel.tsx +++ b/src/modules/geoportal/v2/feature-info-panel.tsx @@ -1,19 +1,23 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { X, RefreshCw, Loader2, FileText, Download, AlertCircle, + Home, Building, MapPin, ChevronDown, ChevronRight, Users, } from "lucide-react"; import { cn } from "@/shared/lib/utils"; export interface ClickedFeatureLite { - /** GisFeature uuid — empty when tile only has object_id */ + /** 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; } interface ParcelDetail { @@ -28,101 +32,237 @@ interface ParcelDetail { [k: string]: unknown; } +interface CondoOwner { + unitNo?: string; + apartmentNo?: string; + owners?: string[]; + area?: number; + cf?: string; + [k: string]: unknown; +} + interface Props { feature: ClickedFeatureLite; onClose: () => void; + /** When true, render only header + cadastralRef + suprafață (restricted users) */ + basic?: boolean; } -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; +const LABEL: Record = { + PROPRIETARI: "Proprietari", + PROPRIETARI_VECHI: "Proprietari anteriori", + NR_CF: "Carte funciară", + NR_CF_VECHI: "CF vechi", + NR_TOPO: "Nr. topografic", + ADRESA: "Adresă", + DOC: "Documente", + SUPRAFATA: "Suprafață CF", + SUPRAFATA_2D: "Suprafață 2D", + SUPRAFATA_R: "Suprafață reală", + CATEGORIE_FOLOSINTA: "Categorie folosință", + INTRAVILAN: "Intravilan", + UAT: "UAT", + UAT_SIRUTA: "SIRUTA", + HAS_BUILDING: "Are clădire", + NR_CORPURI: "Nr. corpuri", + NR_CORPURI_LEGALE: "Nr. corpuri legale", + TARLA: "Tarla", + PARCELA: "Parcelă", }; +const PII_KEYS = new Set(["PROPRIETARI", "PROPRIETARI_VECHI", "NR_CF", "NR_CF_VECHI", "DOC"]); + +// Keys rendered specially or excluded from the generic "Date eTerra" list +const SPECIAL_KEYS = new Set([ + "INTRAVILAN", "CATEGORIE_FOLOSINTA", "PARCELE_DETAILS", + "HAS_BUILDING", "BUILD_LEGAL", "NR_CORPURI", "NR_CORPURI_LEGALE", + "UAT", "UAT_SIRUTA", +]); + +function formatNum(v: unknown, fractionDigits = 0): string { + if (typeof v !== "number" || !Number.isFinite(v)) return String(v ?? "-"); + return v.toLocaleString("ro-RO", { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }); +} + function formatValue(val: unknown): string { - if (val == null) return "-"; + if (val == null || val === "") 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) { +function Chip({ + tone = "default", + icon, + children, + title, +}: { + tone?: "default" | "success" | "warning" | "danger" | "muted"; + icon?: React.ReactNode; + children: React.ReactNode; + title?: string; +}) { + 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", + danger: "border-destructive/30 bg-destructive/10 text-destructive", + muted: "border-dashed border-border text-muted-foreground", + }[tone]; + return ( + + {icon} + {children} + + ); +} + +function SectionHeader({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +function KeyValue({ k, v }: { k: string; v: React.ReactNode }) { + return ( +
+
{k}
+
{v}
+
+ ); +} + +export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) { const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [refreshing, setRefreshing] = useState(false); + const [condoOwners, setCondoOwners] = useState(null); + const [condoLoading, setCondoLoading] = useState(false); + const [condoExpanded, setCondoExpanded] = useState(false); - // Fetch detail when feature changes. - // If tile carries the GisFeature uuid: use the fast path (parcela.get, - // stored enrichment, no ANCPI roundtrip). If only siruta+cadastralRef are - // available: skip auto-fetch — user clicks "Citește din ANCPI" to load. + const isCladiri = feature.layerId === "CLADIRI_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. useEffect(() => { - if (!feature.id) { - setDetail(null); - setError(null); - setLoading(false); - return; - } + if (basic) return; let cancelled = false; setLoading(true); setError(null); setDetail(null); - fetch(`/api/gis/parcela/${encodeURIComponent(feature.id)}`) - .then(async (res) => { + + const run = async () => { + try { + let id = feature.id; + if (!id) { + const sr = await fetch( + `/api/gis/search?q=${encodeURIComponent(feature.cadastralRef)}&limit=20`, + ); + if (cancelled) return; + if (!sr.ok) { + setError("search_failed"); + setLoading(false); + return; + } + const sd = (await sr.json()) as { + features?: Array<{ id: string; layerId: string; cadastralRef: string }>; + }; + const match = (sd.features ?? []).find( + (f) => + f.cadastralRef === feature.cadastralRef && + f.layerId === feature.layerId, + ); + if (!match) { + // Parcel not in central DB — show header only; user can press "Citește din ANCPI" + setLoading(false); + return; + } + id = match.id; + } + const r = await fetch(`/api/gis/parcela/${encodeURIComponent(id)}`); if (cancelled) return; - if (res.status === 403) { + if (r.status === 403) { setError("forbidden"); + setLoading(false); return; } - if (!res.ok) { + if (!r.ok) { setError("fetch_failed"); + setLoading(false); return; } - const data: ParcelDetail = await res.json(); - if (!cancelled) setDetail(data); - }) - .catch(() => { - if (!cancelled) setError("network_error"); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); + const data = (await r.json()) as ParcelDetail; + if (!cancelled) { + setDetail(data); + setLoading(false); + } + } catch { + if (!cancelled) { + setError("network_error"); + setLoading(false); + } + } + }; + void run(); return () => { cancelled = true; }; - }, [feature.id]); + }, [feature.id, feature.siruta, feature.cadastralRef, feature.layerId, basic]); - const refreshFromAncpi = async () => { - let siruta = feature.siruta || detail?.siruta || ""; - const cadastralRef = feature.cadastralRef || detail?.cadastralRef || ""; - // Race fix: parcels selected from search dropdown carry id + cadref - // but no siruta. If the parcela.get auto-fetch is still in flight (or - // never fired because tile lacks uuid), hydrate siruta now. - if (!siruta && feature.id) { + // For CLADIRI parcels, fetch condo owners (apartment list). + useEffect(() => { + if (basic || !isCladiri || !feature.siruta || !feature.cadastralRef) return; + let cancelled = false; + setCondoOwners(null); + setCondoLoading(true); + const run = async () => { try { - const res = await fetch(`/api/gis/parcela/${encodeURIComponent(feature.id)}`); - if (res.ok) { - const d = await res.json(); - if (typeof d?.siruta === "string") { - siruta = d.siruta; - setDetail(d); - } + const r = await fetch("/api/gis/building/condo-owners", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + siruta: feature.siruta, + cadastralRef: feature.cadastralRef, + }), + }); + if (cancelled) return; + if (!r.ok) { + setCondoLoading(false); + return; + } + 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 + : []; + if (!cancelled) { + setCondoOwners(owners); + setCondoLoading(false); } } catch { - /* fall through to validation below */ + if (!cancelled) setCondoLoading(false); } - } - if (!siruta || !cadastralRef) { + }; + void run(); + return () => { + cancelled = true; + }; + }, [isCladiri, feature.siruta, feature.cadastralRef, basic]); + + const refreshFromAncpi = async () => { + if (!feature.siruta || !feature.cadastralRef) { setError("missing_siruta_or_cad"); return; } @@ -133,8 +273,8 @@ export function FeatureInfoPanel({ feature, onClose }: Props) { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - siruta, - cadastralRef, + siruta: feature.siruta, + cadastralRef: feature.cadastralRef, force: true, }), }); @@ -144,22 +284,22 @@ export function FeatureInfoPanel({ feature, onClose }: Props) { return; } const techData = await res.json().catch(() => null); - - // If the tile carried a GisFeature uuid, refresh stored detail. - // Otherwise project the orchestrator response into the panel directly. - if (feature.id) { + // Refresh stored detail via parcela.get if uuid known. + if (detail?.id || feature.id) { + const id = detail?.id ?? feature.id; const updated = await fetch( - `/api/gis/parcela/${encodeURIComponent(feature.id)}`, + `/api/gis/parcela/${encodeURIComponent(id)}`, ); if (updated.ok) setDetail(await updated.json()); } else if (techData) { - // Orchestrator returns {status, data: {…enrichment fields}} verbatim. + // Project the orchestrator response into the panel directly. const inner = (techData?.data as Record | undefined) ?? techData; setDetail({ siruta: feature.siruta, cadastralRef: feature.cadastralRef, areaValue: feature.areaValue, + layerId: feature.layerId, enrichment: inner as Record, }); } @@ -178,128 +318,303 @@ export function FeatureInfoPanel({ feature, onClose }: Props) { }; const orderCf = () => { - // The map-side panel doesn't have judet / uat / credit context that the - // ePay flow needs to render properly. Redirect to the parcel-sync page - // (eTerra Parcele → ePay tab) where the user has the full UI: own ePay - // session, visible credit balance, decrement per order. Filter by the - // cadastral so the user lands on this parcel. const cad = encodeURIComponent(feature.cadastralRef); const url = `/parcel-sync?tab=epay&cad=${cad}`; 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 !== ""), + const enrichmentEntries = useMemo( + () => + Object.entries(enrichment).filter( + ([k, v]) => + !SPECIAL_KEYS.has(k) && + v != null && + v !== "" && + (Array.isArray(v) ? v.length > 0 : true), + ), + [enrichment], ); + const hasEnrich = enrichmentEntries.length > 0; + + const intravilanRaw = String(enrichment.INTRAVILAN ?? "").trim(); + const intravilanLower = intravilanRaw.toLowerCase(); + const intravilanLabel = + intravilanLower === "da" + ? "Intravilan" + : intravilanLower === "nu" + ? "Extravilan" + : hasEnrich + ? "Extravilan (presupus)" + : null; + const intravilanTone = + intravilanLower === "da" + ? "success" + : intravilanLower === "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 statusChip = + detail?.isActive === false + ? { label: "Inactiv", tone: "muted" as const } + : { label: "Activ", tone: "success" as const }; + + const layerLabel = feature.layerId.replace("_ACTIVE", "").toLowerCase(); return ( -
-
-
-
- {feature.cadastralRef} +
+ {/* Header */} +
+
+
+ {feature.cadastralRef || feature.objectId || "—"}
-
- {feature.layerId.replace("_ACTIVE", "").toLowerCase()} +
+ {layerLabel} {feature.areaValue != null && ( - · {feature.areaValue.toFixed(0)} m² + · {formatNum(feature.areaValue)} m² + )} + {!basic && detail && ( + + {statusChip.label} + )}
-
- {loading && ( -
- - Se încarcă… -
- )} + {/* Basic mode: stop after header */} + {basic && ( +
+ Acces restricționat — afișăm doar identificatorul cadastral și + suprafața GIS. Contactează administratorul pentru drepturi extinse. +
+ )} - {error === "forbidden" && ( -
- - Nu ai permisiuni de citire detaliată (scope insuficient). -
- )} + {/* Full mode body */} + {!basic && ( +
+ {loading && ( +
+ + Se încarcă datele parcelei… +
+ )} - {error && error !== "forbidden" && ( -
- - Eroare: {error} -
- )} + {error === "forbidden" && ( +
+ + Nu ai permisiuni de citire detaliată (scope insuficient). +
+ )} + {error && error !== "forbidden" && ( +
+ + Eroare: {error} +
+ )} - {detail && !loading && ( - <> -
-
-
SIRUTA
-
{detail.siruta ?? "-"}
+ {/* Caracteristici */} + {!loading && detail && ( +
+ Caracteristici +
+ {intravilanLabel ? ( + } + title={ + intravilanRaw === "" && hasEnrich + ? "Extravilan presupus (ANCPI nu a completat câmpul)" + : `Intravilan: ${intravilanLabel}` + } + > + {intravilanLabel} + + ) : ( + Intravilan ? + )} + + {categorie && ( + + {categorie} + + )} + + {buildingsCount > 0 && ( + } + title={`${buildingsCount} corp${buildingsCount > 1 ? "uri" : ""} pe parcelă`} + > + {buildingsCount} corp{buildingsCount > 1 ? "uri" : ""} + + )} + + {!hasEnrich && !loading && ( + + apasă „Citește din ANCPI" pentru date eTerra + + )}
-
-
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)} -
-
+ {/* Date eTerra */} + {!loading && detail && hasEnrich && ( +
+ Date eTerra +
+ {enrichmentEntries.map(([k, v]) => ( + {formatValue(v)} -
+ + } + /> + ))} +
+ {detail.enrichedAt && ( +

+ Actualizat: {new Date(detail.enrichedAt).toLocaleString("ro-RO")} +

+ )} +
+ )} + + {/* Apartamente (condominium) */} + {!loading && isCladiri && (condoLoading || condoOwners) && ( +
+ + {condoLoading && ( +
+ se încarcă… +
+ )} + {condoExpanded && condoOwners && condoOwners.length === 0 && ( +

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

+ )} + {condoExpanded && condoOwners && condoOwners.length > 0 && ( +
+ {condoOwners.map((u, i) => ( +
+
+ + {u.unitNo ?? u.apartmentNo ?? `Unitate #${i + 1}`} + + {u.area != null && ( + + {formatNum(u.area)} m² + + )} +
+ {u.cf && ( +

+ CF: {u.cf} +

+ )} + {Array.isArray(u.owners) && u.owners.length > 0 && ( +
    + {u.owners.map((o, j) => ( +
  • {o}
  • + ))} +
+ )}
))} - - - )} +
+ )} +
+ )} - {enrichmentEntries.length === 0 && ( -
- Fără date eTerra. Apasă „Citește din ANCPI" pentru a încărca. + {/* Localizare */} + {feature.lat != null && feature.lng != null && ( +
+ Localizare +
+ + + {feature.lat.toFixed(5)}, {feature.lng.toFixed(5)} + + + Google Maps +
- )} - - )} +
+ SIRUTA: {feature.siruta || "-"} +
+
+ )} - {!detail && !loading && !error && ( -
- Apasă „Citește din ANCPI" pentru a încărca detaliile parcelei. -
- )} -
+ {/* Empty state */} + {!loading && !detail && !error && ( +
+ Parcela nu există încă în baza de date centrală. Apasă „Citește + din ANCPI" pentru a o încărca. +
+ )} +
+ )} + {/* Actions toolbar */}
diff --git a/src/modules/geoportal/v2/map-viewer.tsx b/src/modules/geoportal/v2/map-viewer.tsx index bbb7cdd..e9ce1fb 100644 --- a/src/modules/geoportal/v2/map-viewer.tsx +++ b/src/modules/geoportal/v2/map-viewer.tsx @@ -263,13 +263,15 @@ export const MapViewer = forwardRef(function MapViewer( } onFeatureClick({ - id, // may be empty when tile lacks uuid; panel falls back to parcel/tech + id, // typically empty from PMTiles overview; panel falls back to search-by-cadref objectId, siruta, cadastralRef, layerId, areaValue: typeof p.area_value === "number" ? (p.area_value as number) : undefined, + lat: e.lngLat.lat, + lng: e.lngLat.lng, }); }; map.on("click", onClick);