feat(parcel-sync): search by cadastral number with full details

Search tab now uses eTerra application API (same as the web UI):

- POST /api/eterra/search queries /api/immovable/list with exact
  identifierDetails filter + /api/documentation/data for full details
- Returns: nr cad, nr CF, CF vechi, nr topo, suprafata, intravilan,
  categorii folosinta, adresa, proprietari, solicitant
- Automatic workspace (county) resolution from SIRUTA with cache
- Support for multiple cadastral numbers (comma separated)

UI changes:
- Detail cards instead of flat ArcGIS feature table
- Copy details to clipboard button per parcel
- Add parcels to list + CSV export
- Search list with summary table + CSV download
- No more layer filter or pagination (not needed for app API)

New EterraClient methods:
- searchImmovableByIdentifier (exact cadaster lookup)
- fetchCounties / fetchAdminUnitsByCounty (workspace resolution)
This commit is contained in:
AI Assistant
2026-03-06 19:58:33 +02:00
parent c98ce81cb7
commit 540b02d8d2
3 changed files with 737 additions and 316 deletions
+300 -127
View File
@@ -1,29 +1,142 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store"; import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
import { LAYER_CATALOG } from "@/modules/parcel-sync/services/eterra-layers";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type Body = { type Body = {
siruta?: string; siruta?: string;
search?: string; search?: string; // cadastral number(s), comma or newline separated
layerId?: string;
username?: string; username?: string;
password?: string; password?: string;
}; };
/* ------------------------------------------------------------------ */
/* Workspace (county) lookup cache */
/* ------------------------------------------------------------------ */
const globalRef = globalThis as {
__eterraWorkspaceCache?: Map<string, number>;
};
const workspaceCache =
globalRef.__eterraWorkspaceCache ?? new Map<string, number>();
globalRef.__eterraWorkspaceCache = workspaceCache;
/** /**
* Live search eTerra by cadastral number / INSPIRE ID. * Resolve eTerra workspace nomenPk for a given SIRUTA.
* Queries the remote eTerra ArcGIS REST API directly (not local DB). * Strategy: fetch all counties, then for each county fetch its UATs
* until we find one whose nomenPk matches the SIRUTA.
* Results are cached globally (survives hot-reload).
*/
async function resolveWorkspace(
client: EterraClient,
siruta: string,
): Promise<number | null> {
const cached = workspaceCache.get(siruta);
if (cached !== undefined) return cached;
try {
const counties = await client.fetchCounties();
for (const county of counties) {
const countyPk = county?.nomenPk ?? county?.pk ?? county?.id;
if (!countyPk) continue;
try {
const uats = await client.fetchAdminUnitsByCounty(countyPk);
for (const uat of uats) {
const uatPk = String(uat?.nomenPk ?? uat?.pk ?? "");
if (uatPk) {
workspaceCache.set(uatPk, Number(countyPk));
}
}
// Check if our SIRUTA is now resolved
const resolved = workspaceCache.get(siruta);
if (resolved !== undefined) return resolved;
} catch {
continue;
}
}
} catch {
// fallback: can't fetch counties
}
return null;
}
/* ------------------------------------------------------------------ */
/* Helper formatters (same logic as export-bundle magic mode) */
/* ------------------------------------------------------------------ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function formatAddress(item?: any) {
const address = item?.immovableAddresses?.[0]?.address ?? null;
if (!address) return "";
const parts: string[] = [];
if (address.addressDescription) parts.push(address.addressDescription);
if (address.street) parts.push(`Str. ${address.street}`);
if (address.buildingNo) parts.push(`Nr. ${address.buildingNo}`);
if (address.locality?.name) parts.push(address.locality.name);
return parts.length ? parts.join(", ") : "";
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function normalizeIntravilan(values: string[]) {
const normalized = values
.map((v) => String(v ?? "").trim().toLowerCase())
.filter(Boolean);
const unique = new Set(normalized);
if (!unique.size) return "";
if (unique.size === 1)
return unique.has("da") ? "Da" : unique.has("nu") ? "Nu" : "Mixt";
return "Mixt";
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function formatCategories(entries: any[]) {
const map = new Map<string, number>();
for (const entry of entries) {
const key = String(entry?.categorieFolosinta ?? "").trim();
if (!key) continue;
const area = Number(entry?.suprafata ?? 0);
map.set(key, (map.get(key) ?? 0) + (Number.isFinite(area) ? area : 0));
}
return Array.from(map.entries())
.map(([k, a]) => `${k}:${a.toFixed(2).replace(/\.00$/, "")}`)
.join("; ");
}
/* ------------------------------------------------------------------ */
/* Route handler */
/* ------------------------------------------------------------------ */
export type ParcelDetail = {
nrCad: string;
nrCF: string;
nrCFVechi: string;
nrTopo: string;
intravilan: string;
categorieFolosinta: string;
adresa: string;
proprietari: string;
suprafata: number | null;
solicitant: string;
immovablePk: string;
};
/**
* POST /api/eterra/search
*
* Search eTerra by cadastral number using the application API
* (same as the eTerra web UI). Returns full parcel details:
* nr. cadastral, CF, topo, intravilan, categorii folosință,
* adresă, proprietari.
*
* Accepts one or more cadastral numbers (comma/newline separated).
*/ */
export async function POST(req: Request) { export async function POST(req: Request) {
try { try {
const body = (await req.json()) as Body; const body = (await req.json()) as Body;
const siruta = String(body.siruta ?? "").trim(); const siruta = String(body.siruta ?? "").trim();
const search = (body.search ?? "").trim(); const rawSearch = (body.search ?? "").trim();
const layerId = (body.layerId ?? "").trim();
if (!siruta || !/^\d+$/.test(siruta)) { if (!siruta || !/^\d+$/.test(siruta)) {
return NextResponse.json( return NextResponse.json(
@@ -31,9 +144,22 @@ export async function POST(req: Request) {
{ status: 400 }, { status: 400 },
); );
} }
if (!search) { if (!rawSearch) {
return NextResponse.json( return NextResponse.json(
{ error: "Termen de căutare obligatoriu" }, { error: "Număr cadastral obligatoriu" },
{ status: 400 },
);
}
// Parse multiple cadastral numbers (comma, newline, space separated)
const cadNumbers = rawSearch
.split(/[\s,;\n]+/)
.map((s) => s.trim())
.filter(Boolean);
if (cadNumbers.length === 0) {
return NextResponse.json(
{ error: "Număr cadastral obligatoriu" },
{ status: 400 }, { status: 400 },
); );
} }
@@ -56,144 +182,191 @@ export async function POST(req: Request) {
const client = await EterraClient.create(username, password); const client = await EterraClient.create(username, password);
// Decide which layers to search // Resolve workspace (county) for this SIRUTA
const searchLayers = layerId const workspaceId = await resolveWorkspace(client, siruta);
? LAYER_CATALOG.filter((l) => l.id === layerId) if (!workspaceId) {
: LAYER_CATALOG.filter((l) => return NextResponse.json(
["TERENURI_ACTIVE", "CLADIRI_ACTIVE"].includes(l.id), { error: "Nu s-a putut determina județul pentru UAT-ul selectat." },
); { status: 400 },
);
if (searchLayers.length === 0) {
return NextResponse.json({ features: [], total: 0 });
} }
// Build the search WHERE — exact or LIKE depending on input const results: ParcelDetail[] = [];
const isNumericOnly = /^\d+$/.test(search);
const escapedSearch = search.replace(/'/g, "''");
type FoundFeature = { for (const cadNr of cadNumbers) {
id: string;
layerId: string;
siruta: string;
objectId: number;
inspireId?: string;
cadastralRef?: string;
areaValue?: number;
isActive: boolean;
attributes: Record<string, unknown>;
createdAt: string;
updatedAt: string;
};
const allResults: FoundFeature[] = [];
for (const layer of searchLayers) {
try { try {
// Get available fields for this layer // 1. Search immovable by identifier (exact match)
const fields = await client.getLayerFieldNames(layer); const immResponse = await client.searchImmovableByIdentifier(
const upperFields = fields.map((f) => f.toUpperCase()); workspaceId,
siruta,
// Find admin field for siruta filter cadNr,
const adminFields = [
"ADMIN_UNIT_ID",
"SIRUTA",
"UAT_ID",
"SIRUTA_UAT",
"UAT_SIRUTA",
];
const adminField = adminFields.find((a) =>
upperFields.includes(a),
); );
if (!adminField) continue;
// Get actual casing from layer fields
const adminFieldActual =
fields[upperFields.indexOf(adminField)] ?? adminField;
// Build search conditions depending on available fields const items = immResponse?.content ?? [];
const conditions: string[] = []; if (items.length === 0) {
// No result — add placeholder so user knows it wasn't found
const hasCadRef = upperFields.includes( results.push({
"NATIONAL_CADASTRAL_REFERENCE", nrCad: cadNr,
); nrCF: "",
const hasInspire = upperFields.includes("INSPIRE_ID"); nrCFVechi: "",
const hasCadNr = upperFields.includes("NATIONAL_CADNR"); nrTopo: "",
intravilan: "",
if (hasCadRef) { categorieFolosinta: "",
const cadRefField = adresa: "",
fields[upperFields.indexOf("NATIONAL_CADASTRAL_REFERENCE")]!; proprietari: "",
if (isNumericOnly) { suprafata: null,
// Exact match for numeric cadastral numbers solicitant: "",
conditions.push(`${cadRefField}='${escapedSearch}'`); immovablePk: "",
} else { });
conditions.push( continue;
`${cadRefField} LIKE '%${escapedSearch}%'`,
);
}
} }
if (hasCadNr) {
const cadNrField = for (const item of items) {
fields[upperFields.indexOf("NATIONAL_CADNR")]!; const immPk = item?.immovablePk;
if (isNumericOnly) { const immPkStr = String(immPk ?? "");
conditions.push(`${cadNrField}='${escapedSearch}'`);
} else { // Basic data from immovable list
conditions.push(`${cadNrField} LIKE '%${escapedSearch}%'`); let nrCF = String(item?.paperLbNo ?? item?.paperCadNo ?? "");
} let nrCFVechi = "";
} let nrTopo = String(
if (hasInspire) { item?.topNo ?? item?.paperCadNo ?? "",
const inspireField =
fields[upperFields.indexOf("INSPIRE_ID")]!;
conditions.push(
`${inspireField} LIKE '%${escapedSearch}%'`,
); );
} let addressText = formatAddress(item);
let proprietari = "";
let solicitant = "";
let intravilan = "";
let categorie = "";
let suprafata: number | null = null;
if (conditions.length === 0) continue; const areaStr = item?.area ?? item?.areaValue;
if (areaStr != null) {
const parsed = Number(areaStr);
if (Number.isFinite(parsed)) suprafata = parsed;
}
const searchWhere = `${adminFieldActual}=${siruta} AND (${conditions.join(" OR ")})`; // 2. Fetch documentation data (CF, proprietari)
if (immPk) {
try {
const docResponse = await client.fetchDocumentationData(
workspaceId,
[immPk],
);
const features = await client.listLayerByWhere(layer, searchWhere, { // Extract doc details
limit: 50, const docImm = (docResponse?.immovables ?? []).find(
outFields: "*", // eslint-disable-next-line @typescript-eslint/no-explicit-any
}); (d: any) => String(d?.immovablePk) === immPkStr,
);
if (docImm) {
if (docImm.landbookIE) {
const oldCF = nrCF;
nrCF = String(docImm.landbookIE);
if (oldCF && oldCF !== nrCF) nrCFVechi = oldCF;
}
if (docImm.topNo) nrTopo = String(docImm.topNo);
if (docImm.area != null) {
const docArea = Number(docImm.area);
if (Number.isFinite(docArea)) suprafata = docArea;
}
}
const now = new Date().toISOString(); // Extract owners from partTwoRegs
for (const f of features) { const owners: string[] = [];
const attrs = f.attributes; // eslint-disable-next-line @typescript-eslint/no-explicit-any
const objId = (docResponse?.partTwoRegs ?? []).forEach((reg: any) => {
typeof attrs.OBJECTID === "number" if (
? attrs.OBJECTID String(reg?.nodeType ?? "").toUpperCase() === "P" &&
: Number(attrs.OBJECTID ?? 0); reg?.nodeName
) {
const name = String(reg.nodeName).trim();
if (name) owners.push(name);
}
});
proprietari = Array.from(new Set(owners)).join("; ");
} catch {
// Documentation fetch failed — continue with basic data
}
}
allResults.push({ // 3. Fetch application data (solicitant, folosință, intravilan)
id: `live-${layer.id}-${objId}`, if (immPk) {
layerId: layer.id, try {
siruta, const apps = await client.fetchImmAppsByImmovable(
objectId: objId, immPk,
inspireId: (attrs.INSPIRE_ID as string | undefined) ?? undefined, workspaceId,
cadastralRef: );
(attrs.NATIONAL_CADASTRAL_REFERENCE as string | undefined) ?? // Pick most recent application
(attrs.NATIONAL_CADNR as string | undefined) ?? const chosen =
undefined, // eslint-disable-next-line @typescript-eslint/no-explicit-any
areaValue: (apps ?? []).filter((a: any) => a?.dataCerere)
typeof attrs.AREA_VALUE === "number" // eslint-disable-next-line @typescript-eslint/no-explicit-any
? attrs.AREA_VALUE .sort((a: any, b: any) =>
: undefined, (b.dataCerere ?? 0) - (a.dataCerere ?? 0),
isActive: attrs.IS_ACTIVE !== 0, )[0] ?? apps?.[0];
attributes: attrs as Record<string, unknown>,
createdAt: now, if (chosen) {
updatedAt: now, solicitant = String(
chosen.solicitant ?? chosen.deponent ?? "",
);
const appId = chosen.applicationId;
if (appId) {
try {
const fol = await client.fetchParcelFolosinte(
workspaceId,
immPk,
appId,
);
intravilan = normalizeIntravilan(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(fol ?? []).map((f: any) => f?.intravilan ?? ""),
);
categorie = formatCategories(fol ?? []);
} catch {
// folosinta fetch failed
}
}
}
} catch {
// immApps fetch failed
}
}
results.push({
nrCad: String(item?.identifierDetails ?? cadNr),
nrCF,
nrCFVechi,
nrTopo,
intravilan,
categorieFolosinta: categorie,
adresa: addressText,
proprietari,
suprafata,
solicitant,
immovablePk: immPkStr,
}); });
} }
} catch { } catch {
// Skip layer on error and try next // Error for this particular cadNr — add placeholder
continue; results.push({
nrCad: cadNr,
nrCF: "",
nrCFVechi: "",
nrTopo: "",
intravilan: "",
categorieFolosinta: "",
adresa: "",
proprietari: "",
suprafata: null,
solicitant: "",
immovablePk: "",
});
} }
} }
return NextResponse.json({ return NextResponse.json({
features: allResults, results,
total: allResults.length, total: results.length,
source: "eterra-live", source: "eterra-app",
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Eroare server"; const message = error instanceof Error ? error.message : "Eroare server";
@@ -3,13 +3,10 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { import {
Search, Search,
RefreshCw,
Download, Download,
CheckCircle2, CheckCircle2,
XCircle, XCircle,
Loader2, Loader2,
ChevronLeft,
ChevronRight,
MapPin, MapPin,
Layers, Layers,
Sparkles, Sparkles,
@@ -19,6 +16,9 @@ import {
LogOut, LogOut,
Wifi, Wifi,
WifiOff, WifiOff,
ClipboardCopy,
Trash2,
Plus,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input"; import { Input } from "@/shared/components/ui/input";
@@ -45,7 +45,7 @@ import {
type LayerCategory, type LayerCategory,
type LayerCatalogItem, type LayerCatalogItem,
} from "../services/eterra-layers"; } from "../services/eterra-layers";
import type { ParcelFeature } from "../types"; import type { ParcelDetail } from "@/app/api/eterra/search/route";
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Types */ /* Types */
@@ -295,13 +295,11 @@ export function ParcelSyncModule() {
const [downloadingLayer, setDownloadingLayer] = useState<string | null>(null); const [downloadingLayer, setDownloadingLayer] = useState<string | null>(null);
/* ── Parcel search tab ──────────────────────────────────────── */ /* ── Parcel search tab ──────────────────────────────────────── */
const [features, setFeatures] = useState<ParcelFeature[]>([]); const [searchResults, setSearchResults] = useState<ParcelDetail[]>([]);
const [featuresTotal, setFeaturesTotal] = useState(0); const [searchList, setSearchList] = useState<ParcelDetail[]>([]);
const [featuresPage, setFeaturesPage] = useState(1);
const [featuresSearch, setFeaturesSearch] = useState(""); const [featuresSearch, setFeaturesSearch] = useState("");
const [featuresLayerFilter, setFeaturesLayerFilter] = useState("");
const [loadingFeatures, setLoadingFeatures] = useState(false); const [loadingFeatures, setLoadingFeatures] = useState(false);
const PAGE_SIZE = 50; const [searchError, setSearchError] = useState("");
/* ════════════════════════════════════════════════════════════ */ /* ════════════════════════════════════════════════════════════ */
/* Load UAT data + check server session on mount */ /* Load UAT data + check server session on mount */
@@ -594,67 +592,109 @@ export function ParcelSyncModule() {
); );
/* ════════════════════════════════════════════════════════════ */ /* ════════════════════════════════════════════════════════════ */
/* Load features (parcel search tab) */ /* Search parcels by cadastral number (eTerra app API) */
/* - When search term is present → live query eTerra */
/* - When search is empty → show nothing (prompt user) */
/* ════════════════════════════════════════════════════════════ */ /* ════════════════════════════════════════════════════════════ */
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const handleSearch = useCallback(async () => {
const loadFeatures = useCallback(async () => {
if (!siruta || !/^\d+$/.test(siruta)) return; if (!siruta || !/^\d+$/.test(siruta)) return;
if (!featuresSearch.trim()) { if (!featuresSearch.trim()) {
// No search term → clear results setSearchResults([]);
setFeatures([]); setSearchError("");
setFeaturesTotal(0);
return; return;
} }
setLoadingFeatures(true); setLoadingFeatures(true);
setSearchError("");
try { try {
// Live search against eTerra
const res = await fetch("/api/eterra/search", { const res = await fetch("/api/eterra/search", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
siruta, siruta,
search: featuresSearch.trim(), search: featuresSearch.trim(),
layerId: featuresLayerFilter || undefined,
}), }),
}); });
const data = (await res.json()) as { const data = (await res.json()) as {
features?: ParcelFeature[]; results?: ParcelDetail[];
total?: number; total?: number;
error?: string; error?: string;
}; };
if (data.error) { if (data.error) {
setFeatures([]); setSearchResults([]);
setFeaturesTotal(0); setSearchError(data.error);
} else { } else {
if (data.features) setFeatures(data.features); setSearchResults(data.results ?? []);
if (data.total != null) setFeaturesTotal(data.total); setSearchError("");
} }
} catch { } catch {
/* ignore */ setSearchError("Eroare de rețea.");
} }
setLoadingFeatures(false); setLoadingFeatures(false);
}, [siruta, featuresLayerFilter, featuresSearch]); }, [siruta, featuresSearch]);
// Debounced search — waits 600ms after user stops typing // No auto-search — user clicks button or presses Enter
useEffect(() => { const handleSearchKeyDown = useCallback(
if (!siruta || !/^\d+$/.test(siruta)) return; (e: React.KeyboardEvent) => {
if (!featuresSearch.trim()) { if (e.key === "Enter") {
setFeatures([]); e.preventDefault();
setFeaturesTotal(0); void handleSearch();
return; }
} },
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); [handleSearch],
searchDebounceRef.current = setTimeout(() => { );
void loadFeatures();
}, 600); // Add result(s) to list for CSV export
return () => { const addToList = useCallback(
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); (item: ParcelDetail) => {
}; setSearchList((prev) => {
}, [siruta, featuresLayerFilter, featuresSearch, loadFeatures]); if (prev.some((p) => p.nrCad === item.nrCad && p.immovablePk === item.immovablePk))
return prev;
return [...prev, item];
});
},
[],
);
const removeFromList = useCallback((nrCad: string) => {
setSearchList((prev) => prev.filter((p) => p.nrCad !== nrCad));
}, []);
// CSV export
const downloadCSV = useCallback(() => {
const items = searchList.length > 0 ? searchList : searchResults;
if (items.length === 0) return;
const headers = [
"NR_CAD",
"NR_CF",
"NR_CF_VECHI",
"NR_TOPO",
"SUPRAFATA",
"INTRAVILAN",
"CATEGORIE_FOLOSINTA",
"ADRESA",
"PROPRIETARI",
"SOLICITANT",
];
const rows = items.map((p) => [
p.nrCad,
p.nrCF,
p.nrCFVechi,
p.nrTopo,
p.suprafata != null ? String(p.suprafata) : "",
p.intravilan,
`"${(p.categorieFolosinta ?? "").replace(/"/g, '""')}"`,
`"${(p.adresa ?? "").replace(/"/g, '""')}"`,
`"${(p.proprietari ?? "").replace(/"/g, '""')}"`,
`"${(p.solicitant ?? "").replace(/"/g, '""')}"`,
]);
const csv = [headers.join(","), ...rows.map((r) => r.join(","))].join("\n");
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `parcele_${siruta}_${Date.now()}.csv`;
a.click();
URL.revokeObjectURL(url);
}, [searchList, searchResults, siruta]);
/* ════════════════════════════════════════════════════════════ */ /* ════════════════════════════════════════════════════════════ */
/* Derived data */ /* Derived data */
@@ -670,7 +710,6 @@ export function ParcelSyncModule() {
}, []); }, []);
const sirutaValid = siruta.length > 0 && /^\d+$/.test(siruta); const sirutaValid = siruta.length > 0 && /^\d+$/.test(siruta);
const totalPages = Math.ceil(featuresTotal / PAGE_SIZE);
const progressPct = const progressPct =
exportProgress?.total && exportProgress.total > 0 exportProgress?.total && exportProgress.total > 0
@@ -738,7 +777,7 @@ export function ParcelSyncModule() {
setUatQuery(label); setUatQuery(label);
setSiruta(item.siruta); setSiruta(item.siruta);
setShowUatResults(false); setShowUatResults(false);
setFeaturesPage(1); setSearchResults([]);
}} }}
> >
<span className="font-medium">{item.name}</span> <span className="font-medium">{item.name}</span>
@@ -795,170 +834,335 @@ export function ParcelSyncModule() {
</Card> </Card>
) : ( ) : (
<> <>
{/* Filters */} {/* Search input */}
<Card> <Card>
<CardContent className="pt-4"> <CardContent className="pt-4">
<div className="flex gap-3 flex-wrap items-end"> <div className="flex gap-3 items-end">
<div className="space-y-1 flex-1 min-w-[200px]"> <div className="space-y-1 flex-1">
<Label className="text-xs">Căutare</Label> <Label className="text-xs">
Numere cadastrale (separate prin virgulă sau Enter)
</Label>
<div className="relative"> <div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="Număr cadastral (ex: 62580)..." placeholder="ex: 62580 sau 62580, 62581, 62582"
className="pl-9" className="pl-9"
value={featuresSearch} value={featuresSearch}
onChange={(e) => { onChange={(e) => setFeaturesSearch(e.target.value)}
setFeaturesSearch(e.target.value); onKeyDown={handleSearchKeyDown}
setFeaturesPage(1);
}}
/> />
</div> </div>
</div> </div>
<div className="space-y-1 min-w-[200px]">
<Label className="text-xs">Layer</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={featuresLayerFilter}
onChange={(e) => {
setFeaturesLayerFilter(e.target.value);
setFeaturesPage(1);
}}
>
<option value="">Toate layerele</option>
{LAYER_CATALOG.map((l) => (
<option key={l.id} value={l.id}>
{l.label}
</option>
))}
</select>
</div>
<Button <Button
size="sm" onClick={() => void handleSearch()}
variant="outline" disabled={loadingFeatures || !featuresSearch.trim()}
onClick={loadFeatures}
disabled={loadingFeatures}
> >
{loadingFeatures ? ( {loadingFeatures ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : ( ) : (
<RefreshCw className="h-3.5 w-3.5" /> <Search className="mr-2 h-4 w-4" />
)} )}
Caută
</Button> </Button>
</div> </div>
</CardContent> {searchError && (
</Card> <p className="text-xs text-destructive mt-2">{searchError}</p>
{/* Table */}
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40">
<th className="px-4 py-2.5 text-left font-medium">
OBJECTID
</th>
<th className="px-4 py-2.5 text-left font-medium">
Ref. cadastrală
</th>
<th className="px-4 py-2.5 text-left font-medium hidden md:table-cell">
INSPIRE ID
</th>
<th className="px-4 py-2.5 text-right font-medium hidden sm:table-cell">
Suprafață
</th>
<th className="px-4 py-2.5 text-left font-medium hidden lg:table-cell">
Layer
</th>
<th className="px-4 py-2.5 text-left font-medium hidden lg:table-cell">
Actualizat
</th>
</tr>
</thead>
<tbody>
{features.length === 0 && !loadingFeatures ? (
<tr>
<td
colSpan={6}
className="px-4 py-8 text-center text-muted-foreground"
>
{featuresSearch.trim()
? "Nicio parcelă găsită în eTerra pentru căutarea curentă."
: "Introdu un număr cadastral sau INSPIRE ID pentru a căuta în eTerra."}
</td>
</tr>
) : (
features.map((f) => {
const layerLabel =
LAYER_CATALOG.find((l) => l.id === f.layerId)
?.label ?? f.layerId;
return (
<tr
key={f.id}
className="border-b hover:bg-muted/30 transition-colors"
>
<td className="px-4 py-2 font-mono text-xs">
{f.objectId}
</td>
<td className="px-4 py-2">
{f.cadastralRef ?? "—"}
</td>
<td className="px-4 py-2 hidden md:table-cell text-xs text-muted-foreground">
{f.inspireId ?? "—"}
</td>
<td className="px-4 py-2 text-right hidden sm:table-cell tabular-nums">
{formatArea(f.areaValue)}
</td>
<td className="px-4 py-2 hidden lg:table-cell">
<Badge
variant="outline"
className="text-[11px] font-normal"
>
{layerLabel}
</Badge>
</td>
<td className="px-4 py-2 hidden lg:table-cell text-xs text-muted-foreground">
{formatDate(f.updatedAt)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
{featuresTotal > PAGE_SIZE && (
<div className="flex items-center justify-between border-t px-4 py-3">
<span className="text-xs text-muted-foreground">
{featuresTotal.toLocaleString("ro-RO")} total pagina{" "}
{featuresPage} / {totalPages}
</span>
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
disabled={featuresPage <= 1}
onClick={() =>
setFeaturesPage((p) => Math.max(1, p - 1))
}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
disabled={featuresPage >= totalPages}
onClick={() => setFeaturesPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Results */}
{loadingFeatures && searchResults.length === 0 && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Loader2 className="h-10 w-10 mx-auto mb-3 animate-spin opacity-50" />
<p>Se caută în eTerra...</p>
<p className="text-xs mt-1 opacity-60">
Prima căutare pe un UAT nou poate dura ~10-30s (se încarcă lista de județe).
</p>
</CardContent>
</Card>
)}
{searchResults.length > 0 && (
<>
{/* Action bar */}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{searchResults.length} rezultat{searchResults.length > 1 ? "e" : ""}
{searchList.length > 0 && (
<span className="ml-2">
· <strong>{searchList.length}</strong> în listă
</span>
)}
</span>
<div className="flex gap-2">
{searchResults.length > 0 && (
<Button
size="sm"
variant="outline"
onClick={() => {
for (const r of searchResults) addToList(r);
}}
>
<Plus className="mr-1 h-3.5 w-3.5" />
Adaugă toate în listă
</Button>
)}
<Button
size="sm"
variant="default"
onClick={downloadCSV}
disabled={searchResults.length === 0 && searchList.length === 0}
>
<FileDown className="mr-1 h-3.5 w-3.5" />
Descarcă CSV
</Button>
</div>
</div>
{/* Detail cards */}
<div className="space-y-3">
{searchResults.map((p, idx) => (
<Card
key={`${p.nrCad}-${p.immovablePk}-${idx}`}
className={cn(
"transition-colors",
!p.immovablePk && "opacity-60",
)}
>
<CardContent className="pt-4">
<div className="flex items-start justify-between mb-3">
<div>
<h3 className="text-lg font-semibold tabular-nums">
Nr. Cad. {p.nrCad}
</h3>
{!p.immovablePk && (
<p className="text-xs text-destructive">
Parcela nu a fost găsită în eTerra.
</p>
)}
</div>
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
title="Adaugă în listă"
onClick={() => addToList(p)}
disabled={!p.immovablePk}
>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
title="Copiază detalii"
onClick={() => {
const text = [
`Nr. Cad: ${p.nrCad}`,
`Nr. CF: ${p.nrCF || "—"}`,
p.nrCFVechi ? `CF vechi: ${p.nrCFVechi}` : null,
p.nrTopo ? `Nr. Topo: ${p.nrTopo}` : null,
p.suprafata != null
? `Suprafață: ${p.suprafata.toLocaleString("ro-RO")} mp`
: null,
`Intravilan: ${p.intravilan || "—"}`,
p.categorieFolosinta
? `Categorie: ${p.categorieFolosinta}`
: null,
p.adresa ? `Adresă: ${p.adresa}` : null,
p.proprietari ? `Proprietari: ${p.proprietari}` : null,
p.solicitant ? `Solicitant: ${p.solicitant}` : null,
]
.filter(Boolean)
.join("\n");
void navigator.clipboard.writeText(text);
}}
>
<ClipboardCopy className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{p.immovablePk && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-2 text-sm">
<div>
<span className="text-xs text-muted-foreground block">
Nr. CF
</span>
<span className="font-medium">
{p.nrCF || "—"}
</span>
</div>
{p.nrCFVechi && (
<div>
<span className="text-xs text-muted-foreground block">
CF vechi
</span>
<span>{p.nrCFVechi}</span>
</div>
)}
<div>
<span className="text-xs text-muted-foreground block">
Nr. Topo
</span>
<span>{p.nrTopo || "—"}</span>
</div>
<div>
<span className="text-xs text-muted-foreground block">
Suprafață
</span>
<span className="tabular-nums">
{p.suprafata != null
? formatArea(p.suprafata)
: "—"}
</span>
</div>
<div>
<span className="text-xs text-muted-foreground block">
Intravilan
</span>
<Badge
variant={
p.intravilan === "Da"
? "default"
: p.intravilan === "Nu"
? "secondary"
: "outline"
}
className="text-[11px]"
>
{p.intravilan || "—"}
</Badge>
</div>
{p.categorieFolosinta && (
<div className="col-span-2">
<span className="text-xs text-muted-foreground block">
Categorii folosință
</span>
<span className="text-xs">
{p.categorieFolosinta}
</span>
</div>
)}
{p.adresa && (
<div className="col-span-2">
<span className="text-xs text-muted-foreground block">
Adresă
</span>
<span>{p.adresa}</span>
</div>
)}
{p.proprietari && (
<div className="col-span-2 lg:col-span-3">
<span className="text-xs text-muted-foreground block">
Proprietari
</span>
<span>{p.proprietari}</span>
</div>
)}
{p.solicitant && (
<div className="col-span-2">
<span className="text-xs text-muted-foreground block">
Solicitant
</span>
<span>{p.solicitant}</span>
</div>
)}
</div>
)}
</CardContent>
</Card>
))}
</div>
</>
)}
{/* Empty state when no search has been done */}
{searchResults.length === 0 && !loadingFeatures && !searchError && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Search className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>Introdu un număr cadastral și apasă Caută.</p>
<p className="text-xs mt-1 opacity-60">
Poți căuta mai multe parcele simultan, separate prin virgulă.
</p>
</CardContent>
</Card>
)}
{/* Saved list */}
{searchList.length > 0 && (
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">
Lista mea ({searchList.length} parcele)
</h3>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setSearchList([])}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
Golește
</Button>
<Button size="sm" onClick={downloadCSV}>
<FileDown className="mr-1 h-3.5 w-3.5" />
CSV din listă
</Button>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40">
<th className="px-3 py-2 text-left font-medium">Nr. Cad</th>
<th className="px-3 py-2 text-left font-medium">Nr. CF</th>
<th className="px-3 py-2 text-right font-medium hidden sm:table-cell">Suprafață</th>
<th className="px-3 py-2 text-left font-medium hidden md:table-cell">Proprietari</th>
<th className="px-3 py-2 w-8"></th>
</tr>
</thead>
<tbody>
{searchList.map((p) => (
<tr
key={`list-${p.nrCad}-${p.immovablePk}`}
className="border-b hover:bg-muted/30 transition-colors"
>
<td className="px-3 py-2 font-mono text-xs font-medium">
{p.nrCad}
</td>
<td className="px-3 py-2 text-xs">
{p.nrCF || "—"}
</td>
<td className="px-3 py-2 text-right hidden sm:table-cell tabular-nums text-xs">
{p.suprafata != null ? formatArea(p.suprafata) : "—"}
</td>
<td className="px-3 py-2 hidden md:table-cell text-xs truncate max-w-[300px]">
{p.proprietari || "—"}
</td>
<td className="px-3 py-2">
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0"
onClick={() => removeFromList(p.nrCad)}
>
<XCircle className="h-3.5 w-3.5" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</> </>
)} )}
</TabsContent> </TabsContent>
@@ -463,6 +463,50 @@ export class EterraClient {
return this.getRawJson(url); return this.getRawJson(url);
} }
/**
* Search immovable list by exact cadastral number (identifierDetails).
* This is the eTerra application API that the web UI uses when you type
* a cadastral number in the search box.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async searchImmovableByIdentifier(workspaceId: string | number, adminUnitId: string | number, identifierDetails: string, page = 0, size = 10): Promise<any> {
const url = `${BASE_URL}/api/immovable/list`;
const filters: Array<{ value: string | number; type: "NUMBER" | "STRING"; key: string; op: string }> = [
{ value: Number(workspaceId), type: "NUMBER", key: "workspace.nomenPk", op: "=" },
{ value: Number(adminUnitId), type: "NUMBER", key: "adminUnit.nomenPk", op: "=" },
{ value: identifierDetails, type: "STRING", key: "identifierDetails", op: "=" },
{ value: -1, type: "NUMBER", key: "inscrisCF", op: "=" },
{ value: "P", type: "STRING", key: "immovableType", op: "<>C" },
];
const payload = { filters, nrElements: size, page, sorters: [] };
return this.requestRaw(() =>
this.client.post(url, payload, {
headers: { "Content-Type": "application/json;charset=UTF-8" },
timeout: this.timeoutMs,
}),
);
}
/**
* Fetch all counties (workspaces) from eTerra nomenclature.
* Returns array of { nomenPk, name, parentNomenPk, ... }
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async fetchCounties(): Promise<any[]> {
const url = `${BASE_URL}/api/adm/nomen/COUNTY/list`;
return this.getRawJson(url);
}
/**
* Fetch administrative units (UATs) under a county workspace.
* Returns array of { nomenPk, name, parentNomenPk, ... }
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async fetchAdminUnitsByCounty(countyNomenPk: string | number): Promise<any[]> {
const url = `${BASE_URL}/api/adm/nomen/ADMINISTRATIVEUNIT/filterByParent/${countyNomenPk}`;
return this.getRawJson(url);
}
/* ---- Internals ------------------------------------------------ */ /* ---- Internals ------------------------------------------------ */
private layerQueryUrl(layer: LayerConfig) { private layerQueryUrl(layer: LayerConfig) {