From d948e5c1cf987e6019ac9cb2fdfb018a5b5417cc Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Fri, 6 Mar 2026 20:46:44 +0200 Subject: [PATCH] feat(parcel-sync): county-aware UAT autocomplete with workspace resolution - New /api/eterra/uats endpoint fetches all counties + UATs from eTerra, caches server-side for 1 hour, returns enriched data with county name and workspacePk for each UAT - When eTerra is connected, auto-fetches enriched UAT list (replaces static uat.json fallback) shows 'FELEACU (57582), CLUJ' format - UAT autocomplete now searches both UAT name and county name - Selected UAT stores workspacePk in state, passes it directly to /api/eterra/search eliminates slow per-search county resolution - Search route accepts optional workspacePk, falls back to resolveWorkspace() - Dropdown shows UAT name, SIRUTA code, and county prominently - Increased autocomplete results from 8 to 12 items --- src/app/api/eterra/search/route.ts | 32 ++-- src/app/api/eterra/uats/route.ts | 111 ++++++++++++++ .../components/parcel-sync-module.tsx | 145 ++++++++++++++---- .../parcel-sync/services/eterra-client.ts | 93 +++++++++-- 4 files changed, 320 insertions(+), 61 deletions(-) create mode 100644 src/app/api/eterra/uats/route.ts diff --git a/src/app/api/eterra/search/route.ts b/src/app/api/eterra/search/route.ts index 34439f2..63e9736 100644 --- a/src/app/api/eterra/search/route.ts +++ b/src/app/api/eterra/search/route.ts @@ -7,9 +7,10 @@ export const dynamic = "force-dynamic"; type Body = { siruta?: string; - search?: string; // cadastral number(s), comma or newline separated + search?: string; // cadastral number(s), comma or newline separated username?: string; password?: string; + workspacePk?: number; // county workspace PK — if provided, skips resolution }; /* ------------------------------------------------------------------ */ @@ -81,7 +82,11 @@ function formatAddress(item?: any) { // eslint-disable-next-line @typescript-eslint/no-explicit-any function normalizeIntravilan(values: string[]) { const normalized = values - .map((v) => String(v ?? "").trim().toLowerCase()) + .map((v) => + String(v ?? "") + .trim() + .toLowerCase(), + ) .filter(Boolean); const unique = new Set(normalized); if (!unique.size) return ""; @@ -182,8 +187,11 @@ export async function POST(req: Request) { const client = await EterraClient.create(username, password); - // Resolve workspace (county) for this SIRUTA - const workspaceId = await resolveWorkspace(client, siruta); + // Use provided workspacePk — or fall back to resolution + let workspaceId = body.workspacePk ?? null; + if (!workspaceId || !Number.isFinite(workspaceId)) { + workspaceId = await resolveWorkspace(client, siruta); + } if (!workspaceId) { return NextResponse.json( { error: "Nu s-a putut determina județul pentru UAT-ul selectat." }, @@ -228,9 +236,7 @@ export async function POST(req: Request) { // Basic data from immovable list let nrCF = String(item?.paperLbNo ?? item?.paperCadNo ?? ""); let nrCFVechi = ""; - let nrTopo = String( - item?.topNo ?? item?.paperCadNo ?? "", - ); + let nrTopo = String(item?.topNo ?? item?.paperCadNo ?? ""); let addressText = formatAddress(item); let proprietari = ""; let solicitant = ""; @@ -298,16 +304,16 @@ export async function POST(req: Request) { // Pick most recent application const chosen = // eslint-disable-next-line @typescript-eslint/no-explicit-any - (apps ?? []).filter((a: any) => a?.dataCerere) + (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), + .sort( + (a: any, b: any) => + (b.dataCerere ?? 0) - (a.dataCerere ?? 0), )[0] ?? apps?.[0]; if (chosen) { - solicitant = String( - chosen.solicitant ?? chosen.deponent ?? "", - ); + solicitant = String(chosen.solicitant ?? chosen.deponent ?? ""); const appId = chosen.applicationId; if (appId) { try { diff --git a/src/app/api/eterra/uats/route.ts b/src/app/api/eterra/uats/route.ts new file mode 100644 index 0000000..35b6c65 --- /dev/null +++ b/src/app/api/eterra/uats/route.ts @@ -0,0 +1,111 @@ +import { NextResponse } from "next/server"; +import { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; +import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/* ------------------------------------------------------------------ */ +/* Server-side cache */ +/* ------------------------------------------------------------------ */ + +type EnrichedUat = { + siruta: string; + name: string; + county: string; + workspacePk: number; +}; + +const globalRef = globalThis as { + __eterraEnrichedUats?: { data: EnrichedUat[]; ts: number }; +}; + +/** Cache TTL — 1 hour (county/UAT data rarely changes) */ +const CACHE_TTL_MS = 60 * 60 * 1000; + +/* ------------------------------------------------------------------ */ +/* GET /api/eterra/uats */ +/* */ +/* Returns all UATs enriched with county name + workspacePk. */ +/* Cached server-side for 1 hour. */ +/* ------------------------------------------------------------------ */ + +export async function GET() { + try { + // Return from cache if fresh + const cached = globalRef.__eterraEnrichedUats; + if (cached && Date.now() - cached.ts < CACHE_TTL_MS) { + return NextResponse.json({ uats: cached.data, cached: true }); + } + + // Need eTerra credentials + const session = getSessionCredentials(); + const username = String( + session?.username || process.env.ETERRA_USERNAME || "", + ).trim(); + const password = String( + session?.password || process.env.ETERRA_PASSWORD || "", + ).trim(); + + if (!username || !password) { + return NextResponse.json( + { error: "Conectează-te la eTerra mai întâi.", uats: [] }, + { status: 401 }, + ); + } + + const client = await EterraClient.create(username, password); + + // Fetch all counties + const counties = await client.fetchCounties(); + const enriched: EnrichedUat[] = []; + + for (const county of counties) { + const countyPk = county?.nomenPk ?? county?.pk ?? county?.id; + const countyName = String(county?.name ?? "").trim(); + if (!countyPk || !countyName) continue; + + try { + const uats = await client.fetchAdminUnitsByCounty(countyPk); + for (const uat of uats) { + const uatPk = String(uat?.nomenPk ?? uat?.pk ?? ""); + const uatName = String(uat?.name ?? "").trim(); + if (!uatPk || !uatName) continue; + + enriched.push({ + siruta: uatPk, + name: uatName, + county: countyName, + workspacePk: Number(countyPk), + }); + } + } catch { + // Skip county if UAT fetch fails + continue; + } + } + + // Update cache + globalRef.__eterraEnrichedUats = { data: enriched, ts: Date.now() }; + + // Also populate the workspace cache used by search route + const wsGlobal = globalThis as { + __eterraWorkspaceCache?: Map; + }; + if (!wsGlobal.__eterraWorkspaceCache) { + wsGlobal.__eterraWorkspaceCache = new Map(); + } + for (const u of enriched) { + wsGlobal.__eterraWorkspaceCache.set(u.siruta, u.workspacePk); + } + + return NextResponse.json({ + uats: enriched, + cached: false, + total: enriched.length, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Eroare server"; + return NextResponse.json({ error: message, uats: [] }, { status: 500 }); + } +} diff --git a/src/modules/parcel-sync/components/parcel-sync-module.tsx b/src/modules/parcel-sync/components/parcel-sync-module.tsx index 8ac60fa..b3d3b74 100644 --- a/src/modules/parcel-sync/components/parcel-sync-module.tsx +++ b/src/modules/parcel-sync/components/parcel-sync-module.tsx @@ -51,7 +51,12 @@ import type { ParcelDetail } from "@/app/api/eterra/search/route"; /* Types */ /* ------------------------------------------------------------------ */ -type UatEntry = { siruta: string; name: string; county?: string }; +type UatEntry = { + siruta: string; + name: string; + county?: string; + workspacePk?: number; +}; type SessionStatus = { connected: boolean; @@ -277,7 +282,9 @@ export function ParcelSyncModule() { const [uatResults, setUatResults] = useState([]); const [showUatResults, setShowUatResults] = useState(false); const [siruta, setSiruta] = useState(""); + const [workspacePk, setWorkspacePk] = useState(null); const uatRef = useRef(null); + const enrichedUatsFetched = useRef(false); /* ── Export state ────────────────────────────────────────────── */ const [exportJobId, setExportJobId] = useState(null); @@ -318,6 +325,7 @@ export function ParcelSyncModule() { }, []); useEffect(() => { + // Load static UAT data as fallback fetch("/uat.json") .then((res) => res.json()) .then((data: UatEntry[]) => setUatData(data)) @@ -333,6 +341,36 @@ export function ParcelSyncModule() { }; }, [fetchSession]); + /* ════════════════════════════════════════════════════════════ */ + /* Fetch enriched UAT list (with county + workspace) when */ + /* connected to eTerra. Falls back to static uat.json. */ + /* ════════════════════════════════════════════════════════════ */ + + useEffect(() => { + if (!session.connected || enrichedUatsFetched.current) return; + enrichedUatsFetched.current = true; + + fetch("/api/eterra/uats") + .then((res) => res.json()) + .then( + (data: { + uats?: { + siruta: string; + name: string; + county: string; + workspacePk: number; + }[]; + }) => { + if (data.uats && data.uats.length > 0) { + setUatData(data.uats); + } + }, + ) + .catch(() => { + // Keep static uat.json data + }); + }, [session.connected]); + /* ════════════════════════════════════════════════════════════ */ /* UAT autocomplete filter */ /* ════════════════════════════════════════════════════════════ */ @@ -346,12 +384,15 @@ export function ParcelSyncModule() { const isDigit = /^\d+$/.test(raw); const query = normalizeText(raw); const results = uatData - .filter((item) => - isDigit - ? item.siruta.startsWith(raw) - : normalizeText(item.name).includes(query), - ) - .slice(0, 8); + .filter((item) => { + if (isDigit) return item.siruta.startsWith(raw); + // Match UAT name or county name + if (normalizeText(item.name).includes(query)) return true; + if (item.county && normalizeText(item.county).includes(query)) + return true; + return false; + }) + .slice(0, 12); setUatResults(results); }, [uatQuery, uatData]); @@ -611,6 +652,7 @@ export function ParcelSyncModule() { body: JSON.stringify({ siruta, search: featuresSearch.trim(), + ...(workspacePk ? { workspacePk } : {}), }), }); const data = (await res.json()) as { @@ -629,7 +671,7 @@ export function ParcelSyncModule() { setSearchError("Eroare de rețea."); } setLoadingFeatures(false); - }, [siruta, featuresSearch]); + }, [siruta, featuresSearch, workspacePk]); // No auto-search — user clicks button or presses Enter const handleSearchKeyDown = useCallback( @@ -643,16 +685,17 @@ export function ParcelSyncModule() { ); // 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 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)); @@ -772,18 +815,31 @@ export function ParcelSyncModule() { onMouseDown={(e) => { e.preventDefault(); const label = item.county - ? `${item.name} (${item.siruta}) — ${item.county}` + ? `${item.name} (${item.siruta}), ${item.county}` : `${item.name} (${item.siruta})`; setUatQuery(label); setSiruta(item.siruta); + setWorkspacePk(item.workspacePk ?? null); setShowUatResults(false); setSearchResults([]); }} > - {item.name} - + + {item.name} + + ({item.siruta}) + + {item.county && ( + + ,{" "} + + {item.county} + + + )} + + {item.siruta} - {item.county ? ` · ${item.county}` : ""} ))} @@ -878,7 +934,8 @@ export function ParcelSyncModule() {

Se caută în eTerra...

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

@@ -889,7 +946,8 @@ export function ParcelSyncModule() { {/* Action bar */}
- {searchResults.length} rezultat{searchResults.length > 1 ? "e" : ""} + {searchResults.length} rezultat + {searchResults.length > 1 ? "e" : ""} {searchList.length > 0 && ( · {searchList.length} în listă @@ -913,7 +971,9 @@ export function ParcelSyncModule() { size="sm" variant="default" onClick={downloadCSV} - disabled={searchResults.length === 0 && searchList.length === 0} + disabled={ + searchResults.length === 0 && searchList.length === 0 + } > Descarcă CSV @@ -963,7 +1023,9 @@ export function ParcelSyncModule() { const text = [ `Nr. Cad: ${p.nrCad}`, `Nr. CF: ${p.nrCF || "—"}`, - p.nrCFVechi ? `CF vechi: ${p.nrCFVechi}` : null, + p.nrCFVechi + ? `CF vechi: ${p.nrCFVechi}` + : null, p.nrTopo ? `Nr. Topo: ${p.nrTopo}` : null, p.suprafata != null ? `Suprafață: ${p.suprafata.toLocaleString("ro-RO")} mp` @@ -973,8 +1035,12 @@ export function ParcelSyncModule() { ? `Categorie: ${p.categorieFolosinta}` : null, p.adresa ? `Adresă: ${p.adresa}` : null, - p.proprietari ? `Proprietari: ${p.proprietari}` : null, - p.solicitant ? `Solicitant: ${p.solicitant}` : null, + p.proprietari + ? `Proprietari: ${p.proprietari}` + : null, + p.solicitant + ? `Solicitant: ${p.solicitant}` + : null, ] .filter(Boolean) .join("\n"); @@ -1087,7 +1153,8 @@ export function ParcelSyncModule() {

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

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

@@ -1120,10 +1187,18 @@ export function ParcelSyncModule() { - - - - + + + + @@ -1140,7 +1215,9 @@ export function ParcelSyncModule() { {p.nrCF || "—"}
Nr. CadNr. CFSuprafațăProprietari + Nr. Cad + + Nr. CF + + Suprafață + + Proprietari +
- {p.suprafata != null ? formatArea(p.suprafata) : "—"} + {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 908933d..2f5f586 100644 --- a/src/modules/parcel-sync/services/eterra-client.ts +++ b/src/modules/parcel-sync/services/eterra-client.ts @@ -408,23 +408,52 @@ export class EterraClient { /* ---- Magic-mode methods (eTerra application APIs) ------------- */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - async fetchImmAppsByImmovable(immovableId: string | number, workspaceId: string | number): Promise { + async fetchImmAppsByImmovable( + immovableId: string | number, + workspaceId: string | number, + ): Promise { const url = `${BASE_URL}/api/immApps/byImm/list/${immovableId}/${workspaceId}`; return this.getRawJson(url); } // eslint-disable-next-line @typescript-eslint/no-explicit-any - async fetchParcelFolosinte(workspaceId: string | number, immovableId: string | number, applicationId: string | number, page = 1): Promise { + async fetchParcelFolosinte( + workspaceId: string | number, + immovableId: string | number, + applicationId: string | number, + page = 1, + ): Promise { const url = `${BASE_URL}/api/immApps/parcels/list/${workspaceId}/${immovableId}/${applicationId}/${page}`; return this.getRawJson(url); } // eslint-disable-next-line @typescript-eslint/no-explicit-any - async fetchImmovableListByAdminUnit(workspaceId: string | number, adminUnitId: string | number, page = 0, size = 200, includeInscrisCF = true): Promise { + async fetchImmovableListByAdminUnit( + workspaceId: string | number, + adminUnitId: string | number, + page = 0, + size = 200, + includeInscrisCF = true, + ): 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: "=" }, + 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: "C", type: "STRING", key: "immovableType", op: "<>C" }, ]; if (includeInscrisCF) { @@ -440,7 +469,10 @@ export class EterraClient { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - async fetchDocumentationData(workspaceId: string | number, immovableIds: Array): Promise { + async fetchDocumentationData( + workspaceId: string | number, + immovableIds: Array, + ): Promise { const url = `${BASE_URL}/api/documentation/data/`; const payload = { workflowCode: "EXPLORE_DATABASE", @@ -458,7 +490,12 @@ export class EterraClient { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - async fetchImmovableParcelDetails(workspaceId: string | number, immovableId: string | number, page = 1, size = 1): Promise { + async fetchImmovableParcelDetails( + workspaceId: string | number, + immovableId: string | number, + page = 1, + size = 1, + ): Promise { const url = `${BASE_URL}/api/immovable/details/parcels/list/${workspaceId}/${immovableId}/${page}/${size}`; return this.getRawJson(url); } @@ -469,12 +506,38 @@ export class EterraClient { * 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 { + 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: "=" }, + 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" }, ]; @@ -502,7 +565,9 @@ export class EterraClient { * Returns array of { nomenPk, name, parentNomenPk, ... } */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - async fetchAdminUnitsByCounty(countyNomenPk: string | number): Promise { + async fetchAdminUnitsByCounty( + countyNomenPk: string | number, + ): Promise { const url = `${BASE_URL}/api/adm/nomen/ADMINISTRATIVEUNIT/filterByParent/${countyNomenPk}`; return this.getRawJson(url); }