From 540b02d8d2c753c8a33dbaa2097c8549fa81604b Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Fri, 6 Mar 2026 19:58:33 +0200 Subject: [PATCH] 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) --- src/app/api/eterra/search/route.ts | 427 +++++++++---- .../components/parcel-sync-module.tsx | 582 ++++++++++++------ .../parcel-sync/services/eterra-client.ts | 44 ++ 3 files changed, 737 insertions(+), 316 deletions(-) diff --git a/src/app/api/eterra/search/route.ts b/src/app/api/eterra/search/route.ts index 99ce0e0..34439f2 100644 --- a/src/app/api/eterra/search/route.ts +++ b/src/app/api/eterra/search/route.ts @@ -1,29 +1,142 @@ import { NextResponse } from "next/server"; import { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; 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 dynamic = "force-dynamic"; type Body = { siruta?: string; - search?: string; - layerId?: string; + search?: string; // cadastral number(s), comma or newline separated username?: string; password?: string; }; +/* ------------------------------------------------------------------ */ +/* Workspace (county) lookup cache */ +/* ------------------------------------------------------------------ */ + +const globalRef = globalThis as { + __eterraWorkspaceCache?: Map; +}; +const workspaceCache = + globalRef.__eterraWorkspaceCache ?? new Map(); +globalRef.__eterraWorkspaceCache = workspaceCache; + /** - * Live search eTerra by cadastral number / INSPIRE ID. - * Queries the remote eTerra ArcGIS REST API directly (not local DB). + * Resolve eTerra workspace nomenPk for a given SIRUTA. + * 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 { + 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(); + 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) { try { const body = (await req.json()) as Body; const siruta = String(body.siruta ?? "").trim(); - const search = (body.search ?? "").trim(); - const layerId = (body.layerId ?? "").trim(); + const rawSearch = (body.search ?? "").trim(); if (!siruta || !/^\d+$/.test(siruta)) { return NextResponse.json( @@ -31,9 +144,22 @@ export async function POST(req: Request) { { status: 400 }, ); } - if (!search) { + if (!rawSearch) { 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 }, ); } @@ -56,144 +182,191 @@ export async function POST(req: Request) { const client = await EterraClient.create(username, password); - // Decide which layers to search - const searchLayers = layerId - ? LAYER_CATALOG.filter((l) => l.id === layerId) - : LAYER_CATALOG.filter((l) => - ["TERENURI_ACTIVE", "CLADIRI_ACTIVE"].includes(l.id), - ); - - if (searchLayers.length === 0) { - return NextResponse.json({ features: [], total: 0 }); + // Resolve workspace (county) for this SIRUTA + const workspaceId = await resolveWorkspace(client, siruta); + if (!workspaceId) { + return NextResponse.json( + { error: "Nu s-a putut determina județul pentru UAT-ul selectat." }, + { status: 400 }, + ); } - // Build the search WHERE — exact or LIKE depending on input - const isNumericOnly = /^\d+$/.test(search); - const escapedSearch = search.replace(/'/g, "''"); + const results: ParcelDetail[] = []; - type FoundFeature = { - id: string; - layerId: string; - siruta: string; - objectId: number; - inspireId?: string; - cadastralRef?: string; - areaValue?: number; - isActive: boolean; - attributes: Record; - createdAt: string; - updatedAt: string; - }; - - const allResults: FoundFeature[] = []; - - for (const layer of searchLayers) { + for (const cadNr of cadNumbers) { try { - // Get available fields for this layer - const fields = await client.getLayerFieldNames(layer); - const upperFields = fields.map((f) => f.toUpperCase()); - - // Find admin field for siruta filter - const adminFields = [ - "ADMIN_UNIT_ID", - "SIRUTA", - "UAT_ID", - "SIRUTA_UAT", - "UAT_SIRUTA", - ]; - const adminField = adminFields.find((a) => - upperFields.includes(a), + // 1. Search immovable by identifier (exact match) + const immResponse = await client.searchImmovableByIdentifier( + workspaceId, + siruta, + cadNr, ); - if (!adminField) continue; - // Get actual casing from layer fields - const adminFieldActual = - fields[upperFields.indexOf(adminField)] ?? adminField; - // Build search conditions depending on available fields - const conditions: string[] = []; - - const hasCadRef = upperFields.includes( - "NATIONAL_CADASTRAL_REFERENCE", - ); - const hasInspire = upperFields.includes("INSPIRE_ID"); - const hasCadNr = upperFields.includes("NATIONAL_CADNR"); - - if (hasCadRef) { - const cadRefField = - fields[upperFields.indexOf("NATIONAL_CADASTRAL_REFERENCE")]!; - if (isNumericOnly) { - // Exact match for numeric cadastral numbers - conditions.push(`${cadRefField}='${escapedSearch}'`); - } else { - conditions.push( - `${cadRefField} LIKE '%${escapedSearch}%'`, - ); - } + const items = immResponse?.content ?? []; + if (items.length === 0) { + // No result — add placeholder so user knows it wasn't found + results.push({ + nrCad: cadNr, + nrCF: "", + nrCFVechi: "", + nrTopo: "", + intravilan: "", + categorieFolosinta: "", + adresa: "", + proprietari: "", + suprafata: null, + solicitant: "", + immovablePk: "", + }); + continue; } - if (hasCadNr) { - const cadNrField = - fields[upperFields.indexOf("NATIONAL_CADNR")]!; - if (isNumericOnly) { - conditions.push(`${cadNrField}='${escapedSearch}'`); - } else { - conditions.push(`${cadNrField} LIKE '%${escapedSearch}%'`); - } - } - if (hasInspire) { - const inspireField = - fields[upperFields.indexOf("INSPIRE_ID")]!; - conditions.push( - `${inspireField} LIKE '%${escapedSearch}%'`, + + for (const item of items) { + const immPk = item?.immovablePk; + const immPkStr = String(immPk ?? ""); + + // Basic data from immovable list + let nrCF = String(item?.paperLbNo ?? item?.paperCadNo ?? ""); + let nrCFVechi = ""; + let nrTopo = String( + item?.topNo ?? item?.paperCadNo ?? "", ); - } + 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, { - limit: 50, - outFields: "*", - }); + // Extract doc details + const docImm = (docResponse?.immovables ?? []).find( + // 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(); - for (const f of features) { - const attrs = f.attributes; - const objId = - typeof attrs.OBJECTID === "number" - ? attrs.OBJECTID - : Number(attrs.OBJECTID ?? 0); + // Extract owners from partTwoRegs + const owners: string[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (docResponse?.partTwoRegs ?? []).forEach((reg: any) => { + if ( + String(reg?.nodeType ?? "").toUpperCase() === "P" && + 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({ - id: `live-${layer.id}-${objId}`, - layerId: layer.id, - siruta, - objectId: objId, - inspireId: (attrs.INSPIRE_ID as string | undefined) ?? undefined, - cadastralRef: - (attrs.NATIONAL_CADASTRAL_REFERENCE as string | undefined) ?? - (attrs.NATIONAL_CADNR as string | undefined) ?? - undefined, - areaValue: - typeof attrs.AREA_VALUE === "number" - ? attrs.AREA_VALUE - : undefined, - isActive: attrs.IS_ACTIVE !== 0, - attributes: attrs as Record, - createdAt: now, - updatedAt: now, + // 3. Fetch application data (solicitant, folosință, intravilan) + if (immPk) { + try { + const apps = await client.fetchImmAppsByImmovable( + immPk, + workspaceId, + ); + // Pick most recent application + const chosen = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (apps ?? []).filter((a: any) => a?.dataCerere) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .sort((a: any, b: any) => + (b.dataCerere ?? 0) - (a.dataCerere ?? 0), + )[0] ?? apps?.[0]; + + if (chosen) { + 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 { - // Skip layer on error and try next - continue; + // Error for this particular cadNr — add placeholder + results.push({ + nrCad: cadNr, + nrCF: "", + nrCFVechi: "", + nrTopo: "", + intravilan: "", + categorieFolosinta: "", + adresa: "", + proprietari: "", + suprafata: null, + solicitant: "", + immovablePk: "", + }); } } return NextResponse.json({ - features: allResults, - total: allResults.length, - source: "eterra-live", + results, + total: results.length, + source: "eterra-app", }); } catch (error) { const message = error instanceof Error ? error.message : "Eroare server"; diff --git a/src/modules/parcel-sync/components/parcel-sync-module.tsx b/src/modules/parcel-sync/components/parcel-sync-module.tsx index a287a7b..8ac60fa 100644 --- a/src/modules/parcel-sync/components/parcel-sync-module.tsx +++ b/src/modules/parcel-sync/components/parcel-sync-module.tsx @@ -3,13 +3,10 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Search, - RefreshCw, Download, CheckCircle2, XCircle, Loader2, - ChevronLeft, - ChevronRight, MapPin, Layers, Sparkles, @@ -19,6 +16,9 @@ import { LogOut, Wifi, WifiOff, + ClipboardCopy, + Trash2, + Plus, } from "lucide-react"; import { Button } from "@/shared/components/ui/button"; import { Input } from "@/shared/components/ui/input"; @@ -45,7 +45,7 @@ import { type LayerCategory, type LayerCatalogItem, } from "../services/eterra-layers"; -import type { ParcelFeature } from "../types"; +import type { ParcelDetail } from "@/app/api/eterra/search/route"; /* ------------------------------------------------------------------ */ /* Types */ @@ -295,13 +295,11 @@ export function ParcelSyncModule() { const [downloadingLayer, setDownloadingLayer] = useState(null); /* ── Parcel search tab ──────────────────────────────────────── */ - const [features, setFeatures] = useState([]); - const [featuresTotal, setFeaturesTotal] = useState(0); - const [featuresPage, setFeaturesPage] = useState(1); + const [searchResults, setSearchResults] = useState([]); + const [searchList, setSearchList] = useState([]); const [featuresSearch, setFeaturesSearch] = useState(""); - const [featuresLayerFilter, setFeaturesLayerFilter] = useState(""); const [loadingFeatures, setLoadingFeatures] = useState(false); - const PAGE_SIZE = 50; + const [searchError, setSearchError] = useState(""); /* ════════════════════════════════════════════════════════════ */ /* Load UAT data + check server session on mount */ @@ -594,67 +592,109 @@ export function ParcelSyncModule() { ); /* ════════════════════════════════════════════════════════════ */ - /* Load features (parcel search tab) */ - /* - When search term is present → live query eTerra */ - /* - When search is empty → show nothing (prompt user) */ + /* Search parcels by cadastral number (eTerra app API) */ /* ════════════════════════════════════════════════════════════ */ - const searchDebounceRef = useRef | null>(null); - - const loadFeatures = useCallback(async () => { + const handleSearch = useCallback(async () => { if (!siruta || !/^\d+$/.test(siruta)) return; if (!featuresSearch.trim()) { - // No search term → clear results - setFeatures([]); - setFeaturesTotal(0); + setSearchResults([]); + setSearchError(""); return; } setLoadingFeatures(true); + setSearchError(""); try { - // Live search against eTerra const res = await fetch("/api/eterra/search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ siruta, search: featuresSearch.trim(), - layerId: featuresLayerFilter || undefined, }), }); const data = (await res.json()) as { - features?: ParcelFeature[]; + results?: ParcelDetail[]; total?: number; error?: string; }; if (data.error) { - setFeatures([]); - setFeaturesTotal(0); + setSearchResults([]); + setSearchError(data.error); } else { - if (data.features) setFeatures(data.features); - if (data.total != null) setFeaturesTotal(data.total); + setSearchResults(data.results ?? []); + setSearchError(""); } } catch { - /* ignore */ + setSearchError("Eroare de rețea."); } setLoadingFeatures(false); - }, [siruta, featuresLayerFilter, featuresSearch]); + }, [siruta, featuresSearch]); - // Debounced search — waits 600ms after user stops typing - useEffect(() => { - if (!siruta || !/^\d+$/.test(siruta)) return; - if (!featuresSearch.trim()) { - setFeatures([]); - setFeaturesTotal(0); - return; - } - if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); - searchDebounceRef.current = setTimeout(() => { - void loadFeatures(); - }, 600); - return () => { - if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); - }; - }, [siruta, featuresLayerFilter, featuresSearch, loadFeatures]); + // No auto-search — user clicks button or presses Enter + const handleSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + void handleSearch(); + } + }, + [handleSearch], + ); + + // Add result(s) to list for CSV export + const addToList = useCallback( + (item: ParcelDetail) => { + setSearchList((prev) => { + 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 */ @@ -670,7 +710,6 @@ export function ParcelSyncModule() { }, []); const sirutaValid = siruta.length > 0 && /^\d+$/.test(siruta); - const totalPages = Math.ceil(featuresTotal / PAGE_SIZE); const progressPct = exportProgress?.total && exportProgress.total > 0 @@ -738,7 +777,7 @@ export function ParcelSyncModule() { setUatQuery(label); setSiruta(item.siruta); setShowUatResults(false); - setFeaturesPage(1); + setSearchResults([]); }} > {item.name} @@ -795,170 +834,335 @@ export function ParcelSyncModule() { ) : ( <> - {/* Filters */} + {/* Search input */} -
-
- +
+
+
{ - setFeaturesSearch(e.target.value); - setFeaturesPage(1); - }} + onChange={(e) => setFeaturesSearch(e.target.value)} + onKeyDown={handleSearchKeyDown} />
-
- - -
- - - - {/* Table */} - - -
- - - - - - - - - - - - - {features.length === 0 && !loadingFeatures ? ( - - - - ) : ( - features.map((f) => { - const layerLabel = - LAYER_CATALOG.find((l) => l.id === f.layerId) - ?.label ?? f.layerId; - return ( - - - - - - - - - ); - }) - )} - -
- OBJECTID - - Ref. cadastrală - - INSPIRE ID - - Suprafață - - Layer - - Actualizat -
- {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."} -
- {f.objectId} - - {f.cadastralRef ?? "—"} - - {f.inspireId ?? "—"} - - {formatArea(f.areaValue)} - - - {layerLabel} - - - {formatDate(f.updatedAt)} -
-
- - {/* Pagination */} - {featuresTotal > PAGE_SIZE && ( -
- - {featuresTotal.toLocaleString("ro-RO")} total — pagina{" "} - {featuresPage} / {totalPages} - -
- - -
-
+ {searchError && ( +

{searchError}

)}
+ + {/* Results */} + {loadingFeatures && searchResults.length === 0 && ( + + + +

Se caută în eTerra...

+

+ Prima căutare pe un UAT nou poate dura ~10-30s (se încarcă lista de județe). +

+
+
+ )} + + {searchResults.length > 0 && ( + <> + {/* Action bar */} +
+ + {searchResults.length} rezultat{searchResults.length > 1 ? "e" : ""} + {searchList.length > 0 && ( + + · {searchList.length} în listă + + )} + +
+ {searchResults.length > 0 && ( + + )} + +
+
+ + {/* Detail cards */} +
+ {searchResults.map((p, idx) => ( + + +
+
+

+ Nr. Cad. {p.nrCad} +

+ {!p.immovablePk && ( +

+ Parcela nu a fost găsită în eTerra. +

+ )} +
+
+ + +
+
+ + {p.immovablePk && ( +
+
+ + Nr. CF + + + {p.nrCF || "—"} + +
+ {p.nrCFVechi && ( +
+ + CF vechi + + {p.nrCFVechi} +
+ )} +
+ + Nr. Topo + + {p.nrTopo || "—"} +
+
+ + Suprafață + + + {p.suprafata != null + ? formatArea(p.suprafata) + : "—"} + +
+
+ + Intravilan + + + {p.intravilan || "—"} + +
+ {p.categorieFolosinta && ( +
+ + Categorii folosință + + + {p.categorieFolosinta} + +
+ )} + {p.adresa && ( +
+ + Adresă + + {p.adresa} +
+ )} + {p.proprietari && ( +
+ + Proprietari + + {p.proprietari} +
+ )} + {p.solicitant && ( +
+ + Solicitant + + {p.solicitant} +
+ )} +
+ )} +
+
+ ))} +
+ + )} + + {/* Empty state when no search has been done */} + {searchResults.length === 0 && !loadingFeatures && !searchError && ( + + + +

Introdu un număr cadastral și apasă Caută.

+

+ Poți căuta mai multe parcele simultan, separate prin virgulă. +

+
+
+ )} + + {/* Saved list */} + {searchList.length > 0 && ( + + +
+

+ Lista mea ({searchList.length} parcele) +

+
+ + +
+
+
+ + + + + + + + + + + + {searchList.map((p) => ( + + + + + + + + ))} + +
Nr. CadNr. CFSuprafațăProprietari
+ {p.nrCad} + + {p.nrCF || "—"} + + {p.suprafata != null ? formatArea(p.suprafata) : "—"} + + {p.proprietari || "—"} + + +
+
+
+
+ )} )} diff --git a/src/modules/parcel-sync/services/eterra-client.ts b/src/modules/parcel-sync/services/eterra-client.ts index 0f0d403..908933d 100644 --- a/src/modules/parcel-sync/services/eterra-client.ts +++ b/src/modules/parcel-sync/services/eterra-client.ts @@ -463,6 +463,50 @@ export class EterraClient { 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 { + 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 { + 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 { + const url = `${BASE_URL}/api/adm/nomen/ADMINISTRATIVEUNIT/filterByParent/${countyNomenPk}`; + return this.getRawJson(url); + } + /* ---- Internals ------------------------------------------------ */ private layerQueryUrl(layer: LayerConfig) {