Files
ArchiTools/src/modules/geoportal/v2/feature-info-panel.tsx
T
Claude VM 36840f31f6 fix(geoportal-v2): gate condo-owners on IS_CONDOMINIUM + visible empty state
Two issues from Marius's cladiri screenshots:

1. APARTAMENTE "se încarcă…" sat for ~10s then vanished
   — useEffect fired for every CLADIRI click regardless of whether
   the building was actually a condo. Orchestrator's
   /api/v1/building/condo-owners hit eTerra live, got back an empty
   list for non-condos, returned [], section auto-hid → user saw the
   spinner blink and disappear.

   New gate: useEffect waits for `detail` to land, then reads
   IS_CONDOMINIUM / PARCEL_IS_CONDOMINIUM from enrichment. If neither
   is `1`, skip the fetch entirely. Non-condos no longer pay the 10s
   eTerra round-trip just to show nothing.

2. EMPTY CONDO LISTS WERE HIDING SILENTLY
   — for buildings flagged condo where ANCPI hasn't registered units
   yet, the section would still vanish (`condoOwners.length > 0`
   check). Now: if the fetch returns []  AND the building is a
   condo, render the section with "Fără apartamente înregistrate la
   ANCPI." That's the truthful UX. Same fallback when the fetch
   errors — treat as empty rather than swallow.

Render trigger flipped from
  (condoLoading || (condoOwners && condoOwners.length > 0))
to
  (condoLoading || condoOwners != null)
so the section shows whenever the gate decided to fetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 22:03:18 +03:00

1324 lines
50 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { signIn } from "next-auth/react";
import {
X, RefreshCw, Loader2, FileText, AlertCircle,
Home, Building, Building2, MapPin, ChevronRight, Users,
Sparkles, ShieldCheck, AlertTriangle, HelpCircle,
Factory, Warehouse,
} from "lucide-react";
import { cn } from "@/shared/lib/utils";
import { CfOrderModal } from "./cf-order-modal";
import { useUatName } from "./uat-lookup";
const AUTH_RETRY_KEY = "gis_panel_auth_retry";
export interface ClickedFeatureLite {
id: string;
objectId?: string;
siruta: string;
cadastralRef: string;
layerId: string;
areaValue?: number;
lat?: number;
lng?: number;
}
interface ParcelDetail {
id?: string;
layerId?: string;
siruta?: string;
cadastralRef?: string;
objectId?: number | string;
areaValue?: number;
isActive?: boolean;
enrichment?: Record<string, unknown>;
enrichedAt?: string;
[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;
owners?: string[];
area?: number;
cf?: string;
[k: string]: unknown;
}
interface Props {
feature: ClickedFeatureLite;
onClose: () => void;
/** 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;
}
const LABEL: Record<string, string> = {
NR_CF: "Nr. CF",
NR_CF_VECHI: "CF vechi",
PARCEL_LANDBOOK_NO: "Nr. CF (tehnic)",
ACT_PROPRIETATE: "Act de proprietate",
NR_CAD: "Nr. cadastral",
NR_TOPO: "Nr. topografic",
PARCEL_TOPO_NO: "Nr. topo (alt)",
SUPRAFATA: "Suprafață",
SUPRAFATA_R: "Suprafață reală",
SUPRAFATA_2D: "Suprafață 2D",
PARCEL_LEGAL_AREA: "Suprafață legală",
ADRESA: "Adresă",
PARCEL_POSTAL_NO: "Nr. poștal",
PROPRIETARI: "Proprietari",
PROPRIETARI_VECHI: "Proprietari anteriori",
SOLICITANT: "Solicitant",
DATA_CERERE: "Data cererii",
TIP_INSCRIERE: "Tip înscriere",
DOC: "Documente",
CATEGORIE_FOLOSINTA: "Categorie folosință",
INTRAVILAN: "Intravilan",
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ă",
UAT: "UAT",
UAT_SIRUTA: "SIRUTA",
PARCEL_TECH_ENRICHED_AT: "Actualizat tehnic",
// Cladire-specific (CLADIRI_ACTIVE)
CLADIRE_TYPE: "Tip clădire",
CLADIRE_DESTINATIE: "Destinație",
CLADIRE_DESTINATIE_CODE: "Cod destinație",
CLADIRE_SUBTYPE: "Subtip",
CLADIRE_REGIM: "Regim înălțime",
CLADIRE_NIVELURI: "Niveluri",
CLADIRE_AN_CONSTRUIRE: "An construire",
CLADIRE_AREA_CF: "Suprafață CF",
CLADIRE_LANDBOOK_IE: "Carte funciară IE",
CLADIRE_COMMON_PARTS: "Părți comune",
CLADIRE_OBSERVATII: "Observații",
CLADIRE_UNITS_NO_ANCPI: "Nr. unități ANCPI",
CLADIRE_ENERGETIC_CLASS: "Clasă energetică",
IS_LEGAL_BUILDING: "Clădire legală",
IS_CONDOMINIUM: "Condominium",
ENRICHED_AT: "Actualizat",
};
// ────────────────────────────────────────────────────────── formatters
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 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") {
const n = parseFloat(val.replace(/[^\d.,-]/g, "").replace(",", "."));
return Number.isFinite(n) ? n : null;
}
return null;
}
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 (
<span
className={cn(
"h-1.5 w-1.5 shrink-0 rounded-full",
active ? "bg-emerald-500" : "bg-muted-foreground/40",
)}
title={active ? "Activ" : "Inactiv"}
/>
);
}
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-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];
return (
<span
title={title}
className={cn(
"inline-flex h-6 items-center gap-1 rounded-md border px-2 text-[11px] font-medium",
toneClass,
)}
>
{icon}
{children}
</span>
);
}
function MetricCell({
label,
value,
hint,
}: {
label: string;
value: string | null;
hint?: string;
}) {
return (
<div className="min-w-0 flex-1 px-2 py-1 text-center leading-tight">
<p className="truncate text-[9px] uppercase tracking-wider text-muted-foreground" title={hint}>
{label}
</p>
<p className="truncate text-xs font-semibold tabular-nums">{value ?? "—"}</p>
</div>
);
}
function InfoRow({
label,
value,
mono,
}: {
label: string;
value: string | number | null | undefined;
mono?: boolean;
}) {
if (value === null || value === undefined || value === "") return null;
return (
<div className="flex justify-between gap-2 text-xs">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span
className={cn(
"break-words text-right text-foreground",
mono && "font-mono text-[11px]",
)}
>
{String(value)}
</span>
</div>
);
}
function InfoBlock({
label,
value,
}: {
label: string;
value: string | null | undefined;
}) {
if (!value) return null;
return (
<div className="space-y-0.5">
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{label}
</p>
<p className="whitespace-pre-line text-xs leading-snug text-foreground">
{value}
</p>
</div>
);
}
function CollapsibleInfoBlock({
label,
value,
}: {
label: string;
value: string | null | undefined;
}) {
if (!value) return null;
return (
<details className="group space-y-0.5">
<summary className="-mx-1 flex cursor-pointer list-none items-center gap-1 rounded px-1 py-0.5 hover:bg-muted/40">
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground transition-transform group-open:rotate-90" />
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{label}
</p>
</summary>
<p className="whitespace-pre-line pl-4 text-xs leading-snug text-foreground">
{value}
</p>
</details>
);
}
// ────────────────────────────────────────────────────────── 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 (
<button
type="button"
onClick={() => onSelect(b)}
className={cn(
"flex min-h-[44px] w-full items-center gap-2.5 px-2.5 py-2 text-left",
"hover:bg-muted/40 active:bg-muted/60 transition-colors",
)}
>
<Icon
className={cn(
"h-4 w-4 shrink-0",
b.isCondo ? "text-blue-600" : "text-emerald-600",
)}
aria-hidden
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="font-mono text-xs font-semibold tabular-nums">
{buildingSuffix(b.cadastralRef)}
</span>
{areaStr && (
<span className="text-[11px] tabular-nums text-muted-foreground">
{areaStr}
</span>
)}
</div>
<div className="mt-0.5 truncate text-[10px] text-muted-foreground/80">
{buildingTipLabel(b)}
</div>
</div>
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium",
status.tone === "ok" && "bg-emerald-100 text-emerald-900 dark:bg-emerald-950 dark:text-emerald-200",
status.tone === "warn" && "bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-200",
status.tone === "muted" && "bg-muted text-muted-foreground",
)}
>
<status.Icon className="h-3 w-3" />
{status.label}
</span>
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
</button>
);
}
// ────────────────────────────────────────────────────────── 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<ParcelDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [buildings, setBuildings] = useState<BuildingItem[] | null>(null);
const [condoOwners, setCondoOwners] = useState<CondoOwner[] | null>(null);
const [condoLoading, setCondoLoading] = useState(false);
const [cfModalOpen, setCfModalOpen] = useState(false);
// Close the CF modal whenever the user switches to a different
// parcel — keeps the modal scoped to a single decision instead of
// silently re-targeting mid-flight.
useEffect(() => {
setCfModalOpen(false);
}, [feature.cadastralRef, feature.siruta, feature.layerId]);
const authRetriedRef = useRef<boolean>(
typeof window !== "undefined" &&
sessionStorage.getItem(AUTH_RETRY_KEY) === "1",
);
const isCladiri = feature.layerId === "CLADIRI_ACTIVE";
const isTerenuri = feature.layerId === "TERENURI_ACTIVE";
// ── Hydrate detail ──────────────────────────────────────
useEffect(() => {
if (basic) return;
let cancelled = false;
setLoading(true);
setError(null);
setDetail(null);
const run = async () => {
try {
let r: Response;
if (feature.id) {
r = await fetch(`/api/gis/parcela/${encodeURIComponent(feature.id)}`);
} else {
r = await fetch(
`/api/gis/parcela/find?siruta=${encodeURIComponent(feature.siruta)}` +
`&cad=${encodeURIComponent(feature.cadastralRef)}` +
`&layerId=${encodeURIComponent(feature.layerId)}`,
);
}
if (cancelled) return;
if (r.status === 404) {
setLoading(false);
return;
}
if (r.status === 403) {
if (!authRetriedRef.current && typeof window !== "undefined") {
authRetriedRef.current = true;
sessionStorage.setItem(AUTH_RETRY_KEY, "1");
void signIn("authentik", { callbackUrl: window.location.href });
return;
}
setError("forbidden");
setLoading(false);
return;
}
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);
setLoading(false);
}
} catch {
if (!cancelled) {
setError("network_error");
setLoading(false);
}
}
};
void run();
return () => {
cancelled = true;
};
}, [feature.id, feature.siruta, feature.cadastralRef, feature.layerId, basic]);
// ── Buildings on a parcel ───────────────────────────────
// For TERENURI parcels: search by parent cadref and filter for
// CLADIRI siblings whose cadref starts with "<parent>-". 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<BuildingItem>((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<string, unknown> };
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 ──────────────────────────
// Only fires for buildings flagged IS_CONDOMINIUM=1 (or
// PARCEL_IS_CONDOMINIUM=1). For everything else the orchestrator's
// eTerra round-trip would take ~10s and come back empty — bad UX.
// We wait for `detail` to land so we can read the flag from enrichment.
useEffect(() => {
if (basic || !isCladiri || !feature.siruta || !feature.cadastralRef) return;
if (!detail) return;
const e = (detail.enrichment ?? {}) as Record<string, unknown>;
const isCondo =
Number(e.IS_CONDOMINIUM ?? 0) === 1 ||
Number(e.PARCEL_IS_CONDOMINIUM ?? 0) === 1;
if (!isCondo) {
setCondoOwners(null);
setCondoLoading(false);
return;
}
let cancelled = false;
setCondoOwners(null);
setCondoLoading(true);
const run = async () => {
try {
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) {
// Treat any failure as "no apartments known" — keep section
// visible with a note instead of hiding silently.
setCondoOwners([]);
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 {
if (!cancelled) {
setCondoOwners([]);
setCondoLoading(false);
}
}
};
void run();
return () => {
cancelled = true;
};
}, [isCladiri, feature.siruta, feature.cadastralRef, basic, detail]);
// ── Manual + auto refresh (deep enrich) ─────────────────
const refreshFromAncpi = useCallback(
async (opts: { manual?: boolean } = {}) => {
if (!feature.siruta || !feature.cadastralRef) {
setError("missing_siruta_or_cad");
return;
}
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,
// Pass layerId so orchestrator skips its dash-suffix auto-detect
// (more reliable than parsing "-C3" out of cadref). Orchestrator
// PR ships 2026-05-20 — accepts CLADIRI_ACTIVE + TERENURI_ACTIVE.
layerId: feature.layerId,
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<string, unknown>; 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);
}
},
[
feature.id,
feature.siruta,
feature.cadastralRef,
feature.layerId,
feature.areaValue,
detail?.id,
],
);
// Auto-enrich on sparse data — DISABLED per Marius. Refresh fires
// only when the user explicitly hits the "Actualizează" button in
// the Date eTerra header. Keeps the eTerra account pool out of
// browse-spam territory.
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<string, unknown>;
const hasEnrich = useMemo(
() => Object.keys(enrichment).length > 0,
[enrichment],
);
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 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 =
intravilanRaw === "da"
? "Intravilan"
: intravilanRaw === "nu"
? "Extravilan"
: hasEnrich
? "Extravilan (presupus)"
: null;
const intravilanTone =
intravilanRaw === "da"
? "success"
: intravilanRaw === "nu" || (hasEnrich && intravilanRaw === "")
? "warning"
: "default";
const categorie = String(enrichment.CATEGORIE_FOLOSINTA ?? "").trim();
const nrCorpuri = Number(enrichment.NR_CORPURI ?? 0) || 0;
const hasBuildingFlag = Number(enrichment.HAS_BUILDING ?? 0) || 0;
const buildingsCount =
nrCorpuri > 0
? nrCorpuri
: Array.isArray(buildings)
? buildings.length
: hasBuildingFlag;
// Cladire-specific (only meaningful for CLADIRI_ACTIVE)
const cladireType = String(enrichment.CLADIRE_TYPE ?? "").trim();
const cladireDestinatie = String(enrichment.CLADIRE_DESTINATIE ?? "").trim();
const cladireSubtype = String(enrichment.CLADIRE_SUBTYPE ?? "").trim();
const cladireRegim = String(enrichment.CLADIRE_REGIM ?? "").trim();
const cladireNiveluri = enrichment.CLADIRE_NIVELURI;
const cladireAn = String(enrichment.CLADIRE_AN_CONSTRUIRE ?? "").trim();
const cladireAreaCf = formatArea(enrichment.CLADIRE_AREA_CF);
const cladireObservatii = String(enrichment.CLADIRE_OBSERVATII ?? "").trim();
const cladireLandbookIe = String(enrichment.CLADIRE_LANDBOOK_IE ?? "").trim();
const cladireCommonParts = String(enrichment.CLADIRE_COMMON_PARTS ?? "").trim();
const cladireUnitsNo = enrichment.CLADIRE_UNITS_NO_ANCPI;
const cladireEnergetic = String(enrichment.CLADIRE_ENERGETIC_CLASS ?? "").trim();
const isLegalBuilding = enrichment.IS_LEGAL_BUILDING;
const isCondominium = Number(enrichment.IS_CONDOMINIUM ?? 0) === 1;
const hasCladireData =
isCladiri &&
Boolean(
cladireType ||
cladireDestinatie ||
cladireSubtype ||
cladireRegim ||
cladireAn ||
cladireAreaCf ||
cladireObservatii ||
cladireLandbookIe ||
cladireEnergetic ||
cladireNiveluri != null ||
cladireUnitsNo != null ||
isLegalBuilding != null ||
isCondominium,
);
const isActive = detail?.isActive !== false;
const cadrefHeader = feature.cadastralRef || feature.objectId || "—";
const layerLabel = feature.layerId.replace("_ACTIVE", "").toLowerCase();
const uatName = useUatName(feature.siruta);
const enrichedAgo = formatRelativeTime(detail?.enrichedAt);
// ────────────────────────────────────────────────────────
return (
<div className="w-96 max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border bg-background/95 shadow-xl backdrop-blur-md">
{/* Header */}
<div className="flex items-start justify-between gap-2 border-b px-3 pb-2 pt-2.5">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
{!basic && detail && <StatusDot active={isActive} />}
<h3 className="truncate font-mono text-base font-semibold tracking-tight">
{cadrefHeader}
</h3>
</div>
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">
<span className="capitalize">{layerLabel}</span>
{feature.areaValue != null && (
<span> · {formatNum(feature.areaValue)} m²</span>
)}
{uatName && (
<span> · {uatName}</span>
)}
{feature.siruta && (
<span
className="text-muted-foreground/70"
title={`SIRUTA ${feature.siruta}`}
> · {feature.siruta}</span>
)}
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Închide"
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{basic && (
<div className="px-3 py-2.5 text-xs text-muted-foreground">
Acces restricționat afișăm doar identificatorul cadastral și
suprafața GIS. Contactează administratorul pentru drepturi
extinse.
</div>
)}
{!basic && (
<div className="max-h-[70vh] overflow-y-auto">
{loading && (
<div className="flex items-center gap-2 px-3 py-3 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span>Se încarcă datele parcelei</span>
</div>
)}
{refreshing && !loading && (
<div className="flex items-center gap-2 border-b bg-muted/20 px-3 py-1.5 text-[11px] text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
<span>Se preiau date suplimentare din ANCPI</span>
</div>
)}
{error === "forbidden" && (
<div className="m-2 flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-50/50 p-2 text-xs text-amber-900 dark:bg-amber-950/20 dark:text-amber-200">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>Datele detaliate nu pot fi încărcate momentan.</span>
</div>
)}
{error && error !== "forbidden" && (
<div className="m-2 flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-xs text-destructive">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>
{error === "no_available_account"
? "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"
? isCladiri
? "Deep-enrich nu suportă încă construcțiile — datele clădirii vin via parcela părinte (gis-api orchestrator side)."
: "Parcela nu există în baza centrală gis_core."
: error === "eterra_fetch_failed"
? "eTerra ANCPI nu răspunde momentan. Reîncearcă în 1-2 minute."
: error === "search_limit_exceeded"
? "Numărul cadastral e foarte comun (sute de parcele). gis-api are limit 50 la căutare — aceasta nu apare."
: `Eroare: ${error}`}
</span>
</div>
)}
{/* Caracteristici chips */}
{!loading && detail && (
<div className="border-b px-2 py-1.5">
<div className="flex flex-wrap items-center gap-1.5">
{intravilanLabel ? (
<Chip
tone={intravilanTone}
icon={<Home className="h-3 w-3" />}
>
{intravilanLabel}
</Chip>
) : (
<Chip tone="muted">Intravilan ?</Chip>
)}
{categorie && (
<Chip title={`Categorie folosință: ${categorie}`}>
{categorie}
</Chip>
)}
{buildingsCount > 0 && (
<Chip
icon={<Building className="h-3 w-3" />}
title={`${buildingsCount} construcție${buildingsCount > 1 ? "i" : ""} pe parcelă`}
>
{buildingsCount} corp{buildingsCount > 1 ? "uri" : ""}
</Chip>
)}
</div>
</div>
)}
{/* Metric strip — Suprafețe */}
{!loading && detail && (areaGisStr || area2D || areaLegala) && (
<div className="border-b px-2 py-1.5">
<div className="flex items-stretch divide-x divide-border/60 rounded-md bg-muted/30">
<MetricCell label="GIS" value={areaGisStr} hint="Suprafață calculată din poligon" />
<MetricCell label="2D eTerra" value={area2D} hint="Suprafață 2D din eTerra" />
<MetricCell label="Legală" value={areaLegala} hint="Suprafață legală (acte)" />
</div>
</div>
)}
{/* Date eTerra card */}
{!loading && detail && (
<div className="mx-2 my-2 rounded-md border bg-muted/15">
<div className="flex items-center justify-between gap-2 border-b px-2.5 py-1.5">
<p className="flex shrink-0 items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<Sparkles className="h-2.5 w-2.5" />
Date eTerra
</p>
<button
type="button"
onClick={() => refreshFromAncpi({ manual: true })}
disabled={refreshing || !feature.siruta || !feature.cadastralRef}
className={cn(
"ml-auto inline-flex h-7 shrink-0 items-center gap-1.5 whitespace-nowrap rounded px-2 text-[11px] font-medium transition-colors",
"border bg-background hover:bg-muted disabled:opacity-50",
)}
>
{refreshing ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
) : (
<RefreshCw className="h-3 w-3 shrink-0" />
)}
<span>{hasEnrich ? "Actualizează" : "Încarcă din ANCPI"}</span>
</button>
</div>
<div className={cn("space-y-2 px-2.5 py-2", refreshing && "animate-pulse opacity-70")}>
{!hasEnrich && !refreshing && (
<p className="py-1 text-[11px] italic text-muted-foreground">
Date ne-încărcate apasă{" "}
<RefreshCw className="inline h-2.5 w-2.5" /> din dreapta sus.
</p>
)}
{(nrCf || nrTopo) && (
<div className="grid grid-cols-2 gap-2">
{nrCf && (
<div>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
Nr. CF
</p>
<p className="font-mono text-sm font-semibold">{nrCf}</p>
</div>
)}
{nrTopo && (
<div>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
Nr. topo
</p>
<p className="font-mono text-sm font-semibold">{nrTopo}</p>
</div>
)}
</div>
)}
{nrCfVechi && nrCfVechi !== nrCf && (
<InfoRow label="CF vechi" value={nrCfVechi} mono />
)}
{adresa && (
<div className="flex items-start gap-1.5">
<MapPin className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground" />
<p className="flex-1 text-xs leading-snug">{adresa}</p>
{feature.lat != null && feature.lng != null && (
<a
href={`https://www.google.com/maps/search/?api=1&query=${feature.lat},${feature.lng}`}
target="_blank"
rel="noopener noreferrer"
title="Deschide în Google Maps"
className="shrink-0 text-[10px] text-primary hover:underline"
>
Google Maps
</a>
)}
</div>
)}
{proprietari && (
<InfoBlock label="Proprietari" value={proprietari} />
)}
{proprietariVechi && (
<CollapsibleInfoBlock
label="Foști proprietari"
value={proprietariVechi}
/>
)}
{/* Înscriere — collapsed. Holds the most-recent application
metadata (who applied, when, what document) which is
NOT the same as current ownership. SOLICITANT lives
here, not next to PROPRIETARI, to avoid the "Bojan
Elena = proprietar?" confusion. */}
{(solicitant || tipInscriere || dataCererii || actProp) && (
<details className="group border-t pt-2">
<summary className="-mx-1 flex cursor-pointer list-none items-center gap-1 rounded px-1 py-0.5 hover:bg-muted/40">
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground transition-transform group-open:rotate-90" />
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
Înscriere
</p>
</summary>
<div className="mt-1 space-y-1 pl-4">
{solicitant && <InfoRow label="Solicitant" value={solicitant} />}
{tipInscriere && <InfoRow label="Tip înscriere" value={tipInscriere} />}
{dataCererii && <InfoRow label="Data cererii" value={dataCererii} />}
{actProp && <InfoRow label="Act proprietate" value={actProp} />}
</div>
</details>
)}
{enrichedAgo && hasEnrich && (
<p className="border-t pt-1.5 text-[10px] text-muted-foreground">
Actualizat din ANCPI · {enrichedAgo}
</p>
)}
</div>
</div>
)}
{/* Construcții list (terenuri only) */}
{!loading && isTerenuri && buildings && buildings.length > 0 && (
<div className="mx-2 my-2">
<p className="mb-1 px-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Construcții ({buildings.length})
</p>
<div className="divide-y divide-border/40 rounded-md border bg-background">
{buildings.map((b) => (
<BuildingRow
key={b.id ?? b.cadastralRef}
b={b}
onSelect={handleBuildingSelect}
/>
))}
</div>
</div>
)}
{/* Caracteristici corp (cladiri only) */}
{!loading && isCladiri && hasCladireData && (
<div className="mx-2 my-2 rounded-md border bg-muted/15">
<div className="border-b px-2.5 py-1.5">
<p className="flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<Building2 className="h-2.5 w-2.5" />
Caracteristici corp
</p>
</div>
<div className="space-y-2 px-2.5 py-2">
{/* Tip + destinatie + subtype as chips */}
<div className="flex flex-wrap items-center gap-1.5">
{cladireType && <Chip>{cladireType}</Chip>}
{cladireDestinatie && (
<Chip title={`Destinație: ${cladireDestinatie}`}>{cladireDestinatie}</Chip>
)}
{cladireSubtype && cladireSubtype !== cladireDestinatie && (
<Chip tone="muted">{cladireSubtype}</Chip>
)}
{isCondominium && (
<Chip
tone="default"
icon={<Building2 className="h-3 w-3" />}
>
Condominium{cladireUnitsNo != null && ` · ${cladireUnitsNo} u.`}
</Chip>
)}
{Number(isLegalBuilding) === 1 && (
<Chip
tone="success"
icon={<ShieldCheck className="h-3 w-3" />}
>
Cu acte
</Chip>
)}
{Number(isLegalBuilding) === 0 && (
<Chip
tone="warning"
icon={<AlertTriangle className="h-3 w-3" />}
>
Fără acte
</Chip>
)}
</div>
{/* Grid 3-col metrics: regim / niveluri / an */}
{(cladireRegim || cladireNiveluri != null || cladireAn) && (
<div className="grid grid-cols-3 gap-2 rounded-md bg-muted/30 px-1 py-1.5 text-center">
<div className="flex flex-col">
<span className="text-[9px] uppercase tracking-wider text-muted-foreground" title="Regim înălțime">
Regim
</span>
<span className="font-mono text-xs font-semibold">
{cladireRegim || "—"}
</span>
</div>
<div className="flex flex-col">
<span className="text-[9px] uppercase tracking-wider text-muted-foreground">
Niveluri
</span>
<span className="font-mono text-xs font-semibold tabular-nums">
{cladireNiveluri != null && cladireNiveluri !== ""
? String(cladireNiveluri)
: "—"}
</span>
</div>
<div className="flex flex-col">
<span className="text-[9px] uppercase tracking-wider text-muted-foreground">
An
</span>
<span className="font-mono text-xs font-semibold tabular-nums">
{cladireAn || "—"}
</span>
</div>
</div>
)}
{cladireAreaCf && (
<InfoRow label="Suprafață CF" value={cladireAreaCf} />
)}
{cladireLandbookIe && (
<InfoRow label="CF IE" value={cladireLandbookIe} mono />
)}
{cladireEnergetic && (
<InfoRow label="Clasă energetică" value={cladireEnergetic} />
)}
{cladireCommonParts && (
<InfoRow label="Părți comune" value={cladireCommonParts} />
)}
{cladireObservatii && (
<InfoBlock label="Observații" value={cladireObservatii} />
)}
</div>
</div>
)}
{/* Apartamente (cladiri only — only mounted when IS_CONDOMINIUM=1
per the gating useEffect; non-condos skip the fetch entirely
so we never sit on a 10s eTerra round-trip just to discover
there's nothing to show). */}
{!loading && isCladiri && (condoLoading || condoOwners != null) && (
<div className="mx-2 my-2">
<p className="mb-1 flex items-center gap-1 px-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<Users className="h-3 w-3" />
Apartamente
{condoOwners && condoOwners.length > 0 && (
<span className="text-muted-foreground/70">
({condoOwners.length})
</span>
)}
</p>
{condoLoading && (
<div className="flex items-center gap-1.5 px-1 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> se încarcă
</div>
)}
{!condoLoading && condoOwners != null && condoOwners.length === 0 && (
<p className="px-1 text-[11px] italic text-muted-foreground">
Fără apartamente înregistrate la ANCPI.
</p>
)}
{condoOwners && condoOwners.length > 0 && (
<div className="space-y-1.5">
{condoOwners.map((u, i) => (
<div
key={`${u.unitNo ?? u.apartmentNo ?? i}`}
className="rounded-md border border-border/60 bg-muted/30 px-2 py-1.5 text-xs"
>
<div className="flex items-center justify-between gap-2">
<span className="font-mono font-medium">
{u.unitNo ?? u.apartmentNo ?? `Unitate #${i + 1}`}
</span>
{u.area != null && (
<span className="text-muted-foreground">
{Math.round(u.area).toLocaleString("ro-RO")} mp
</span>
)}
</div>
{u.cf && (
<p className="text-[11px] text-muted-foreground">
CF: {u.cf}
</p>
)}
{Array.isArray(u.owners) && u.owners.length > 0 && (
<ul className="mt-0.5 list-inside list-disc text-[11px]">
{parseOwners(u.owners).map((o, j) => (
<li key={`${o}-${j}`}>{o}</li>
))}
</ul>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Localizare — collapsible. Coords + ObjectId for those who need
them (cadastrali, etc.). Skipped when ADRESA is shown above
(Google Maps link moves there); otherwise this is the only
way to get a map link, so keep it. */}
{feature.lat != null && feature.lng != null && (
<details className="group mx-2 my-2 rounded-md border bg-muted/15">
<summary className="-mx-0 flex cursor-pointer list-none items-center gap-1 border-b px-2.5 py-1.5 hover:bg-muted/40">
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground transition-transform group-open:rotate-90" />
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Localizare
</p>
{feature.objectId && (
<span className="ml-auto text-[10px] text-muted-foreground/70">
ID {feature.objectId}
</span>
)}
</summary>
<div className="space-y-1.5 px-2.5 py-2 text-xs">
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">WGS84</span>
<span className="font-mono tabular-nums">
{feature.lat.toFixed(6)}, {feature.lng.toFixed(6)}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">SIRUTA</span>
<span className="font-mono tabular-nums">
{feature.siruta || "—"}
</span>
</div>
<a
href={`https://www.google.com/maps/search/?api=1&query=${feature.lat},${feature.lng}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-[11px] text-primary hover:underline"
>
<MapPin className="h-3 w-3" />
Deschide în Google Maps
</a>
</div>
</details>
)}
{!loading && !detail && !error && (
<div className="m-2 rounded-md border border-dashed bg-muted/30 p-2.5 text-xs text-muted-foreground">
Parcela nu există încă în baza de date centrală. Apasă
Încarcă" în secțiunea Date eTerra pentru a o adăuga.
</div>
)}
</div>
)}
{/* Actions toolbar */}
<div className="flex flex-wrap gap-1 border-t bg-muted/30 p-1.5">
<button
type="button"
onClick={() => setCfModalOpen(true)}
disabled={!feature.cadastralRef || !feature.siruta}
className="inline-flex items-center gap-1 rounded bg-background px-2 py-1 text-[11px] font-medium hover:bg-muted disabled:opacity-50"
title="Comandă extras Carte Funciară (1 credit ePay)"
>
<FileText className="h-3 w-3" />
Comandă CF
</button>
</div>
{/* CF order modal — confirmation + animated multi-step progress */}
<CfOrderModal
open={cfModalOpen}
cadastralRef={feature.cadastralRef}
siruta={feature.siruta}
onClose={() => setCfModalOpen(false)}
/>
</div>
);
}