71cfc29f9a
V2 panel toolbar replaces the single "Comandă CF" button with two rows:
[Încadrare] [Pl. situație] [Coord.] [DXF] ← 4 exports
[CF intern] [Extras CF] ← 2 CF flows
Each export button pops an inline modal:
- PIZ / PAD: SignAsPicker (PFA / PJA radio list, manual-add inline,
co-signer slot on PIZ) + basemap toggle (google / orto for PIZ).
- Coord / DXF: no picker — single-click download via JWT proxy.
"CF intern" is the free copycf flow from eTerra (proxied via gis-api);
"Extras CF" keeps the existing CfOrderModal (1 credit ePay). The two
modes are now visually balanced as a 2-button row.
Sign-as picker rows merge user-owned Signatory table entries with the
SIGN_AS_DEFAULT_OPTIONS env-driven fallback (org-wide hardcoded options;
defaults seed two Studii de teren entries — Tiurbe PFA + SRL PJA). New
rows added via the picker's "Adaugă autorizație" inline form write to
the Signatory table; ENV rows are read-only.
Architots side ships fully:
- prisma Signatory model + ALTER TABLE applied (per the schema-drift
feedback memory).
- /api/sign-as-options (GET, POST) + /api/sign-as-options/[id]
(PATCH, DELETE).
- /api/cf-intern/order and /api/gis/parcel/[id]/{piz,pad,coords,dxf}
proxy routes — auth check + JWT forward, stream binary back.
- gis-api thin client extended with the matching exports.* namespace.
Until the gis-api endpoints ship (next session — full spec in
docs/plans/005-gis-api-export-endpoints.md), each export proxy returns
501 "…urmează" with a Romanian message so the modal shows what's
coming instead of a hard error.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1455 lines
55 KiB
TypeScript
1455 lines
55 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,
|
|
Map as MapIcon, FileSpreadsheet, FileBox, Receipt, ScrollText,
|
|
} from "lucide-react";
|
|
import { cn } from "@/shared/lib/utils";
|
|
import { CfOrderModal } from "./cf-order-modal";
|
|
import { ExportModal, type ExportKind } from "./exports/export-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);
|
|
const [cfInternBusy, setCfInternBusy] = useState(false);
|
|
const [cfInternError, setCfInternError] = useState<string | null>(null);
|
|
const [exportKind, setExportKind] = useState<ExportKind | null>(null);
|
|
|
|
// Close the modals 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);
|
|
setExportKind(null);
|
|
setCfInternError(null);
|
|
}, [feature.cadastralRef, feature.siruta, feature.layerId]);
|
|
|
|
const handleCfIntern = useCallback(async () => {
|
|
if (!feature.cadastralRef || !feature.siruta) return;
|
|
setCfInternBusy(true);
|
|
setCfInternError(null);
|
|
try {
|
|
const res = await fetch("/api/cf-intern/order", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
nrCadastral: feature.cadastralRef,
|
|
siruta: feature.siruta,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
let msg = `Eroare HTTP ${res.status}`;
|
|
const text = await res.text();
|
|
try {
|
|
const j = JSON.parse(text);
|
|
if (typeof j.error === "string") msg = j.error;
|
|
} catch {
|
|
if (text) msg += `: ${text.slice(0, 200)}`;
|
|
}
|
|
setCfInternError(msg);
|
|
return;
|
|
}
|
|
const blob = await res.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const cd = res.headers.get("content-disposition") ?? "";
|
|
const m = /filename\*?=(?:UTF-8''|"?)([^";]+)/i.exec(cd);
|
|
const filename = m?.[1]
|
|
? decodeURIComponent(m[1])
|
|
: `cf_intern_${feature.cadastralRef}.pdf`;
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.click();
|
|
setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
|
} catch (err) {
|
|
setCfInternError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setCfInternBusy(false);
|
|
}
|
|
}, [feature.cadastralRef, feature.siruta]);
|
|
|
|
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 — exports + CF split */}
|
|
<div className="space-y-1 border-t bg-muted/30 p-1.5">
|
|
{/* 4 export buttons row: PIZ / Plan situație / Coord / DXF */}
|
|
<div className="grid grid-cols-4 gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setExportKind("piz")}
|
|
disabled={!feature.cadastralRef}
|
|
className="inline-flex flex-col items-center gap-0.5 rounded bg-background px-1 py-1.5 text-[10px] font-medium hover:bg-muted disabled:opacity-50"
|
|
title="Plan de încadrare în zonă (PDF, 1:5000)"
|
|
>
|
|
<MapIcon className="h-3.5 w-3.5" />
|
|
<span>Încadrare</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setExportKind("pad")}
|
|
disabled={!feature.cadastralRef}
|
|
className="inline-flex flex-col items-center gap-0.5 rounded bg-background px-1 py-1.5 text-[10px] font-medium hover:bg-muted disabled:opacity-50"
|
|
title="Plan de situație / amplasament (PDF)"
|
|
>
|
|
<ScrollText className="h-3.5 w-3.5" />
|
|
<span>Pl. situație</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setExportKind("coords")}
|
|
disabled={!feature.cadastralRef}
|
|
className="inline-flex flex-col items-center gap-0.5 rounded bg-background px-1 py-1.5 text-[10px] font-medium hover:bg-muted disabled:opacity-50"
|
|
title="Coordonate Stereo70 (XLSX)"
|
|
>
|
|
<FileSpreadsheet className="h-3.5 w-3.5" />
|
|
<span>Coord.</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setExportKind("dxf")}
|
|
disabled={!feature.cadastralRef}
|
|
className="inline-flex flex-col items-center gap-0.5 rounded bg-background px-1 py-1.5 text-[10px] font-medium hover:bg-muted disabled:opacity-50"
|
|
title="Export DXF (parcela + vecini)"
|
|
>
|
|
<FileBox className="h-3.5 w-3.5" />
|
|
<span>DXF</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* CF row: intern (free, ~2-3s) vs ANCPI extract (1 credit ePay) */}
|
|
<div className="grid grid-cols-2 gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={handleCfIntern}
|
|
disabled={cfInternBusy || !feature.cadastralRef || !feature.siruta}
|
|
className="inline-flex items-center justify-center gap-1 rounded bg-background px-2 py-1.5 text-[11px] font-medium hover:bg-muted disabled:opacity-50"
|
|
title="Descarcă extras CF intern din eTerra (gratuit)"
|
|
>
|
|
{cfInternBusy ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
<FileText className="h-3 w-3" />
|
|
)}
|
|
CF intern
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setCfModalOpen(true)}
|
|
disabled={!feature.cadastralRef || !feature.siruta}
|
|
className="inline-flex items-center justify-center gap-1 rounded bg-background px-2 py-1.5 text-[11px] font-medium hover:bg-muted disabled:opacity-50"
|
|
title="Comandă extras de Carte Funciară prin ePay (1 credit)"
|
|
>
|
|
<Receipt className="h-3 w-3" />
|
|
Extras CF
|
|
</button>
|
|
</div>
|
|
|
|
{cfInternError && (
|
|
<div className="rounded border border-destructive/40 bg-destructive/10 px-2 py-1 text-[10px] text-destructive">
|
|
{cfInternError}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* CF order modal — confirmation + animated multi-step progress */}
|
|
<CfOrderModal
|
|
open={cfModalOpen}
|
|
cadastralRef={feature.cadastralRef}
|
|
siruta={feature.siruta}
|
|
onClose={() => setCfModalOpen(false)}
|
|
/>
|
|
|
|
{/* Export modal — used by all 4 of PIZ/PAD/Coord/DXF buttons */}
|
|
{exportKind && (
|
|
<ExportModal
|
|
open
|
|
kind={exportKind}
|
|
parcelId={detail?.id ? String(detail.id) : feature.id}
|
|
cadastralRef={feature.cadastralRef}
|
|
uatName={uatName ?? null}
|
|
layerId={feature.layerId}
|
|
onClose={() => setExportKind(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|