fix(geoportal-v2): rewrite info panel — auto-fetch + sections + condo + basic mode
Root cause of B1 (panel showed "Apasă din ANCPI" even with full enrichment
in DB): PMTiles overview tiles don't carry the GisFeature uuid, only
siruta/cadastral_ref/object_id. The panel's useEffect bailed out at
`!feature.id` and never fetched. So the data was there, the UI just
refused to ask for it.
Fix: when the click feature has no uuid, the panel now calls
`/api/gis/search?q=<cadref>`, filters by layerId match, and uses the
returned id to do `parcela.get(id)`. One extra round trip (~50ms with
the trigram-idx fix from 2026-05-18). For features arriving from the
search dropdown the uuid is already known — that path is unchanged.
Panel redesign — same data shape as eterra.live, ArchiTools styling
(shadcn instead of HeroUI), single-file:
- Header: cadref + layer + area + status chip + close
- Caracteristici: intravilan + categorie folosință + nr corpuri (chips)
- Date eTerra: all enrichment fields, PII passes through gis-api scope
redaction (scope=basic → PROPRIETARI/NR_CF/DOC already null)
- Apartamente (condominium): for CLADIRI_ACTIVE clicks, fetches
/api/gis/building/condo-owners and renders units with owners + cf + area
- Localizare: click lat/lng + Google Maps link + SIRUTA echo
Two new proxy routes (thin wrappers over gis-api):
- POST /api/gis/parcel/units-fetch
- POST /api/gis/building/condo-owners
Basic-panel mode for restricted users (per Marius: "for users I don't
want to give full access to"):
- New env BASIC_PANEL_USERS (csv emails) → session.basicPanel flag
- Optional PANEL_BASIC_GLOBAL=1 to force-basic everyone
- When true, panel renders only header + cadref + suprafață + a
restriction notice; all sections + condo fetch are skipped
- Defaults to off; pilot user Marius gets full panel as before
map-viewer now forwards lngLat on click so the Localizare section has
coordinates without a second lookup.
Type-check clean. Production build (NODE_ENV=production npx next build)
passes. The dev-mode prerender error on / page is pre-existing (Next 16
useContext-null on client component during static export, unrelated).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getAuthSession } from "@/core/auth/require-auth";
|
||||||
|
import { gisApi, GisApiError, type ParcelRefBody } from "@/lib/gis-api-client";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const session = await getAuthSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: ParcelRefBody;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as ParcelRefBody;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "invalid_body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body?.siruta || !body?.cadastralRef) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "missing_fields", required: ["siruta", "cadastralRef"] },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return NextResponse.json(await gisApi.building.condoOwners(body));
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof GisApiError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.code, status: err.status, body: err.body },
|
||||||
|
{ status: err.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error("[gis-building-condo-owners] internal:", msg);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "internal_error", hint: msg.slice(0, 200) },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getAuthSession } from "@/core/auth/require-auth";
|
||||||
|
import { gisApi, GisApiError, type ParcelRefBody } from "@/lib/gis-api-client";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const session = await getAuthSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: ParcelRefBody;
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as ParcelRefBody;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "invalid_body" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body?.siruta || !body?.cadastralRef) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "missing_fields", required: ["siruta", "cadastralRef"] },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return NextResponse.json(await gisApi.parcel.unitsFetch(body));
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof GisApiError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.code, status: err.status, body: err.body },
|
||||||
|
{ status: err.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.error("[gis-parcel-units-fetch] internal:", msg);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "internal_error", hint: msg.slice(0, 200) },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { NextAuthOptions } from "next-auth";
|
|||||||
import type { JWT } from "next-auth/jwt";
|
import type { JWT } from "next-auth/jwt";
|
||||||
import AuthentikProvider from "next-auth/providers/authentik";
|
import AuthentikProvider from "next-auth/providers/authentik";
|
||||||
import { useGisAcFlag } from "@/core/feature-flags/use-gis-ac";
|
import { useGisAcFlag } from "@/core/feature-flags/use-gis-ac";
|
||||||
|
import { useBasicPanelFlag } from "@/core/feature-flags/basic-panel";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh the Authentik access_token using the stored refresh_token.
|
* Refresh the Authentik access_token using the stored refresh_token.
|
||||||
@@ -194,6 +195,7 @@ export const authOptions: NextAuthOptions = {
|
|||||||
// branch the same way server routes do (env-driven, evaluated per
|
// branch the same way server routes do (env-driven, evaluated per
|
||||||
// request so flag flip + container restart picks up without rebuild).
|
// request so flag flip + container restart picks up without rebuild).
|
||||||
(session as any).useGisAc = useGisAcFlag(session.user?.email);
|
(session as any).useGisAc = useGisAcFlag(session.user?.email);
|
||||||
|
(session as any).basicPanel = useBasicPanelFlag(session.user?.email);
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Panel-density flag for the V2 geoportal info panel.
|
||||||
|
// Users in BASIC_PANEL_USERS see ONLY cadastralRef + suprafață + status —
|
||||||
|
// no enrichment, no PII, no buildings. Everyone else gets the full panel.
|
||||||
|
// Set in Infisical /architools: BASIC_PANEL_USERS=foo@x,bar@y
|
||||||
|
|
||||||
|
const BASIC_USERS = (process.env.BASIC_PANEL_USERS || "")
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
export function useBasicPanelFlag(userEmail?: string | null): boolean {
|
||||||
|
if (process.env.PANEL_BASIC_GLOBAL === "1") return true;
|
||||||
|
if (!userEmail) return false;
|
||||||
|
return BASIC_USERS.includes(userEmail.toLowerCase());
|
||||||
|
}
|
||||||
@@ -1,19 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
X, RefreshCw, Loader2, FileText, Download, AlertCircle,
|
X, RefreshCw, Loader2, FileText, Download, AlertCircle,
|
||||||
|
Home, Building, MapPin, ChevronDown, ChevronRight, Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
export interface ClickedFeatureLite {
|
export interface ClickedFeatureLite {
|
||||||
/** GisFeature uuid — empty when tile only has object_id */
|
/** GisFeature uuid — typically empty from PMTiles overview, falls back to search-by-cadref */
|
||||||
id: string;
|
id: string;
|
||||||
objectId?: string;
|
objectId?: string;
|
||||||
siruta: string;
|
siruta: string;
|
||||||
cadastralRef: string;
|
cadastralRef: string;
|
||||||
layerId: string;
|
layerId: string;
|
||||||
areaValue?: number;
|
areaValue?: number;
|
||||||
|
/** Click point lat/lng — used by Localizare section */
|
||||||
|
lat?: number;
|
||||||
|
lng?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParcelDetail {
|
interface ParcelDetail {
|
||||||
@@ -28,101 +32,237 @@ interface ParcelDetail {
|
|||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CondoOwner {
|
||||||
|
unitNo?: string;
|
||||||
|
apartmentNo?: string;
|
||||||
|
owners?: string[];
|
||||||
|
area?: number;
|
||||||
|
cf?: string;
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
feature: ClickedFeatureLite;
|
feature: ClickedFeatureLite;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/** When true, render only header + cadastralRef + suprafață (restricted users) */
|
||||||
|
basic?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LABEL = (key: string): string => {
|
const LABEL: Record<string, string> = {
|
||||||
const map: Record<string, string> = {
|
PROPRIETARI: "Proprietari",
|
||||||
PROPRIETARI: "Proprietari",
|
PROPRIETARI_VECHI: "Proprietari anteriori",
|
||||||
PROPRIETARI_VECHI: "Proprietari vechi",
|
NR_CF: "Carte funciară",
|
||||||
NR_CF: "Nr. CF",
|
NR_CF_VECHI: "CF vechi",
|
||||||
NR_CF_VECHI: "Nr. CF vechi",
|
NR_TOPO: "Nr. topografic",
|
||||||
DOC: "Documente",
|
ADRESA: "Adresă",
|
||||||
SUPRAFATA: "Suprafață",
|
DOC: "Documente",
|
||||||
CATEGORIE_FOLOSINTA: "Categorie folosință",
|
SUPRAFATA: "Suprafață CF",
|
||||||
INTRAVILAN: "Intravilan",
|
SUPRAFATA_2D: "Suprafață 2D",
|
||||||
UAT: "UAT",
|
SUPRAFATA_R: "Suprafață reală",
|
||||||
UAT_SIRUTA: "SIRUTA",
|
CATEGORIE_FOLOSINTA: "Categorie folosință",
|
||||||
};
|
INTRAVILAN: "Intravilan",
|
||||||
return map[key] ?? key;
|
UAT: "UAT",
|
||||||
|
UAT_SIRUTA: "SIRUTA",
|
||||||
|
HAS_BUILDING: "Are clădire",
|
||||||
|
NR_CORPURI: "Nr. corpuri",
|
||||||
|
NR_CORPURI_LEGALE: "Nr. corpuri legale",
|
||||||
|
TARLA: "Tarla",
|
||||||
|
PARCELA: "Parcelă",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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",
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 formatValue(val: unknown): string {
|
function formatValue(val: unknown): string {
|
||||||
if (val == null) return "-";
|
if (val == null || val === "") return "-";
|
||||||
if (Array.isArray(val)) return val.join(", ");
|
if (Array.isArray(val)) return val.join(", ");
|
||||||
if (typeof val === "object") return JSON.stringify(val);
|
if (typeof val === "object") return JSON.stringify(val);
|
||||||
return String(val);
|
return String(val);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeatureInfoPanel({ feature, onClose }: Props) {
|
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-50 text-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-200",
|
||||||
|
warning: "border-amber-500/30 bg-amber-50 text-amber-900 dark:bg-amber-950/30 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-xs font-medium",
|
||||||
|
toneClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<p className="mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyValue({ k, v }: { k: string; v: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<dt className="text-[11px] text-muted-foreground">{k}</dt>
|
||||||
|
<dd className="break-words text-xs">{v}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) {
|
||||||
const [detail, setDetail] = useState<ParcelDetail | null>(null);
|
const [detail, setDetail] = useState<ParcelDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [condoOwners, setCondoOwners] = useState<CondoOwner[] | null>(null);
|
||||||
|
const [condoLoading, setCondoLoading] = useState(false);
|
||||||
|
const [condoExpanded, setCondoExpanded] = useState(false);
|
||||||
|
|
||||||
// Fetch detail when feature changes.
|
const isCladiri = feature.layerId === "CLADIRI_ACTIVE";
|
||||||
// If tile carries the GisFeature uuid: use the fast path (parcela.get,
|
|
||||||
// stored enrichment, no ANCPI roundtrip). If only siruta+cadastralRef are
|
// Hydrate detail. If tile carries the uuid → fast path. Otherwise look it
|
||||||
// available: skip auto-fetch — user clicks "Citește din ANCPI" to load.
|
// up via /api/gis/search by cadastralRef and pick the match on layerId.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!feature.id) {
|
if (basic) return;
|
||||||
setDetail(null);
|
|
||||||
setError(null);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setDetail(null);
|
setDetail(null);
|
||||||
fetch(`/api/gis/parcela/${encodeURIComponent(feature.id)}`)
|
|
||||||
.then(async (res) => {
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
let id = feature.id;
|
||||||
|
if (!id) {
|
||||||
|
const sr = await fetch(
|
||||||
|
`/api/gis/search?q=${encodeURIComponent(feature.cadastralRef)}&limit=20`,
|
||||||
|
);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!sr.ok) {
|
||||||
|
setError("search_failed");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sd = (await sr.json()) as {
|
||||||
|
features?: Array<{ id: string; layerId: string; cadastralRef: string }>;
|
||||||
|
};
|
||||||
|
const match = (sd.features ?? []).find(
|
||||||
|
(f) =>
|
||||||
|
f.cadastralRef === feature.cadastralRef &&
|
||||||
|
f.layerId === feature.layerId,
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
// Parcel not in central DB — show header only; user can press "Citește din ANCPI"
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
id = match.id;
|
||||||
|
}
|
||||||
|
const r = await fetch(`/api/gis/parcela/${encodeURIComponent(id)}`);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (res.status === 403) {
|
if (r.status === 403) {
|
||||||
setError("forbidden");
|
setError("forbidden");
|
||||||
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!r.ok) {
|
||||||
setError("fetch_failed");
|
setError("fetch_failed");
|
||||||
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data: ParcelDetail = await res.json();
|
const data = (await r.json()) as ParcelDetail;
|
||||||
if (!cancelled) setDetail(data);
|
if (!cancelled) {
|
||||||
})
|
setDetail(data);
|
||||||
.catch(() => {
|
setLoading(false);
|
||||||
if (!cancelled) setError("network_error");
|
}
|
||||||
})
|
} catch {
|
||||||
.finally(() => {
|
if (!cancelled) {
|
||||||
if (!cancelled) setLoading(false);
|
setError("network_error");
|
||||||
});
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void run();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [feature.id]);
|
}, [feature.id, feature.siruta, feature.cadastralRef, feature.layerId, basic]);
|
||||||
|
|
||||||
const refreshFromAncpi = async () => {
|
// For CLADIRI parcels, fetch condo owners (apartment list).
|
||||||
let siruta = feature.siruta || detail?.siruta || "";
|
useEffect(() => {
|
||||||
const cadastralRef = feature.cadastralRef || detail?.cadastralRef || "";
|
if (basic || !isCladiri || !feature.siruta || !feature.cadastralRef) return;
|
||||||
// Race fix: parcels selected from search dropdown carry id + cadref
|
let cancelled = false;
|
||||||
// but no siruta. If the parcela.get auto-fetch is still in flight (or
|
setCondoOwners(null);
|
||||||
// never fired because tile lacks uuid), hydrate siruta now.
|
setCondoLoading(true);
|
||||||
if (!siruta && feature.id) {
|
const run = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/gis/parcela/${encodeURIComponent(feature.id)}`);
|
const r = await fetch("/api/gis/building/condo-owners", {
|
||||||
if (res.ok) {
|
method: "POST",
|
||||||
const d = await res.json();
|
headers: { "Content-Type": "application/json" },
|
||||||
if (typeof d?.siruta === "string") {
|
body: JSON.stringify({
|
||||||
siruta = d.siruta;
|
siruta: feature.siruta,
|
||||||
setDetail(d);
|
cadastralRef: feature.cadastralRef,
|
||||||
}
|
}),
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!r.ok) {
|
||||||
|
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 {
|
} catch {
|
||||||
/* fall through to validation below */
|
if (!cancelled) setCondoLoading(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
if (!siruta || !cadastralRef) {
|
void run();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isCladiri, feature.siruta, feature.cadastralRef, basic]);
|
||||||
|
|
||||||
|
const refreshFromAncpi = async () => {
|
||||||
|
if (!feature.siruta || !feature.cadastralRef) {
|
||||||
setError("missing_siruta_or_cad");
|
setError("missing_siruta_or_cad");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -133,8 +273,8 @@ export function FeatureInfoPanel({ feature, onClose }: Props) {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
siruta,
|
siruta: feature.siruta,
|
||||||
cadastralRef,
|
cadastralRef: feature.cadastralRef,
|
||||||
force: true,
|
force: true,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -144,22 +284,22 @@ export function FeatureInfoPanel({ feature, onClose }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const techData = await res.json().catch(() => null);
|
const techData = await res.json().catch(() => null);
|
||||||
|
// Refresh stored detail via parcela.get if uuid known.
|
||||||
// If the tile carried a GisFeature uuid, refresh stored detail.
|
if (detail?.id || feature.id) {
|
||||||
// Otherwise project the orchestrator response into the panel directly.
|
const id = detail?.id ?? feature.id;
|
||||||
if (feature.id) {
|
|
||||||
const updated = await fetch(
|
const updated = await fetch(
|
||||||
`/api/gis/parcela/${encodeURIComponent(feature.id)}`,
|
`/api/gis/parcela/${encodeURIComponent(id)}`,
|
||||||
);
|
);
|
||||||
if (updated.ok) setDetail(await updated.json());
|
if (updated.ok) setDetail(await updated.json());
|
||||||
} else if (techData) {
|
} else if (techData) {
|
||||||
// Orchestrator returns {status, data: {…enrichment fields}} verbatim.
|
// Project the orchestrator response into the panel directly.
|
||||||
const inner =
|
const inner =
|
||||||
(techData?.data as Record<string, unknown> | undefined) ?? techData;
|
(techData?.data as Record<string, unknown> | undefined) ?? techData;
|
||||||
setDetail({
|
setDetail({
|
||||||
siruta: feature.siruta,
|
siruta: feature.siruta,
|
||||||
cadastralRef: feature.cadastralRef,
|
cadastralRef: feature.cadastralRef,
|
||||||
areaValue: feature.areaValue,
|
areaValue: feature.areaValue,
|
||||||
|
layerId: feature.layerId,
|
||||||
enrichment: inner as Record<string, unknown>,
|
enrichment: inner as Record<string, unknown>,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -178,128 +318,303 @@ export function FeatureInfoPanel({ feature, onClose }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const orderCf = () => {
|
const orderCf = () => {
|
||||||
// The map-side panel doesn't have judet / uat / credit context that the
|
|
||||||
// ePay flow needs to render properly. Redirect to the parcel-sync page
|
|
||||||
// (eTerra Parcele → ePay tab) where the user has the full UI: own ePay
|
|
||||||
// session, visible credit balance, decrement per order. Filter by the
|
|
||||||
// cadastral so the user lands on this parcel.
|
|
||||||
const cad = encodeURIComponent(feature.cadastralRef);
|
const cad = encodeURIComponent(feature.cadastralRef);
|
||||||
const url = `/parcel-sync?tab=epay&cad=${cad}`;
|
const url = `/parcel-sync?tab=epay&cad=${cad}`;
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
};
|
};
|
||||||
|
|
||||||
const enrichment = (detail?.enrichment ?? {}) as Record<string, unknown>;
|
const enrichment = (detail?.enrichment ?? {}) as Record<string, unknown>;
|
||||||
const enrichmentEntries = Object.entries(enrichment).filter(
|
const enrichmentEntries = useMemo(
|
||||||
([, v]) => v != null && (Array.isArray(v) ? v.length > 0 : v !== ""),
|
() =>
|
||||||
|
Object.entries(enrichment).filter(
|
||||||
|
([k, v]) =>
|
||||||
|
!SPECIAL_KEYS.has(k) &&
|
||||||
|
v != null &&
|
||||||
|
v !== "" &&
|
||||||
|
(Array.isArray(v) ? v.length > 0 : true),
|
||||||
|
),
|
||||||
|
[enrichment],
|
||||||
);
|
);
|
||||||
|
const hasEnrich = enrichmentEntries.length > 0;
|
||||||
|
|
||||||
|
const intravilanRaw = String(enrichment.INTRAVILAN ?? "").trim();
|
||||||
|
const intravilanLower = intravilanRaw.toLowerCase();
|
||||||
|
const intravilanLabel =
|
||||||
|
intravilanLower === "da"
|
||||||
|
? "Intravilan"
|
||||||
|
: intravilanLower === "nu"
|
||||||
|
? "Extravilan"
|
||||||
|
: hasEnrich
|
||||||
|
? "Extravilan (presupus)"
|
||||||
|
: null;
|
||||||
|
const intravilanTone =
|
||||||
|
intravilanLower === "da"
|
||||||
|
? "success"
|
||||||
|
: intravilanLower === "nu" || (hasEnrich && intravilanRaw === "")
|
||||||
|
? "warning"
|
||||||
|
: "default";
|
||||||
|
|
||||||
|
const categorie = String(enrichment.CATEGORIE_FOLOSINTA ?? "").trim();
|
||||||
|
const nrCorpuri = Number(enrichment.NR_CORPURI ?? 0) || 0;
|
||||||
|
const hasBuilding = Number(enrichment.HAS_BUILDING ?? 0) || 0;
|
||||||
|
const buildingsCount = nrCorpuri > 0 ? nrCorpuri : hasBuilding;
|
||||||
|
|
||||||
|
const statusChip =
|
||||||
|
detail?.isActive === false
|
||||||
|
? { label: "Inactiv", tone: "muted" as const }
|
||||||
|
: { label: "Activ", tone: "success" as const };
|
||||||
|
|
||||||
|
const layerLabel = feature.layerId.replace("_ACTIVE", "").toLowerCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-80 rounded-md border bg-background/95 shadow-lg backdrop-blur">
|
<div className="w-96 max-w-[calc(100vw-2rem)] rounded-lg border bg-background/95 shadow-xl backdrop-blur-md overflow-hidden">
|
||||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
{/* Header */}
|
||||||
<div className="min-w-0">
|
<div className="flex items-start justify-between border-b px-3 py-2.5">
|
||||||
<div className="truncate font-mono text-sm font-medium">
|
<div className="min-w-0 flex-1">
|
||||||
{feature.cadastralRef}
|
<div className="truncate font-mono text-base font-semibold">
|
||||||
|
{feature.cadastralRef || feature.objectId || "—"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
{feature.layerId.replace("_ACTIVE", "").toLowerCase()}
|
<span>{layerLabel}</span>
|
||||||
{feature.areaValue != null && (
|
{feature.areaValue != null && (
|
||||||
<span> · {feature.areaValue.toFixed(0)} m²</span>
|
<span>· {formatNum(feature.areaValue)} m²</span>
|
||||||
|
)}
|
||||||
|
{!basic && detail && (
|
||||||
|
<Chip
|
||||||
|
tone={statusChip.tone}
|
||||||
|
title={`Status: ${statusChip.label}`}
|
||||||
|
>
|
||||||
|
{statusChip.label}
|
||||||
|
</Chip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
aria-label="Închide"
|
||||||
|
className="ml-2 rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-96 overflow-y-auto p-3 text-sm">
|
{/* Basic mode: stop after header */}
|
||||||
{loading && (
|
{basic && (
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
<div className="px-3 py-3 text-xs text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
Acces restricționat — afișăm doar identificatorul cadastral și
|
||||||
<span>Se încarcă…</span>
|
suprafața GIS. Contactează administratorul pentru drepturi extinse.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error === "forbidden" && (
|
{/* Full mode body */}
|
||||||
<div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-50 p-2 text-xs text-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
|
{!basic && (
|
||||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
<div className="max-h-[60vh] overflow-y-auto">
|
||||||
<span>Nu ai permisiuni de citire detaliată (scope insuficient).</span>
|
{loading && (
|
||||||
</div>
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && error !== "forbidden" && (
|
{error === "forbidden" && (
|
||||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-xs text-destructive">
|
<div className="m-3 flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-50 p-2 text-xs text-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
|
||||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
<span>Eroare: {error}</span>
|
<span>Nu ai permisiuni de citire detaliată (scope insuficient).</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{error && error !== "forbidden" && (
|
||||||
|
<div className="m-3 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>Eroare: {error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{detail && !loading && (
|
{/* Caracteristici */}
|
||||||
<>
|
{!loading && detail && (
|
||||||
<dl className="space-y-1.5">
|
<div className="border-b px-3 py-2.5">
|
||||||
<div className="flex justify-between gap-2">
|
<SectionHeader>Caracteristici</SectionHeader>
|
||||||
<dt className="text-muted-foreground">SIRUTA</dt>
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<dd className="font-mono">{detail.siruta ?? "-"}</dd>
|
{intravilanLabel ? (
|
||||||
|
<Chip
|
||||||
|
tone={intravilanTone}
|
||||||
|
icon={<Home className="h-3.5 w-3.5" />}
|
||||||
|
title={
|
||||||
|
intravilanRaw === "" && hasEnrich
|
||||||
|
? "Extravilan presupus (ANCPI nu a completat câmpul)"
|
||||||
|
: `Intravilan: ${intravilanLabel}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{intravilanLabel}
|
||||||
|
</Chip>
|
||||||
|
) : (
|
||||||
|
<Chip tone="muted">Intravilan ?</Chip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{categorie && (
|
||||||
|
<Chip title={`Categorie folosință: ${categorie}`}>
|
||||||
|
{categorie}
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{buildingsCount > 0 && (
|
||||||
|
<Chip
|
||||||
|
tone="default"
|
||||||
|
icon={<Building className="h-3.5 w-3.5" />}
|
||||||
|
title={`${buildingsCount} corp${buildingsCount > 1 ? "uri" : ""} pe parcelă`}
|
||||||
|
>
|
||||||
|
{buildingsCount} corp{buildingsCount > 1 ? "uri" : ""}
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasEnrich && !loading && (
|
||||||
|
<span className="text-[11px] italic text-muted-foreground">
|
||||||
|
apasă „Citește din ANCPI" pentru date eTerra
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between gap-2">
|
</div>
|
||||||
<dt className="text-muted-foreground">Suprafață</dt>
|
)}
|
||||||
<dd>
|
|
||||||
{detail.areaValue != null
|
|
||||||
? `${detail.areaValue.toFixed(0)} m²`
|
|
||||||
: "-"}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between gap-2">
|
|
||||||
<dt className="text-muted-foreground">Activă</dt>
|
|
||||||
<dd>{detail.isActive === false ? "nu" : "da"}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{enrichmentEntries.length > 0 && (
|
{/* Date eTerra */}
|
||||||
<>
|
{!loading && detail && hasEnrich && (
|
||||||
<div className="my-2 border-t" />
|
<div className="border-b px-3 py-2.5">
|
||||||
<div className="mb-1 text-xs font-semibold text-muted-foreground">
|
<SectionHeader>Date eTerra</SectionHeader>
|
||||||
Date eTerra
|
<dl className="grid grid-cols-1 gap-2">
|
||||||
</div>
|
{enrichmentEntries.map(([k, v]) => (
|
||||||
<dl className="space-y-1.5">
|
<KeyValue
|
||||||
{enrichmentEntries.map(([k, v]) => (
|
key={k}
|
||||||
<div key={k} className="flex flex-col gap-0.5">
|
k={LABEL[k] ?? k}
|
||||||
<dt className="text-xs text-muted-foreground">
|
v={
|
||||||
{LABEL(k)}
|
<span className={cn(
|
||||||
</dt>
|
"font-mono",
|
||||||
<dd className="break-words font-mono text-xs">
|
PII_KEYS.has(k) && "text-foreground",
|
||||||
|
)}>
|
||||||
{formatValue(v)}
|
{formatValue(v)}
|
||||||
</dd>
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
{detail.enrichedAt && (
|
||||||
|
<p className="mt-1.5 text-[10px] text-muted-foreground">
|
||||||
|
Actualizat: {new Date(detail.enrichedAt).toLocaleString("ro-RO")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Apartamente (condominium) */}
|
||||||
|
{!loading && isCladiri && (condoLoading || condoOwners) && (
|
||||||
|
<div className="border-b px-3 py-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCondoExpanded((s) => !s)}
|
||||||
|
className="flex w-full items-center justify-between"
|
||||||
|
>
|
||||||
|
<SectionHeader>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
Apartamente
|
||||||
|
{condoOwners && condoOwners.length > 0 && (
|
||||||
|
<span className="text-muted-foreground/70">
|
||||||
|
({condoOwners.length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</SectionHeader>
|
||||||
|
{condoExpanded ? (
|
||||||
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{condoLoading && (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" /> se încarcă…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{condoExpanded && condoOwners && condoOwners.length === 0 && (
|
||||||
|
<p className="text-xs italic text-muted-foreground">
|
||||||
|
Fără apartamente înregistrate pentru această clădire.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{condoExpanded && condoOwners && condoOwners.length > 0 && (
|
||||||
|
<div className="mt-1 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">
|
||||||
|
{formatNum(u.area)} m²
|
||||||
|
</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]">
|
||||||
|
{u.owners.map((o, j) => (
|
||||||
|
<li key={`${o}-${j}`}>{o}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{enrichmentEntries.length === 0 && (
|
{/* Localizare */}
|
||||||
<div className="mt-2 rounded-md border border-dashed bg-muted/30 p-2 text-xs text-muted-foreground">
|
{feature.lat != null && feature.lng != null && (
|
||||||
Fără date eTerra. Apasă „Citește din ANCPI" pentru a încărca.
|
<div className="px-3 py-2.5">
|
||||||
|
<SectionHeader>Localizare</SectionHeader>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs">
|
||||||
|
<MapPin className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="font-mono">
|
||||||
|
{feature.lat.toFixed(5)}, {feature.lng.toFixed(5)}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={`https://www.google.com/maps/search/?api=1&query=${feature.lat},${feature.lng}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="ml-auto text-xs text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Google Maps
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||||
</>
|
SIRUTA: <span className="font-mono">{feature.siruta || "-"}</span>
|
||||||
)}
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!detail && !loading && !error && (
|
{/* Empty state */}
|
||||||
<div className="rounded-md border border-dashed bg-muted/30 p-2 text-xs text-muted-foreground">
|
{!loading && !detail && !error && (
|
||||||
Apasă „Citește din ANCPI" pentru a încărca detaliile parcelei.
|
<div className="m-3 rounded-md border border-dashed bg-muted/30 p-3 text-xs text-muted-foreground">
|
||||||
</div>
|
Parcela nu există încă în baza de date centrală. Apasă „Citește
|
||||||
)}
|
din ANCPI" pentru a o încărca.
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions toolbar */}
|
||||||
<div className="flex flex-wrap gap-1 border-t bg-muted/30 p-2">
|
<div className="flex flex-wrap gap-1 border-t bg-muted/30 p-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={refreshFromAncpi}
|
onClick={refreshFromAncpi}
|
||||||
disabled={refreshing}
|
disabled={refreshing || basic}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium transition-colors",
|
"inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium transition-colors",
|
||||||
"bg-background hover:bg-muted disabled:opacity-50",
|
"bg-background hover:bg-muted disabled:opacity-50",
|
||||||
@@ -315,7 +630,7 @@ export function FeatureInfoPanel({ feature, onClose }: Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={exportGpkg}
|
onClick={exportGpkg}
|
||||||
className="inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium transition-colors bg-background hover:bg-muted"
|
className="inline-flex items-center gap-1 rounded bg-background px-2 py-1 text-xs font-medium transition-colors hover:bg-muted"
|
||||||
title="Deschide în eterra.live pentru export"
|
title="Deschide în eterra.live pentru export"
|
||||||
>
|
>
|
||||||
<Download className="h-3 w-3" />
|
<Download className="h-3 w-3" />
|
||||||
@@ -324,7 +639,7 @@ export function FeatureInfoPanel({ feature, onClose }: Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={orderCf}
|
onClick={orderCf}
|
||||||
className="inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium transition-colors bg-background hover:bg-muted"
|
className="inline-flex items-center gap-1 rounded bg-background px-2 py-1 text-xs font-medium transition-colors hover:bg-muted"
|
||||||
title="Deschide ePay în parcel-sync (cont propriu, credite vizibile)"
|
title="Deschide ePay în parcel-sync (cont propriu, credite vizibile)"
|
||||||
>
|
>
|
||||||
<FileText className="h-3 w-3" />
|
<FileText className="h-3 w-3" />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useRef, useState, useCallback } from "react";
|
import { useRef, useState, useCallback } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import { BasemapSwitcher, type BasemapId } from "./basemap-switcher";
|
import { BasemapSwitcher, type BasemapId } from "./basemap-switcher";
|
||||||
import { SearchBar, type FeatureHit, type UatHit } from "./search-bar";
|
import { SearchBar, type FeatureHit, type UatHit } from "./search-bar";
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +25,8 @@ const MapViewer = dynamic(
|
|||||||
|
|
||||||
export function GeoportalV2() {
|
export function GeoportalV2() {
|
||||||
const mapRef = useRef<MapViewerHandle>(null);
|
const mapRef = useRef<MapViewerHandle>(null);
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const basicPanel = Boolean((session as { basicPanel?: boolean } | null)?.basicPanel);
|
||||||
const [basemap, setBasemap] = useState<BasemapId>("liberty");
|
const [basemap, setBasemap] = useState<BasemapId>("liberty");
|
||||||
const [clicked, setClicked] = useState<ClickedFeatureLite | null>(null);
|
const [clicked, setClicked] = useState<ClickedFeatureLite | null>(null);
|
||||||
|
|
||||||
@@ -75,6 +78,7 @@ export function GeoportalV2() {
|
|||||||
<FeatureInfoPanel
|
<FeatureInfoPanel
|
||||||
feature={clicked}
|
feature={clicked}
|
||||||
onClose={() => setClicked(null)}
|
onClose={() => setClicked(null)}
|
||||||
|
basic={basicPanel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -263,13 +263,15 @@ export const MapViewer = forwardRef<MapViewerHandle, Props>(function MapViewer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
onFeatureClick({
|
onFeatureClick({
|
||||||
id, // may be empty when tile lacks uuid; panel falls back to parcel/tech
|
id, // typically empty from PMTiles overview; panel falls back to search-by-cadref
|
||||||
objectId,
|
objectId,
|
||||||
siruta,
|
siruta,
|
||||||
cadastralRef,
|
cadastralRef,
|
||||||
layerId,
|
layerId,
|
||||||
areaValue:
|
areaValue:
|
||||||
typeof p.area_value === "number" ? (p.area_value as number) : undefined,
|
typeof p.area_value === "number" ? (p.area_value as number) : undefined,
|
||||||
|
lat: e.lngLat.lat,
|
||||||
|
lng: e.lngLat.lng,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
map.on("click", onClick);
|
map.on("click", onClick);
|
||||||
|
|||||||
Reference in New Issue
Block a user