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,40 @@
|
|||||||
|
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 {
|
||||||
|
const data = await gisApi.parcel.tech(body);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof GisApiError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.code, status: err.status, body: err.body },
|
||||||
|
{ status: err.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "internal_error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getAuthSession } from "@/core/auth/require-auth";
|
||||||
|
import { gisApi, GisApiError } from "@/lib/gis-api-client";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const session = await getAuthSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
if (!id) {
|
||||||
|
return NextResponse.json({ error: "missing_id" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await gisApi.parcela.get(id);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof GisApiError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.code, status: err.status },
|
||||||
|
{ status: err.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "internal_error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getAuthSession } from "@/core/auth/require-auth";
|
||||||
|
import { gisApi, GisApiError } from "@/lib/gis-api-client";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const session = await getAuthSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const q = searchParams.get("q")?.trim() ?? "";
|
||||||
|
const limitRaw = Number(searchParams.get("limit") ?? "20");
|
||||||
|
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, 100) : 20;
|
||||||
|
|
||||||
|
if (q.length < 2) {
|
||||||
|
return NextResponse.json({ q, uats: [], features: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await gisApi.search(q, limit);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof GisApiError) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.code, status: err.status },
|
||||||
|
{ status: err.status },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ error: "internal_error" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useRef, useCallback } from "react";
|
import { useState, useRef, useCallback } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import { LayerPanel, getDefaultVisibility } from "./layer-panel";
|
import { LayerPanel, getDefaultVisibility } from "./layer-panel";
|
||||||
import { BasemapSwitcher } from "./basemap-switcher";
|
import { BasemapSwitcher } from "./basemap-switcher";
|
||||||
import { SearchBar } from "./search-bar";
|
import { SearchBar } from "./search-bar";
|
||||||
@@ -12,6 +13,7 @@ import type { MapViewerHandle } from "./map-viewer";
|
|||||||
import type {
|
import type {
|
||||||
BasemapId, ClickedFeature, LayerVisibility, SearchResult, SelectedFeature,
|
BasemapId, ClickedFeature, LayerVisibility, SearchResult, SelectedFeature,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import { GeoportalV2 } from "../v2/geoportal-v2";
|
||||||
|
|
||||||
const MapViewer = dynamic(
|
const MapViewer = dynamic(
|
||||||
() => import("./map-viewer").then((m) => ({ default: m.MapViewer })),
|
() => import("./map-viewer").then((m) => ({ default: m.MapViewer })),
|
||||||
@@ -26,6 +28,18 @@ const MapViewer = dynamic(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export function GeoportalModule() {
|
export function GeoportalModule() {
|
||||||
|
// Faza C cutover — branch on session.useGisAc (server-decided per request,
|
||||||
|
// exposed via NextAuth session callback in src/core/auth/auth-options.ts).
|
||||||
|
// Flag flip = Infisical USE_GIS_AC=1 + container force-recreate.
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const useGisAc = Boolean((session as { useGisAc?: boolean } | null)?.useGisAc);
|
||||||
|
if (useGisAc) {
|
||||||
|
return <GeoportalV2 />;
|
||||||
|
}
|
||||||
|
return <GeoportalV1Legacy />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GeoportalV1Legacy() {
|
||||||
const mapHandleRef = useRef<MapViewerHandle>(null);
|
const mapHandleRef = useRef<MapViewerHandle>(null);
|
||||||
const [basemap, setBasemap] = useState<BasemapId>("liberty");
|
const [basemap, setBasemap] = useState<BasemapId>("liberty");
|
||||||
const [layerVisibility, setLayerVisibility] = useState<LayerVisibility>(getDefaultVisibility);
|
const [layerVisibility, setLayerVisibility] = useState<LayerVisibility>(getDefaultVisibility);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
export type BasemapId = "liberty" | "dark" | "satellite" | "google";
|
||||||
|
|
||||||
|
const OPTIONS: Array<{ id: BasemapId; label: string }> = [
|
||||||
|
{ id: "liberty", label: "Liberty" },
|
||||||
|
{ id: "dark", label: "Întunecat" },
|
||||||
|
{ id: "satellite", label: "Satelit" },
|
||||||
|
{ id: "google", label: "Google" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: BasemapId;
|
||||||
|
onChange: (id: BasemapId) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BasemapSwitcher({ value, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border bg-background/95 shadow-sm backdrop-blur">
|
||||||
|
<div className="flex items-center gap-1 p-1">
|
||||||
|
{OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(opt.id)}
|
||||||
|
className={cn(
|
||||||
|
"rounded px-2 py-1 text-xs font-medium transition-colors",
|
||||||
|
value === opt.id
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState, useCallback } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { BasemapSwitcher, type BasemapId } from "./basemap-switcher";
|
||||||
|
import { SearchBar, type FeatureHit, type UatHit } from "./search-bar";
|
||||||
|
import {
|
||||||
|
FeatureInfoPanel,
|
||||||
|
type ClickedFeatureLite,
|
||||||
|
} from "./feature-info-panel";
|
||||||
|
import type { MapViewerHandle } from "./map-viewer";
|
||||||
|
|
||||||
|
const MapViewer = dynamic(
|
||||||
|
() => import("./map-viewer").then((m) => ({ default: m.MapViewer })),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex h-full items-center justify-center bg-muted/30">
|
||||||
|
<p className="text-sm text-muted-foreground">Se încarcă harta…</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export function GeoportalV2() {
|
||||||
|
const mapRef = useRef<MapViewerHandle>(null);
|
||||||
|
const [basemap, setBasemap] = useState<BasemapId>("liberty");
|
||||||
|
const [clicked, setClicked] = useState<ClickedFeatureLite | null>(null);
|
||||||
|
|
||||||
|
const handleFeatureClick = useCallback((f: ClickedFeatureLite | null) => {
|
||||||
|
setClicked(f);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUatSelect = useCallback((uat: UatHit) => {
|
||||||
|
// Search response doesn't carry bbox/centroid yet — leave panel + let user pan.
|
||||||
|
// Future: enrich search response with uat bounding box, then flyTo.
|
||||||
|
console.info("UAT selected:", uat.siruta, uat.name);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFeatureSelect = useCallback((f: FeatureHit) => {
|
||||||
|
// Show panel directly (feature ID is known)
|
||||||
|
setClicked({
|
||||||
|
id: f.id,
|
||||||
|
siruta: "",
|
||||||
|
cadastralRef: f.cadastralRef,
|
||||||
|
layerId: f.layerId,
|
||||||
|
areaValue: f.areaValue,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<MapViewer
|
||||||
|
ref={mapRef}
|
||||||
|
basemap={basemap}
|
||||||
|
onFeatureClick={handleFeatureClick}
|
||||||
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Top-left: search */}
|
||||||
|
<div className="absolute left-3 top-3 z-10 flex flex-col gap-2">
|
||||||
|
<SearchBar
|
||||||
|
onUatSelect={handleUatSelect}
|
||||||
|
onFeatureSelect={handleFeatureSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top-right: basemap + panel */}
|
||||||
|
<div className="absolute right-14 top-3 z-10 flex flex-col items-end gap-2">
|
||||||
|
<BasemapSwitcher value={basemap} onChange={setBasemap} />
|
||||||
|
{clicked && (
|
||||||
|
<FeatureInfoPanel
|
||||||
|
feature={clicked}
|
||||||
|
onClose={() => setClicked(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom-right: cutover badge (visible until full rollout) */}
|
||||||
|
<div className="pointer-events-none absolute bottom-2 right-2 z-10 rounded bg-primary/90 px-2 py-0.5 text-[10px] font-medium text-primary-foreground shadow">
|
||||||
|
gis.ac · v2
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useEffect, useRef, useState, useImperativeHandle, forwardRef, useCallback,
|
||||||
|
} from "react";
|
||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import { Protocol as PmtilesProtocol } from "pmtiles";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import type { BasemapId } from "./basemap-switcher";
|
||||||
|
import type { ClickedFeatureLite } from "./feature-info-panel";
|
||||||
|
|
||||||
|
// Ensure MapLibre CSS loaded (static import fails in standalone build)
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
const LINK_ID = "maplibre-gl-css";
|
||||||
|
if (!document.getElementById(LINK_ID)) {
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.id = LINK_ID;
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
link.href = "/maplibre-gl.css";
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register PMTiles protocol once
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const w = window as typeof window & { __pmtilesRegistered?: boolean };
|
||||||
|
if (!w.__pmtilesRegistered) {
|
||||||
|
const proto = new PmtilesProtocol();
|
||||||
|
maplibregl.addProtocol("pmtiles", proto.tile);
|
||||||
|
w.__pmtilesRegistered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CENTER: [number, number] = [25.0, 46.0];
|
||||||
|
const DEFAULT_ZOOM = 6;
|
||||||
|
|
||||||
|
const PMTILES_URL =
|
||||||
|
process.env.NEXT_PUBLIC_PMTILES_URL ||
|
||||||
|
"https://pmtiles.gis.ac/overview.pmtiles";
|
||||||
|
|
||||||
|
type BasemapDef =
|
||||||
|
| { type: "style"; url: string }
|
||||||
|
| { type: "raster"; tiles: string[]; attribution: string; tileSize: number; maxzoom?: number };
|
||||||
|
|
||||||
|
const BASEMAPS: Record<BasemapId, BasemapDef> = {
|
||||||
|
liberty: { type: "style", url: "https://tiles.openfreemap.org/styles/liberty" },
|
||||||
|
dark: { type: "style", url: "https://tiles.openfreemap.org/styles/dark" },
|
||||||
|
satellite: {
|
||||||
|
type: "raster",
|
||||||
|
tiles: [
|
||||||
|
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
||||||
|
],
|
||||||
|
attribution: '© <a href="https://www.esri.com">Esri</a>, Maxar',
|
||||||
|
tileSize: 256,
|
||||||
|
},
|
||||||
|
google: {
|
||||||
|
type: "raster",
|
||||||
|
tiles: ["https://mt0.google.com/vt/lyrs=s&x={x}&y={y}&z={z}"],
|
||||||
|
attribution: "© Google",
|
||||||
|
tileSize: 256,
|
||||||
|
maxzoom: 20,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const PM_SRC = "gis-pmtiles";
|
||||||
|
|
||||||
|
function buildStyle(b: BasemapDef): maplibregl.StyleSpecification | string {
|
||||||
|
if (b.type === "style") return b.url;
|
||||||
|
return {
|
||||||
|
version: 8 as const,
|
||||||
|
sources: {
|
||||||
|
basemap: {
|
||||||
|
type: "raster" as const,
|
||||||
|
tiles: b.tiles,
|
||||||
|
tileSize: b.tileSize,
|
||||||
|
attribution: b.attribution,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: "basemap-tiles",
|
||||||
|
type: "raster" as const,
|
||||||
|
source: "basemap",
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: b.maxzoom ?? 19,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MapViewerHandle = {
|
||||||
|
flyTo: (center: [number, number], zoom?: number) => void;
|
||||||
|
getMap: () => maplibregl.Map | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
basemap: BasemapId;
|
||||||
|
onFeatureClick: (f: ClickedFeatureLite | null) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MapViewer = forwardRef<MapViewerHandle, Props>(function MapViewer(
|
||||||
|
{ basemap, onFeatureClick, className },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
|
const viewStateRef = useRef({ center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM });
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
const addGisLayers = useCallback((map: maplibregl.Map) => {
|
||||||
|
if (map.getSource(PM_SRC)) return;
|
||||||
|
|
||||||
|
map.addSource(PM_SRC, {
|
||||||
|
type: "vector",
|
||||||
|
url: `pmtiles://${PMTILES_URL}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// UAT boundaries — visible at every zoom, more prominent on low/mid zoom
|
||||||
|
map.addLayer({
|
||||||
|
id: "v2-uats-z5",
|
||||||
|
type: "line",
|
||||||
|
source: PM_SRC,
|
||||||
|
"source-layer": "gis_uats_z5",
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: 8,
|
||||||
|
paint: {
|
||||||
|
"line-color": "#9ca3af",
|
||||||
|
"line-width": 0.8,
|
||||||
|
"line-opacity": 0.6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
id: "v2-uats-z8",
|
||||||
|
type: "line",
|
||||||
|
source: PM_SRC,
|
||||||
|
"source-layer": "gis_uats_z8",
|
||||||
|
minzoom: 8,
|
||||||
|
maxzoom: 13,
|
||||||
|
paint: {
|
||||||
|
"line-color": "#6b7280",
|
||||||
|
"line-width": 1.2,
|
||||||
|
"line-opacity": 0.7,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Buildings — fill + outline
|
||||||
|
map.addLayer({
|
||||||
|
id: "v2-cladiri-fill",
|
||||||
|
type: "fill",
|
||||||
|
source: PM_SRC,
|
||||||
|
"source-layer": "gis_cladiri",
|
||||||
|
minzoom: 13,
|
||||||
|
paint: {
|
||||||
|
"fill-color": "#fb923c",
|
||||||
|
"fill-opacity": 0.25,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
id: "v2-cladiri-line",
|
||||||
|
type: "line",
|
||||||
|
source: PM_SRC,
|
||||||
|
"source-layer": "gis_cladiri",
|
||||||
|
minzoom: 13,
|
||||||
|
paint: {
|
||||||
|
"line-color": "#ea580c",
|
||||||
|
"line-width": 0.6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parcels (terenuri) — line outline only
|
||||||
|
map.addLayer({
|
||||||
|
id: "v2-terenuri-line",
|
||||||
|
type: "line",
|
||||||
|
source: PM_SRC,
|
||||||
|
"source-layer": "gis_terenuri",
|
||||||
|
minzoom: 13,
|
||||||
|
paint: {
|
||||||
|
"line-color": "#0ea5e9",
|
||||||
|
"line-width": 0.8,
|
||||||
|
"line-opacity": 0.85,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Invisible fill for click hit-testing
|
||||||
|
map.addLayer({
|
||||||
|
id: "v2-terenuri-hit",
|
||||||
|
type: "fill",
|
||||||
|
source: PM_SRC,
|
||||||
|
"source-layer": "gis_terenuri",
|
||||||
|
minzoom: 13,
|
||||||
|
paint: {
|
||||||
|
"fill-color": "#0ea5e9",
|
||||||
|
"fill-opacity": 0.001,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initialize / rebuild on basemap change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const def = BASEMAPS[basemap];
|
||||||
|
setReady(false);
|
||||||
|
|
||||||
|
const map = new maplibregl.Map({
|
||||||
|
container: containerRef.current,
|
||||||
|
style: buildStyle(def),
|
||||||
|
center: viewStateRef.current.center,
|
||||||
|
zoom: viewStateRef.current.zoom,
|
||||||
|
maxZoom: 19,
|
||||||
|
attributionControl: { compact: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
mapRef.current = map;
|
||||||
|
|
||||||
|
map.on("moveend", () => {
|
||||||
|
const c = map.getCenter();
|
||||||
|
viewStateRef.current = {
|
||||||
|
center: [c.lng, c.lat],
|
||||||
|
zoom: map.getZoom(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
map.on("load", () => {
|
||||||
|
addGisLayers(map);
|
||||||
|
setReady(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click handler — prioritize terenuri, then cladiri
|
||||||
|
const onClick = (e: maplibregl.MapMouseEvent) => {
|
||||||
|
const feats = map.queryRenderedFeatures(e.point, {
|
||||||
|
layers: ["v2-terenuri-hit", "v2-cladiri-fill"],
|
||||||
|
});
|
||||||
|
if (feats.length === 0) {
|
||||||
|
onFeatureClick(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const f = feats[0];
|
||||||
|
if (!f?.properties) {
|
||||||
|
onFeatureClick(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = f.properties as Record<string, unknown>;
|
||||||
|
const id = (p.id as string) ?? "";
|
||||||
|
const siruta = String(p.siruta ?? "");
|
||||||
|
const cadastralRef = String(p.cadastral_ref ?? "");
|
||||||
|
const sourceLayer =
|
||||||
|
(f as unknown as { sourceLayer?: string }).sourceLayer ?? "";
|
||||||
|
const layerId =
|
||||||
|
sourceLayer === "gis_cladiri" ? "CLADIRI_ACTIVE" : "TERENURI_ACTIVE";
|
||||||
|
if (!id || !cadastralRef) {
|
||||||
|
onFeatureClick(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onFeatureClick({
|
||||||
|
id,
|
||||||
|
siruta,
|
||||||
|
cadastralRef,
|
||||||
|
layerId,
|
||||||
|
areaValue:
|
||||||
|
typeof p.area_value === "number" ? (p.area_value as number) : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
map.on("click", onClick);
|
||||||
|
map.getCanvas().style.cursor = "default";
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off("click", onClick);
|
||||||
|
map.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
};
|
||||||
|
}, [basemap, addGisLayers, onFeatureClick]);
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
flyTo: (center, zoom) => {
|
||||||
|
mapRef.current?.flyTo({
|
||||||
|
center,
|
||||||
|
zoom: zoom ?? mapRef.current.getZoom(),
|
||||||
|
essential: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getMap: () => mapRef.current,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn("h-full w-full", className)}
|
||||||
|
data-ready={ready}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
|
import { Search, Loader2, MapPin, Hash } from "lucide-react";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
|
||||||
|
export interface UatHit {
|
||||||
|
siruta: string;
|
||||||
|
name: string;
|
||||||
|
county: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeatureHit {
|
||||||
|
id: string;
|
||||||
|
layerId: string;
|
||||||
|
cadastralRef: string;
|
||||||
|
areaValue?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResponse {
|
||||||
|
q: string;
|
||||||
|
uats?: UatHit[];
|
||||||
|
features?: FeatureHit[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onUatSelect: (uat: UatHit) => void;
|
||||||
|
onFeatureSelect: (feature: FeatureHit) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchBar({ onUatSelect, onFeatureSelect }: Props) {
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [results, setResults] = useState<SearchResponse | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onClick);
|
||||||
|
return () => document.removeEventListener("mousedown", onClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runSearch = useCallback(async (query: string) => {
|
||||||
|
if (query.trim().length < 2) {
|
||||||
|
setResults(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/gis/search?q=${encodeURIComponent(query)}&limit=10`,
|
||||||
|
);
|
||||||
|
const data: SearchResponse = await res.json();
|
||||||
|
setResults(data);
|
||||||
|
setOpen(true);
|
||||||
|
} catch {
|
||||||
|
setResults({ q: query, error: "search_failed" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setQ(val);
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => runSearch(val), 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalHits =
|
||||||
|
(results?.uats?.length ?? 0) + (results?.features?.length ?? 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapperRef} className="relative w-72">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={q}
|
||||||
|
onChange={handleChange}
|
||||||
|
onFocus={() => results && setOpen(true)}
|
||||||
|
placeholder="Caută parcelă sau UAT…"
|
||||||
|
className="w-full rounded-md border bg-background/95 py-1.5 pl-8 pr-8 text-sm shadow-sm backdrop-blur focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
{loading && (
|
||||||
|
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && results && totalHits > 0 && (
|
||||||
|
<div className="absolute left-0 right-0 top-full z-20 mt-1 max-h-96 overflow-y-auto rounded-md border bg-background shadow-lg">
|
||||||
|
{results.uats && results.uats.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="bg-muted/50 px-2 py-1 text-xs font-semibold text-muted-foreground">
|
||||||
|
UAT-uri
|
||||||
|
</div>
|
||||||
|
{results.uats.map((u) => (
|
||||||
|
<button
|
||||||
|
key={u.siruta}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onUatSelect(u);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex w-full items-start gap-2 px-2 py-1.5 text-left text-sm hover:bg-muted"
|
||||||
|
>
|
||||||
|
<MapPin className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">
|
||||||
|
<span className="font-medium">{u.name}</span>
|
||||||
|
<span className="text-muted-foreground"> · {u.county}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{results.features && results.features.length > 0 && (
|
||||||
|
<div className="border-t">
|
||||||
|
<div className="bg-muted/50 px-2 py-1 text-xs font-semibold text-muted-foreground">
|
||||||
|
Parcele
|
||||||
|
</div>
|
||||||
|
{results.features.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onFeatureSelect(f);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex w-full items-start gap-2 px-2 py-1.5 text-left text-sm hover:bg-muted"
|
||||||
|
>
|
||||||
|
<Hash className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">
|
||||||
|
<span className="font-mono">{f.cadastralRef}</span>
|
||||||
|
{f.areaValue != null && (
|
||||||
|
<span className="ml-2 text-muted-foreground">
|
||||||
|
{f.areaValue.toFixed(0)} m²
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{open && results && totalHits === 0 && !loading && (
|
||||||
|
<div className="absolute left-0 right-0 top-full z-20 mt-1 rounded-md border bg-background px-3 py-2 text-sm text-muted-foreground shadow-lg">
|
||||||
|
{results.error ? "Eroare la căutare" : "Niciun rezultat"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user