diff --git a/src/modules/geoportal/v2/feature-info-panel.tsx b/src/modules/geoportal/v2/feature-info-panel.tsx index 90b8df4..cf67760 100644 --- a/src/modules/geoportal/v2/feature-info-panel.tsx +++ b/src/modules/geoportal/v2/feature-info-panel.tsx @@ -56,37 +56,82 @@ interface Props { 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 = { - PROPRIETARI: "Proprietari", - PROPRIETARI_VECHI: "Proprietari anteriori", + // CF / titlu NR_CF: "Carte funciară", 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", - ADRESA: "Adresă", - DOC: "Documente", - SUPRAFATA: "Suprafață CF", - SUPRAFATA_2D: "Suprafață 2D", + 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", - UAT: "UAT", - UAT_SIRUTA: "SIRUTA", HAS_BUILDING: "Are clădire", + BUILD_LEGAL: "Clădire legală", NR_CORPURI: "Nr. corpuri", NR_CORPURI_LEGALE: "Nr. corpuri legale", + PARCEL_HAS_LANDBOOK: "Are CF", + 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", }; -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", +// 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"]); + function formatNum(v: unknown, fractionDigits = 0): string { if (typeof v !== "number" || !Number.isFinite(v)) return String(v ?? "-"); return v.toLocaleString("ro-RO", { @@ -102,6 +147,85 @@ function formatValue(val: unknown): string { 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); + if (typeof val === "string") { + return val.split(/\s*[;|]\s*|\s*,\s*(?=[A-ZȘȚĂÎÂ])/).map((s) => s.trim()).filter(Boolean); + } + return [String(val)]; +} + +// 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 ( + + ); + } + return formatValue(val); +} + function Chip({ tone = "default", icon, @@ -348,18 +472,38 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) { }; const enrichment = (detail?.enrichment ?? {}) as Record; - const enrichmentEntries = useMemo( + + // 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), + [enrichment], + ); + + const technicalEntries = useMemo( () => Object.entries(enrichment).filter( ([k, v]) => - !SPECIAL_KEYS.has(k) && - v != null && - v !== "" && - (Array.isArray(v) ? v.length > 0 : true), + !CARACTERISTICI_KEYS.has(k) && !SECTION_KEYS.has(k) && isPresent(v), ), [enrichment], ); - const hasEnrich = enrichmentEntries.length > 0; + + 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 adresa = String(enrichment.ADRESA ?? "").trim(); const intravilanRaw = String(enrichment.INTRAVILAN ?? "").trim(); const intravilanLower = intravilanRaw.toLowerCase(); @@ -503,31 +647,106 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) { )} - {/* Date eTerra */} - {!loading && detail && hasEnrich && ( -
- Date eTerra -
- {enrichmentEntries.map(([k, v]) => ( - - {formatValue(v)} - - } - /> - ))} -
- {detail.enrichedAt && ( -

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

+ {/* Hero: CF + Adresă at a glance */} + {!loading && detail && (nrCf || adresa) && ( +
+ {nrCf && ( +
+

+ Nr. Carte Funciară +

+

+ {nrCf} +

+ {nrCfVechi && nrCfVechi !== nrCf && ( +

+ vechi: {nrCfVechi} +

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

{adresa}

+
+ )} +
+ )} + + {/* 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")}
)}