feat(geoportal-v2): UAT name + SOLICITANT into Înscriere + Google Maps inline
Iteration on the info panel per Marius's feedback.
1. UAT NAME IN HEADER
New uat-lookup.ts hook loads public/uat.json (3,186 rows, ~95 KB,
one-shot fetch + Map cache + subscribers) and exposes
useUatName(siruta). Header reads:
Terenuri · 2.400 m² · FELEACU · 57582
instead of just "SIRUTA 57582". The localitate name lives in front
of the bare siruta number (muted, smaller weight) — siruta is
still there for ops + tooltip, just not the primary signal.
2. SOLICITANT MOVED INTO ÎNSCRIERE
Was rendered as a prominent User-icon line right above PROPRIETARI,
which led to "BOJAN ELENA = current owner?" confusion. The two
fields semantically differ: SOLICITANT is the person who filed the
most recent ANCPI application (e.g. the new buyer initiating a
transfer), PROPRIETARI is who's currently registered as owner. Now
SOLICITANT is collapsed into the existing Înscriere <details> next
to TIP_INSCRIERE / DATA_CERERE / ACT_PROPRIETATE — the
registration-metadata bucket where it belongs.
3. GOOGLE MAPS INLINE WITH ADDRESS
When ADRESA exists, the Google Maps text-link sits right of the
address (using feature.lat/lng for the query). One-tap go-to-map
without a separate Localizare section.
4. LOCALIZARE → COLLAPSIBLE
Bottom Localizare card becomes a closed-by-default <details>.
Inside: WGS84 lat/lng, SIRUTA, and a separate Google Maps link.
ID (objectId) shows in the summary line. Mirrors eterra.live's
approach. The redundant Feleacu/coords echo at the bottom is
gone — coords are still one click away when needed.
NOT in this commit (parked for follow-up):
- PIZ / Plan situație / Coord. / DXF actions — would mean porting
eterra.live's three /api/geoportal/{piz,pad,coords-xlsx} document
generators. Substantial work (mapbox-static-image render +
server-side PDF layout); needs its own session.
- CF intern (gratuit) vs Extras CF (1 credit) split — current
"Comandă CF" modal already handles both pool/connection states,
but the two-button visual split mirroring eterra.live's catalog-
hit fast path is a smaller follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,11 +5,12 @@ import { signIn } from "next-auth/react";
|
|||||||
import {
|
import {
|
||||||
X, RefreshCw, Loader2, FileText, AlertCircle,
|
X, RefreshCw, Loader2, FileText, AlertCircle,
|
||||||
Home, Building, Building2, MapPin, ChevronRight, Users,
|
Home, Building, Building2, MapPin, ChevronRight, Users,
|
||||||
Sparkles, User, ShieldCheck, AlertTriangle, HelpCircle,
|
Sparkles, ShieldCheck, AlertTriangle, HelpCircle,
|
||||||
Factory, Warehouse,
|
Factory, Warehouse,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/shared/lib/utils";
|
import { cn } from "@/shared/lib/utils";
|
||||||
import { CfOrderModal } from "./cf-order-modal";
|
import { CfOrderModal } from "./cf-order-modal";
|
||||||
|
import { useUatName } from "./uat-lookup";
|
||||||
|
|
||||||
const AUTH_RETRY_KEY = "gis_panel_auth_retry";
|
const AUTH_RETRY_KEY = "gis_panel_auth_retry";
|
||||||
|
|
||||||
@@ -791,6 +792,7 @@ export function FeatureInfoPanel({ feature, onClose, onSelectFeature, basic = fa
|
|||||||
|
|
||||||
const cadrefHeader = feature.cadastralRef || feature.objectId || "—";
|
const cadrefHeader = feature.cadastralRef || feature.objectId || "—";
|
||||||
const layerLabel = feature.layerId.replace("_ACTIVE", "").toLowerCase();
|
const layerLabel = feature.layerId.replace("_ACTIVE", "").toLowerCase();
|
||||||
|
const uatName = useUatName(feature.siruta);
|
||||||
|
|
||||||
const enrichedAgo = formatRelativeTime(detail?.enrichedAt);
|
const enrichedAgo = formatRelativeTime(detail?.enrichedAt);
|
||||||
|
|
||||||
@@ -811,8 +813,14 @@ export function FeatureInfoPanel({ feature, onClose, onSelectFeature, basic = fa
|
|||||||
{feature.areaValue != null && (
|
{feature.areaValue != null && (
|
||||||
<span> · {formatNum(feature.areaValue)} m²</span>
|
<span> · {formatNum(feature.areaValue)} m²</span>
|
||||||
)}
|
)}
|
||||||
|
{uatName && (
|
||||||
|
<span> · {uatName}</span>
|
||||||
|
)}
|
||||||
{feature.siruta && (
|
{feature.siruta && (
|
||||||
<span> · SIRUTA {feature.siruta}</span>
|
<span
|
||||||
|
className="text-muted-foreground/70"
|
||||||
|
title={`SIRUTA ${feature.siruta}`}
|
||||||
|
> · {feature.siruta}</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -979,14 +987,18 @@ export function FeatureInfoPanel({ feature, onClose, onSelectFeature, basic = fa
|
|||||||
{adresa && (
|
{adresa && (
|
||||||
<div className="flex items-start gap-1.5">
|
<div className="flex items-start gap-1.5">
|
||||||
<MapPin className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground" />
|
<MapPin className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground" />
|
||||||
<p className="text-xs leading-snug">{adresa}</p>
|
<p className="flex-1 text-xs leading-snug">{adresa}</p>
|
||||||
</div>
|
{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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{solicitant && (
|
|
||||||
<div className="flex items-start gap-1.5">
|
|
||||||
<User className="mt-0.5 h-3 w-3 shrink-0 text-muted-foreground" />
|
|
||||||
<p className="text-xs leading-snug">{solicitant}</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1001,7 +1013,12 @@ export function FeatureInfoPanel({ feature, onClose, onSelectFeature, basic = fa
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(tipInscriere || dataCererii || actProp) && (
|
{/* Î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">
|
<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">
|
<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" />
|
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground transition-transform group-open:rotate-90" />
|
||||||
@@ -1010,6 +1027,7 @@ export function FeatureInfoPanel({ feature, onClose, onSelectFeature, basic = fa
|
|||||||
</p>
|
</p>
|
||||||
</summary>
|
</summary>
|
||||||
<div className="mt-1 space-y-1 pl-4">
|
<div className="mt-1 space-y-1 pl-4">
|
||||||
|
{solicitant && <InfoRow label="Solicitant" value={solicitant} />}
|
||||||
{tipInscriere && <InfoRow label="Tip înscriere" value={tipInscriere} />}
|
{tipInscriere && <InfoRow label="Tip înscriere" value={tipInscriere} />}
|
||||||
{dataCererii && <InfoRow label="Data cererii" value={dataCererii} />}
|
{dataCererii && <InfoRow label="Data cererii" value={dataCererii} />}
|
||||||
{actProp && <InfoRow label="Act proprietate" value={actProp} />}
|
{actProp && <InfoRow label="Act proprietate" value={actProp} />}
|
||||||
@@ -1193,24 +1211,47 @@ export function FeatureInfoPanel({ feature, onClose, onSelectFeature, basic = fa
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Localizare */}
|
{/* 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 && (
|
{feature.lat != null && feature.lng != null && (
|
||||||
<div className="border-t px-2 py-1.5">
|
<details className="group mx-2 my-2 rounded-md border bg-muted/15">
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
<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">
|
||||||
<MapPin className="h-3 w-3 text-muted-foreground" />
|
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground transition-transform group-open:rotate-90" />
|
||||||
<span className="font-mono tabular-nums">
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{feature.lat.toFixed(5)}, {feature.lng.toFixed(5)}
|
Localizare
|
||||||
|
</p>
|
||||||
|
{feature.objectId && (
|
||||||
|
<span className="ml-auto text-[10px] text-muted-foreground/70">
|
||||||
|
ID {feature.objectId}
|
||||||
</span>
|
</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
|
<a
|
||||||
href={`https://www.google.com/maps/search/?api=1&query=${feature.lat},${feature.lng}`}
|
href={`https://www.google.com/maps/search/?api=1&query=${feature.lat},${feature.lng}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="ml-auto text-xs text-primary hover:underline"
|
className="inline-flex items-center gap-1 text-[11px] text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Google Maps
|
<MapPin className="h-3 w-3" />
|
||||||
|
Deschide în Google Maps
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !detail && !error && (
|
{!loading && !detail && !error && (
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Lazy, browser-side lookup of UAT (siruta → name).
|
||||||
|
//
|
||||||
|
// `public/uat.json` is a 3,186-row static asset (~95 KB) generated once
|
||||||
|
// from `gis_core.GisUat`. The V2 panel uses it to display the UAT
|
||||||
|
// name in the header ("FELEACU · SIRUTA 57582" instead of just "SIRUTA
|
||||||
|
// 57582"). Single fetch per tab, cached in a module-level Map.
|
||||||
|
//
|
||||||
|
// Hook callers re-render once the map resolves. No suspense, no
|
||||||
|
// loading flash — the header just upgrades from "SIRUTA 57582" to
|
||||||
|
// "FELEACU · SIRUTA 57582" the instant the fetch completes.
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type UatEntry = { siruta: string; name: string };
|
||||||
|
|
||||||
|
let cache: Map<string, string> | null = null;
|
||||||
|
let inflight: Promise<Map<string, string>> | null = null;
|
||||||
|
const subscribers = new Set<() => void>();
|
||||||
|
|
||||||
|
async function loadUatMap(): Promise<Map<string, string>> {
|
||||||
|
if (cache) return cache;
|
||||||
|
if (inflight) return inflight;
|
||||||
|
inflight = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/uat.json", { cache: "force-cache" });
|
||||||
|
if (!res.ok) throw new Error(`uat.json HTTP ${res.status}`);
|
||||||
|
const arr = (await res.json()) as UatEntry[];
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const u of arr) {
|
||||||
|
if (u?.siruta && u?.name) map.set(String(u.siruta), u.name);
|
||||||
|
}
|
||||||
|
cache = map;
|
||||||
|
subscribers.forEach((cb) => cb());
|
||||||
|
return map;
|
||||||
|
} finally {
|
||||||
|
inflight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return inflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUatName(siruta: string | undefined): string | null {
|
||||||
|
const [, force] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (cache) return;
|
||||||
|
const cb = () => force((n) => n + 1);
|
||||||
|
subscribers.add(cb);
|
||||||
|
void loadUatMap();
|
||||||
|
return () => {
|
||||||
|
subscribers.delete(cb);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
if (!siruta) return null;
|
||||||
|
return cache?.get(String(siruta)) ?? null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user