feat(parcel-sync): live eTerra search by cadastral number
- Add /api/eterra/search queries eTerra ArcGIS REST API directly by NATIONAL_CADASTRAL_REFERENCE, NATIONAL_CADNR, or INSPIRE_ID across TERENURI_ACTIVE + CLADIRI_ACTIVE layers - Search tab now queries eTerra live (not local DB) with 600ms debounce - Requires session connected + UAT selected to search - Updated placeholder and empty state messages in Romanian
This commit is contained in:
@@ -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<string, unknown>;
|
||||
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<string, unknown>,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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<ReturnType<typeof setTimeout> | 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 */}
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
<TabsContent value="search" className="space-y-4">
|
||||
{!sirutaValid ? (
|
||||
{!sirutaValid || !session.connected ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
<Search className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>Selectează un UAT mai sus pentru a căuta parcele.</p>
|
||||
<p>
|
||||
{!session.connected
|
||||
? "Conectează-te la eTerra și selectează un UAT."
|
||||
: "Selectează un UAT mai sus pentru a căuta parcele."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -772,7 +804,7 @@ export function ParcelSyncModule() {
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Ref. cadastrală sau INSPIRE ID..."
|
||||
placeholder="Număr cadastral (ex: 62580)..."
|
||||
className="pl-9"
|
||||
value={featuresSearch}
|
||||
onChange={(e) => {
|
||||
@@ -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."}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user