feat(geoportal): Faza E v2 thin client (PMTiles + gis.ac)
New geoportal module flag-gated by session.useGisAc. Legacy code path preserved as GeoportalV1Legacy (rename only — same logic). When session.useGisAc=true (Infisical USE_GIS_AC=1 OR email in GIS_AC_PILOT_USERS), the page renders GeoportalV2 instead. V2 layout (851 LOC across 5 files): - map-viewer.tsx (~295 LOC): MapLibre + PMTiles src `pmtiles://pmtiles.gis.ac/overview.pmtiles`. Layers: UAT boundaries (z5, z8), parcels (gis_terenuri line + invisible hit-test fill), buildings (gis_cladiri fill+line). Click → resolves layerId from sourceLayer, emits ClickedFeatureLite (id, siruta, cadastralRef, layerId). - search-bar.tsx (~160 LOC): debounced 300ms, calls /api/gis/search, dropdown grouped by UATs / Parcele. - feature-info-panel.tsx (~270 LOC): fetches /api/gis/parcela/[id], renders enrichment block (scope-aware — 403 shown explicitly as "permisiuni insuficiente"). Buttons: "Citește din ANCPI" (POST /api/gis/parcel/tech force:true), "Export GeoPackage" (deep-link to eterra.live/harta?…&autoexport=geopackage), "Comandă CF" placeholder pending Faza F. - basemap-switcher.tsx (~40 LOC): liberty / dark / satellite / google. Dropped orto + topo50/25 (require ANCPI session — orto/topo via raster.gis.ac is TBD Sprint 2). - geoportal-v2.tsx (~85 LOC): wraps MapViewer + SearchBar + BasemapSwitcher + FeatureInfoPanel. API routes (90 LOC across 3 files): - GET /api/gis/search → gisApi.search - GET /api/gis/parcela/[id] → gisApi.parcela.get - POST /api/gis/parcel/tech → gisApi.parcel.tech (refresh ANCPI) All routes 401 if no NextAuth session, forward GisApiError status+code, hit api.gis.ac with the Authentik access_token from session. Per project_audit_correlation_echo memory: no correlationId set on client side (gis-api overwrites server-side). Cutover-bottom-right badge "gis.ac · v2" visible until full rollout for ops visibility. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
X, RefreshCw, Loader2, FileText, Download, AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
export interface ClickedFeatureLite {
|
||||
id: string;
|
||||
siruta: string;
|
||||
cadastralRef: string;
|
||||
layerId: string;
|
||||
areaValue?: number;
|
||||
}
|
||||
|
||||
interface ParcelDetail {
|
||||
id: string;
|
||||
layerId?: string;
|
||||
siruta?: string;
|
||||
cadastralRef?: string;
|
||||
areaValue?: number;
|
||||
isActive?: boolean;
|
||||
enrichment?: Record<string, unknown>;
|
||||
enrichedAt?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
feature: ClickedFeatureLite;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const LABEL = (key: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
PROPRIETARI: "Proprietari",
|
||||
PROPRIETARI_VECHI: "Proprietari vechi",
|
||||
NR_CF: "Nr. CF",
|
||||
NR_CF_VECHI: "Nr. CF vechi",
|
||||
DOC: "Documente",
|
||||
SUPRAFATA: "Suprafață",
|
||||
CATEGORIE_FOLOSINTA: "Categorie folosință",
|
||||
INTRAVILAN: "Intravilan",
|
||||
UAT: "UAT",
|
||||
UAT_SIRUTA: "SIRUTA",
|
||||
};
|
||||
return map[key] ?? key;
|
||||
};
|
||||
|
||||
function formatValue(val: unknown): string {
|
||||
if (val == null) return "-";
|
||||
if (Array.isArray(val)) return val.join(", ");
|
||||
if (typeof val === "object") return JSON.stringify(val);
|
||||
return String(val);
|
||||
}
|
||||
|
||||
export function FeatureInfoPanel({ feature, onClose }: 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);
|
||||
|
||||
// Fetch detail when feature changes
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setDetail(null);
|
||||
fetch(`/api/gis/parcela/${encodeURIComponent(feature.id)}`)
|
||||
.then(async (res) => {
|
||||
if (cancelled) return;
|
||||
if (res.status === 403) {
|
||||
setError("forbidden");
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setError("fetch_failed");
|
||||
return;
|
||||
}
|
||||
const data: ParcelDetail = await res.json();
|
||||
if (!cancelled) setDetail(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError("network_error");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [feature.id]);
|
||||
|
||||
const refreshFromAncpi = async () => {
|
||||
setRefreshing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/gis/parcel/tech", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
siruta: feature.siruta,
|
||||
cadastralRef: feature.cadastralRef,
|
||||
force: true,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
setError(body.error || "refresh_failed");
|
||||
return;
|
||||
}
|
||||
// Re-fetch parcela detail
|
||||
const updated = await fetch(
|
||||
`/api/gis/parcela/${encodeURIComponent(feature.id)}`,
|
||||
);
|
||||
if (updated.ok) setDetail(await updated.json());
|
||||
} catch {
|
||||
setError("network_error");
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportGpkg = () => {
|
||||
const url = `https://eterra.live/harta?siruta=${encodeURIComponent(
|
||||
feature.siruta,
|
||||
)}&cad=${encodeURIComponent(feature.cadastralRef)}&autoexport=geopackage`;
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const enrichment = (detail?.enrichment ?? {}) as Record<string, unknown>;
|
||||
const enrichmentEntries = Object.entries(enrichment).filter(
|
||||
([, v]) => v != null && (Array.isArray(v) ? v.length > 0 : v !== ""),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-80 rounded-md border bg-background/95 shadow-lg backdrop-blur">
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-mono text-sm font-medium">
|
||||
{feature.cadastralRef}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{feature.layerId.replace("_ACTIVE", "").toLowerCase()}
|
||||
{feature.areaValue != null && (
|
||||
<span> · {feature.areaValue.toFixed(0)} m²</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto p-3 text-sm">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Se încarcă…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error === "forbidden" && (
|
||||
<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">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>Nu ai permisiuni de citire detaliată (scope insuficient).</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && error !== "forbidden" && (
|
||||
<div className="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 && (
|
||||
<>
|
||||
<dl className="space-y-1.5">
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt className="text-muted-foreground">SIRUTA</dt>
|
||||
<dd className="font-mono">{detail.siruta ?? "-"}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<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 && (
|
||||
<>
|
||||
<div className="my-2 border-t" />
|
||||
<div className="mb-1 text-xs font-semibold text-muted-foreground">
|
||||
Date eTerra
|
||||
</div>
|
||||
<dl className="space-y-1.5">
|
||||
{enrichmentEntries.map(([k, v]) => (
|
||||
<div key={k} className="flex flex-col gap-0.5">
|
||||
<dt className="text-xs text-muted-foreground">
|
||||
{LABEL(k)}
|
||||
</dt>
|
||||
<dd className="break-words font-mono text-xs">
|
||||
{formatValue(v)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</>
|
||||
)}
|
||||
|
||||
{enrichmentEntries.length === 0 && (
|
||||
<div className="mt-2 rounded-md border border-dashed bg-muted/30 p-2 text-xs text-muted-foreground">
|
||||
Fără date eTerra. Apasă „Citește din ANCPI" pentru a încărca.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1 border-t bg-muted/30 p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={refreshFromAncpi}
|
||||
disabled={refreshing}
|
||||
className={cn(
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
{refreshing ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
)}
|
||||
Citește din ANCPI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
title="Deschide în eterra.live pentru export"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
Export GeoPackage
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium opacity-50"
|
||||
title="Va fi disponibil la Faza F"
|
||||
>
|
||||
<FileText className="h-3 w-3" />
|
||||
Comandă CF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user