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:
Claude VM
2026-05-20 17:49:46 +03:00
parent 100896a564
commit 52c31e3c4d
2 changed files with 119 additions and 21 deletions
+62 -21
View File
@@ -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>
)}
{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>
<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>
)}
@@ -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)}
</span>
<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 && (
+57
View File
@@ -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;
}