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) */
|
/* 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 () => {
|
const loadFeatures = useCallback(async () => {
|
||||||
if (!siruta || !/^\d+$/.test(siruta)) return;
|
if (!siruta || !/^\d+$/.test(siruta)) return;
|
||||||
|
if (!featuresSearch.trim()) {
|
||||||
|
// No search term → clear results
|
||||||
|
setFeatures([]);
|
||||||
|
setFeaturesTotal(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoadingFeatures(true);
|
setLoadingFeatures(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/eterra/features", {
|
// Live search against eTerra
|
||||||
|
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(),
|
||||||
layerId: featuresLayerFilter || undefined,
|
layerId: featuresLayerFilter || undefined,
|
||||||
search: featuresSearch || undefined,
|
|
||||||
page: featuresPage,
|
|
||||||
pageSize: PAGE_SIZE,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = (await res.json()) as {
|
const data = (await res.json()) as {
|
||||||
features?: ParcelFeature[];
|
features?: ParcelFeature[];
|
||||||
total?: number;
|
total?: number;
|
||||||
|
error?: string;
|
||||||
};
|
};
|
||||||
if (data.features) setFeatures(data.features);
|
if (data.error) {
|
||||||
if (data.total != null) setFeaturesTotal(data.total);
|
setFeatures([]);
|
||||||
|
setFeaturesTotal(0);
|
||||||
|
} else {
|
||||||
|
if (data.features) setFeatures(data.features);
|
||||||
|
if (data.total != null) setFeaturesTotal(data.total);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
setLoadingFeatures(false);
|
setLoadingFeatures(false);
|
||||||
}, [siruta, featuresLayerFilter, featuresSearch, featuresPage]);
|
}, [siruta, featuresLayerFilter, featuresSearch]);
|
||||||
|
|
||||||
|
// Debounced search — waits 600ms after user stops typing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (siruta && /^\d+$/.test(siruta)) void loadFeatures();
|
if (!siruta || !/^\d+$/.test(siruta)) return;
|
||||||
}, [siruta, featuresLayerFilter, featuresSearch, featuresPage, loadFeatures]);
|
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 */
|
/* Derived data */
|
||||||
@@ -754,11 +782,15 @@ export function ParcelSyncModule() {
|
|||||||
{/* Tab 1: Parcel search */}
|
{/* Tab 1: Parcel search */}
|
||||||
{/* ═══════════════════════════════════════════════════════ */}
|
{/* ═══════════════════════════════════════════════════════ */}
|
||||||
<TabsContent value="search" className="space-y-4">
|
<TabsContent value="search" className="space-y-4">
|
||||||
{!sirutaValid ? (
|
{!sirutaValid || !session.connected ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-12 text-center text-muted-foreground">
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
<Search className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
@@ -772,7 +804,7 @@ export function ParcelSyncModule() {
|
|||||||
<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="Ref. cadastrală sau INSPIRE ID..."
|
placeholder="Număr cadastral (ex: 62580)..."
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
value={featuresSearch}
|
value={featuresSearch}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -850,9 +882,9 @@ export function ParcelSyncModule() {
|
|||||||
colSpan={6}
|
colSpan={6}
|
||||||
className="px-4 py-8 text-center text-muted-foreground"
|
className="px-4 py-8 text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
{featuresSearch
|
{featuresSearch.trim()
|
||||||
? "Nicio parcelă găsită pentru căutarea curentă."
|
? "Nicio parcelă găsită în eTerra pentru căutarea curentă."
|
||||||
: "Nicio parcelă sincronizată. Exportează date din tabul Export."}
|
: "Introdu un număr cadastral sau INSPIRE ID pentru a căuta în eTerra."}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user