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 {
|
||||
X, RefreshCw, Loader2, FileText, AlertCircle,
|
||||
Home, Building, Building2, MapPin, ChevronRight, Users,
|
||||
Sparkles, User, ShieldCheck, AlertTriangle, HelpCircle,
|
||||
Sparkles, ShieldCheck, AlertTriangle, HelpCircle,
|
||||
Factory, Warehouse,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { CfOrderModal } from "./cf-order-modal";
|
||||
import { useUatName } from "./uat-lookup";
|
||||
|
||||
const AUTH_RETRY_KEY = "gis_panel_auth_retry";
|
||||
|
||||
@@ -791,6 +792,7 @@ export function FeatureInfoPanel({ feature, onClose, onSelectFeature, basic = fa
|
||||
|
||||
const cadrefHeader = feature.cadastralRef || feature.objectId || "—";
|
||||
const layerLabel = feature.layerId.replace("_ACTIVE", "").toLowerCase();
|
||||
const uatName = useUatName(feature.siruta);
|
||||
|
||||
const enrichedAgo = formatRelativeTime(detail?.enrichedAt);
|
||||
|
||||
@@ -811,8 +813,14 @@ export function FeatureInfoPanel({ feature, onClose, onSelectFeature, basic = fa
|
||||
{feature.areaValue != null && (
|
||||
<span> · {formatNum(feature.areaValue)} m²</span>
|
||||
)}
|
||||
{uatName && (
|
||||
<span> · {uatName}</span>
|
||||
)}
|
||||
{feature.siruta && (
|
||||
<span> · SIRUTA {feature.siruta}</span>
|
||||
<span
|
||||
className="text-muted-foreground/70"
|
||||
title={`SIRUTA ${feature.siruta}`}
|
||||
> · {feature.siruta}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -979,14 +987,18 @@ export function FeatureInfoPanel({ feature, onClose, onSelectFeature, basic = fa
|
||||
{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="text-xs leading-snug">{adresa}</p>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
<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" />
|
||||
@@ -1010,6 +1027,7 @@ export function FeatureInfoPanel({ feature, onClose, onSelectFeature, basic = fa
|
||||
</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} />}
|
||||
@@ -1193,24 +1211,47 @@ export function FeatureInfoPanel({ feature, onClose, onSelectFeature, basic = fa
|
||||
</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 && (
|
||||
<div className="border-t px-2 py-1.5">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<MapPin className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-mono tabular-nums">
|
||||
{feature.lat.toFixed(5)}, {feature.lng.toFixed(5)}
|
||||
<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="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>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{!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