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:
@@ -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<string, string> = {
|
||||
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 <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({
|
||||
tone = "default",
|
||||
icon,
|
||||
@@ -348,18 +472,38 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) {
|
||||
};
|
||||
|
||||
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(
|
||||
([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) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date eTerra */}
|
||||
{!loading && detail && hasEnrich && (
|
||||
<div className="border-b px-3 py-2.5">
|
||||
<SectionHeader>Date eTerra</SectionHeader>
|
||||
<dl className="grid grid-cols-1 gap-2">
|
||||
{enrichmentEntries.map(([k, v]) => (
|
||||
{/* Hero: CF + Adresă at a glance */}
|
||||
{!loading && detail && (nrCf || adresa) && (
|
||||
<div className="border-b px-3 py-2.5 space-y-2">
|
||||
{nrCf && (
|
||||
<div>
|
||||
<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
|
||||
key={k}
|
||||
k={LABEL[k] ?? k}
|
||||
v={
|
||||
<span className={cn(
|
||||
"font-mono",
|
||||
PII_KEYS.has(k) && "text-foreground",
|
||||
)}>
|
||||
{formatValue(v)}
|
||||
<span
|
||||
className={cn(
|
||||
PII_KEYS.has(k)
|
||||
? "text-foreground"
|
||||
: "text-foreground/90",
|
||||
)}
|
||||
>
|
||||
{renderValue(k, v)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</dl>
|
||||
{detail.enrichedAt && (
|
||||
<p className="mt-1.5 text-[10px] text-muted-foreground">
|
||||
Actualizat: {new Date(detail.enrichedAt).toLocaleString("ro-RO")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user