diff --git a/src/app/api/eterra/search/route.ts b/src/app/api/eterra/search/route.ts new file mode 100644 index 0000000..99ce0e0 --- /dev/null +++ b/src/app/api/eterra/search/route.ts @@ -0,0 +1,202 @@ +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; + username?: string; + password?: string; +}; + +/** + * Live search eTerra by cadastral number / INSPIRE ID. + * Queries the remote eTerra ArcGIS REST API directly (not local DB). + */ +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(); + + if (!siruta || !/^\d+$/.test(siruta)) { + return NextResponse.json( + { error: "SIRUTA obligatoriu" }, + { status: 400 }, + ); + } + if (!search) { + return NextResponse.json( + { error: "Termen de căutare obligatoriu" }, + { status: 400 }, + ); + } + + // Credential chain: body > session > env + const session = getSessionCredentials(); + const username = String( + body.username || session?.username || process.env.ETERRA_USERNAME || "", + ).trim(); + const password = String( + body.password || session?.password || process.env.ETERRA_PASSWORD || "", + ).trim(); + + if (!username || !password) { + return NextResponse.json( + { error: "Conectează-te la eTerra mai întâi." }, + { status: 401 }, + ); + } + + 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 }); + } + + // Build the search WHERE — exact or LIKE depending on input + const isNumericOnly = /^\d+$/.test(search); + const escapedSearch = search.replace(/'/g, "''"); + + 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) { + 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), + ); + 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}%'`, + ); + } + } + 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}%'`, + ); + } + + if (conditions.length === 0) continue; + + const searchWhere = `${adminFieldActual}=${siruta} AND (${conditions.join(" OR ")})`; + + const features = await client.listLayerByWhere(layer, searchWhere, { + limit: 50, + outFields: "*", + }); + + 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); + + 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, + }); + } + } catch { + // Skip layer on error and try next + continue; + } + } + + return NextResponse.json({ + features: allResults, + total: allResults.length, + source: "eterra-live", + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Eroare server"; + return NextResponse.json({ error: message }, { 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 5272f21..a287a7b 100644 --- a/src/modules/parcel-sync/components/parcel-sync-module.tsx +++ b/src/modules/parcel-sync/components/parcel-sync-module.tsx @@ -595,38 +595,66 @@ export function ParcelSyncModule() { /* ════════════════════════════════════════════════════════════ */ /* Load features (parcel search tab) */ + /* - When search term is present → live query eTerra */ + /* - When search is empty → show nothing (prompt user) */ /* ════════════════════════════════════════════════════════════ */ + const searchDebounceRef = useRef | null>(null); + const loadFeatures = useCallback(async () => { if (!siruta || !/^\d+$/.test(siruta)) return; + if (!featuresSearch.trim()) { + // No search term → clear results + setFeatures([]); + setFeaturesTotal(0); + return; + } setLoadingFeatures(true); try { - const res = await fetch("/api/eterra/features", { + // 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, - search: featuresSearch || undefined, - page: featuresPage, - pageSize: PAGE_SIZE, }), }); const data = (await res.json()) as { features?: ParcelFeature[]; total?: number; + error?: string; }; - if (data.features) setFeatures(data.features); - if (data.total != null) setFeaturesTotal(data.total); + if (data.error) { + setFeatures([]); + setFeaturesTotal(0); + } else { + if (data.features) setFeatures(data.features); + if (data.total != null) setFeaturesTotal(data.total); + } } catch { /* ignore */ } setLoadingFeatures(false); - }, [siruta, featuresLayerFilter, featuresSearch, featuresPage]); + }, [siruta, featuresLayerFilter, featuresSearch]); + // Debounced search — waits 600ms after user stops typing useEffect(() => { - if (siruta && /^\d+$/.test(siruta)) void loadFeatures(); - }, [siruta, featuresLayerFilter, featuresSearch, featuresPage, loadFeatures]); + 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]); /* ════════════════════════════════════════════════════════════ */ /* Derived data */ @@ -754,11 +782,15 @@ export function ParcelSyncModule() { {/* Tab 1: Parcel search */} {/* ═══════════════════════════════════════════════════════ */} - {!sirutaValid ? ( + {!sirutaValid || !session.connected ? ( -

Selectează un UAT mai sus pentru a căuta parcele.

+

+ {!session.connected + ? "Conectează-te la eTerra și selectează un UAT." + : "Selectează un UAT mai sus pentru a căuta parcele."} +

) : ( @@ -772,7 +804,7 @@ export function ParcelSyncModule() {
{ @@ -850,9 +882,9 @@ export function ParcelSyncModule() { colSpan={6} className="px-4 py-8 text-center text-muted-foreground" > - {featuresSearch - ? "Nicio parcelă găsită pentru căutarea curentă." - : "Nicio parcelă sincronizată. Exportează date din tabul Export."} + {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."} ) : (