fix(geoportal-v2): structured panel sections + readable labels (back to basics)

Marius's feedback: "datele arată foarte ciudat" — the flat dl renderer
showed enrichment keys in whatever order gis-api returned them, with
raw key names (PARCEL_POSTAL_NO instead of "Nr. poștal") and no visual
hierarchy. With 20+ keys the important things (NR_CF, ADRESA,
PROPRIETARI) ended up below the fold under PARCEL_* tech metadata.

Restructure to mirror eterra.live's parcel-info layout:

1. HERO BLOCK at the top of the data area
   - "Nr. Carte Funciară" big mono number (NR_CF)
   - NR_CF_VECHI shown below when different
   - Adresă with pin icon and proper text wrapping

2. NAMED SECTIONS (rendered in fixed order, only when populated)
   - Proprietari — splits comma/semicolon-separated names into a list
   - Cadastru — NR_CAD, NR_TOPO, PARCEL_TOPO_NO
   - Suprafețe — SUPRAFATA_R, SUPRAFATA_2D, PARCEL_LEGAL_AREA, SUPRAFATA
     (values parsed + " m²" suffix)
   - Înscriere — SOLICITANT, TIP_INSCRIERE, DATA_CERERE, DOC
   - (CF/Adresă removed from the list because they're in the hero)

3. CARACTERISTICI CHIPS (existing) — Intravilan / Categorie / nr corpuri /
   status stay at the top. CARACTERISTICI_KEYS excludes them from the
   sections below so nothing is shown twice.

4. DETALII TEHNICE (collapsed by default) — anything not in a named
   section: PARCEL_HAS_LANDBOOK, PARCEL_IS_CONDOMINIUM,
   PARCEL_TECH_ENRICHED_AT, etc. Renders with friendly labels (Da/Nu
   for flags, dd/mm/yyyy hh:mm for dates) instead of "1" / ISO strings.

5. Value renderers per key type:
   - formatAreaValue("456" | "456.06" | "456 mp") → "456 m²"
   - formatFlag(0/1, da/nu) → "Da" / "Nu"
   - formatDate(ISO) → "19.05.2026, 04:40" (Romanian locale)
   - parseOwners("MATHBOUT MOHAMED MAHER, DR.") → list items

6. enrichedAt moved to a single small line at the bottom of the data
   area instead of a per-section caption.

LABEL map covers all 25 enrichment keys observed in DB. Anything new
falls back to raw key name (visible — easy to spot and add).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-05-19 17:17:48 +03:00
parent a23ba1957f
commit 342bdca648
+255 -36
View File
@@ -56,37 +56,82 @@ interface Props {
basic?: boolean; 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<string, string> = { const LABEL: Record<string, string> = {
PROPRIETARI: "Proprietari", // CF / titlu
PROPRIETARI_VECHI: "Proprietari anteriori",
NR_CF: "Carte funciară", NR_CF: "Carte funciară",
NR_CF_VECHI: "CF vechi", 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", NR_TOPO: "Nr. topografic",
ADRESA: "Adresă", PARCEL_TOPO_NO: "Nr. topo (alt)",
DOC: "Documente", // Suprafețe
SUPRAFATA: "Suprafață CF", SUPRAFATA: "Suprafață",
SUPRAFATA_2D: "Suprafață 2D",
SUPRAFATA_R: "Suprafață reală", 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ță", CATEGORIE_FOLOSINTA: "Categorie folosință",
INTRAVILAN: "Intravilan", INTRAVILAN: "Intravilan",
UAT: "UAT",
UAT_SIRUTA: "SIRUTA",
HAS_BUILDING: "Are clădire", HAS_BUILDING: "Are clădire",
BUILD_LEGAL: "Clădire legală",
NR_CORPURI: "Nr. corpuri", NR_CORPURI: "Nr. corpuri",
NR_CORPURI_LEGALE: "Nr. corpuri legale", NR_CORPURI_LEGALE: "Nr. corpuri legale",
PARCEL_HAS_LANDBOOK: "Are CF",
PARCEL_IS_CONDOMINIUM: "Condominium",
TARLA: "Tarla", TARLA: "Tarla",
PARCELA: "Parcelă", 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 in CARACTERISTICI chips at the top — never repeated in
// the structured sections below.
// Keys rendered specially or excluded from the generic "Date eTerra" list const CARACTERISTICI_KEYS = new Set([
const SPECIAL_KEYS = new Set([ "INTRAVILAN",
"INTRAVILAN", "CATEGORIE_FOLOSINTA", "PARCELE_DETAILS", "CATEGORIE_FOLOSINTA",
"HAS_BUILDING", "BUILD_LEGAL", "NR_CORPURI", "NR_CORPURI_LEGALE", "HAS_BUILDING",
"UAT", "UAT_SIRUTA", "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 { function formatNum(v: unknown, fractionDigits = 0): string {
if (typeof v !== "number" || !Number.isFinite(v)) return String(v ?? "-"); if (typeof v !== "number" || !Number.isFinite(v)) return String(v ?? "-");
return v.toLocaleString("ro-RO", { return v.toLocaleString("ro-RO", {
@@ -102,6 +147,85 @@ function formatValue(val: unknown): string {
return String(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);
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 <span className="text-muted-foreground"></span>;
}
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 (
<ul className="space-y-0.5">
{owners.map((o, i) => (
<li key={`${o}-${i}`}>{o}</li>
))}
</ul>
);
}
return formatValue(val);
}
function Chip({ function Chip({
tone = "default", tone = "default",
icon, icon,
@@ -348,18 +472,38 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) {
}; };
const enrichment = (detail?.enrichment ?? {}) as Record<string, unknown>; const enrichment = (detail?.enrichment ?? {}) as Record<string, unknown>;
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( Object.entries(enrichment).filter(
([k, v]) => ([k, v]) =>
!SPECIAL_KEYS.has(k) && !CARACTERISTICI_KEYS.has(k) && !SECTION_KEYS.has(k) && isPresent(v),
v != null &&
v !== "" &&
(Array.isArray(v) ? v.length > 0 : true),
), ),
[enrichment], [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 intravilanRaw = String(enrichment.INTRAVILAN ?? "").trim();
const intravilanLower = intravilanRaw.toLowerCase(); const intravilanLower = intravilanRaw.toLowerCase();
@@ -503,31 +647,106 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) {
</div> </div>
)} )}
{/* Date eTerra */} {/* Hero: CF + Adresă at a glance */}
{!loading && detail && hasEnrich && ( {!loading && detail && (nrCf || adresa) && (
<div className="border-b px-3 py-2.5"> <div className="border-b px-3 py-2.5 space-y-2">
<SectionHeader>Date eTerra</SectionHeader> {nrCf && (
<dl className="grid grid-cols-1 gap-2"> <div>
{enrichmentEntries.map(([k, v]) => ( <p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Nr. Carte Funciară
</p>
<p className="mt-0.5 font-mono text-lg font-semibold">
{nrCf}
</p>
{nrCfVechi && nrCfVechi !== nrCf && (
<p className="text-[11px] text-muted-foreground">
vechi: <span className="font-mono">{nrCfVechi}</span>
</p>
)}
</div>
)}
{adresa && (
<div className="flex items-start gap-1.5">
<MapPin className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<p className="text-sm leading-snug">{adresa}</p>
</div>
)}
</div>
)}
{/* 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) => (
<div key={s.id} className="border-b px-3 py-2.5">
<SectionHeader>{s.title}</SectionHeader>
<dl className="space-y-1.5">
{s.rows.map(([k, v]) => (
<KeyValue <KeyValue
key={k} key={k}
k={LABEL[k] ?? k} k={LABEL[k] ?? k}
v={ v={
<span className={cn( <span
"font-mono", className={cn(
PII_KEYS.has(k) && "text-foreground", PII_KEYS.has(k)
)}> ? "text-foreground"
{formatValue(v)} : "text-foreground/90",
)}
>
{renderValue(k, v)}
</span> </span>
} }
/> />
))} ))}
</dl> </dl>
{detail.enrichedAt && ( </div>
<p className="mt-1.5 text-[10px] text-muted-foreground"> ))}
Actualizat: {new Date(detail.enrichedAt).toLocaleString("ro-RO")} </>
</p>
)} )}
{/* 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 && (
<div className="border-b px-3 py-2.5">
<button
type="button"
onClick={() => setTechExpanded((s) => !s)}
className="flex w-full items-center justify-between"
>
<SectionHeader>
Detalii tehnice
<span className="ml-1 text-muted-foreground/70">
({technicalEntries.length})
</span>
</SectionHeader>
{techExpanded ? (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
)}
</button>
{techExpanded && (
<dl className="mt-1 space-y-1.5">
{technicalEntries.map(([k, v]) => (
<KeyValue
key={k}
k={LABEL[k] ?? k}
v={renderValue(k, v)}
/>
))}
</dl>
)}
</div>
)}
{/* enrichedAt timestamp — always at the end of the data area */}
{!loading && detail && hasEnrich && detail.enrichedAt && (
<div className="px-3 py-1.5 text-[10px] text-muted-foreground">
Actualizat din ANCPI: {new Date(detail.enrichedAt).toLocaleString("ro-RO")}
</div> </div>
)} )}