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)
This commit is contained in:
+300
-127
@@ -1,29 +1,142 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||||
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
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 runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type Body = {
|
type Body = {
|
||||||
siruta?: string;
|
siruta?: string;
|
||||||
search?: string;
|
search?: string; // cadastral number(s), comma or newline separated
|
||||||
layerId?: string;
|
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Workspace (county) lookup cache */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const globalRef = globalThis as {
|
||||||
|
__eterraWorkspaceCache?: Map<string, number>;
|
||||||
|
};
|
||||||
|
const workspaceCache =
|
||||||
|
globalRef.__eterraWorkspaceCache ?? new Map<string, number>();
|
||||||
|
globalRef.__eterraWorkspaceCache = workspaceCache;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live search eTerra by cadastral number / INSPIRE ID.
|
* Resolve eTerra workspace nomenPk for a given SIRUTA.
|
||||||
* Queries the remote eTerra ArcGIS REST API directly (not local DB).
|
* 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<number | null> {
|
||||||
|
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<string, number>();
|
||||||
|
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) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const body = (await req.json()) as Body;
|
const body = (await req.json()) as Body;
|
||||||
const siruta = String(body.siruta ?? "").trim();
|
const siruta = String(body.siruta ?? "").trim();
|
||||||
const search = (body.search ?? "").trim();
|
const rawSearch = (body.search ?? "").trim();
|
||||||
const layerId = (body.layerId ?? "").trim();
|
|
||||||
|
|
||||||
if (!siruta || !/^\d+$/.test(siruta)) {
|
if (!siruta || !/^\d+$/.test(siruta)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -31,9 +144,22 @@ export async function POST(req: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!search) {
|
if (!rawSearch) {
|
||||||
return NextResponse.json(
|
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 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -56,144 +182,191 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const client = await EterraClient.create(username, password);
|
const client = await EterraClient.create(username, password);
|
||||||
|
|
||||||
// Decide which layers to search
|
// Resolve workspace (county) for this SIRUTA
|
||||||
const searchLayers = layerId
|
const workspaceId = await resolveWorkspace(client, siruta);
|
||||||
? LAYER_CATALOG.filter((l) => l.id === layerId)
|
if (!workspaceId) {
|
||||||
: LAYER_CATALOG.filter((l) =>
|
return NextResponse.json(
|
||||||
["TERENURI_ACTIVE", "CLADIRI_ACTIVE"].includes(l.id),
|
{ error: "Nu s-a putut determina județul pentru UAT-ul selectat." },
|
||||||
);
|
{ status: 400 },
|
||||||
|
);
|
||||||
if (searchLayers.length === 0) {
|
|
||||||
return NextResponse.json({ features: [], total: 0 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the search WHERE — exact or LIKE depending on input
|
const results: ParcelDetail[] = [];
|
||||||
const isNumericOnly = /^\d+$/.test(search);
|
|
||||||
const escapedSearch = search.replace(/'/g, "''");
|
|
||||||
|
|
||||||
type FoundFeature = {
|
for (const cadNr of cadNumbers) {
|
||||||
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 {
|
try {
|
||||||
// Get available fields for this layer
|
// 1. Search immovable by identifier (exact match)
|
||||||
const fields = await client.getLayerFieldNames(layer);
|
const immResponse = await client.searchImmovableByIdentifier(
|
||||||
const upperFields = fields.map((f) => f.toUpperCase());
|
workspaceId,
|
||||||
|
siruta,
|
||||||
// Find admin field for siruta filter
|
cadNr,
|
||||||
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 items = immResponse?.content ?? [];
|
||||||
const conditions: string[] = [];
|
if (items.length === 0) {
|
||||||
|
// No result — add placeholder so user knows it wasn't found
|
||||||
const hasCadRef = upperFields.includes(
|
results.push({
|
||||||
"NATIONAL_CADASTRAL_REFERENCE",
|
nrCad: cadNr,
|
||||||
);
|
nrCF: "",
|
||||||
const hasInspire = upperFields.includes("INSPIRE_ID");
|
nrCFVechi: "",
|
||||||
const hasCadNr = upperFields.includes("NATIONAL_CADNR");
|
nrTopo: "",
|
||||||
|
intravilan: "",
|
||||||
if (hasCadRef) {
|
categorieFolosinta: "",
|
||||||
const cadRefField =
|
adresa: "",
|
||||||
fields[upperFields.indexOf("NATIONAL_CADASTRAL_REFERENCE")]!;
|
proprietari: "",
|
||||||
if (isNumericOnly) {
|
suprafata: null,
|
||||||
// Exact match for numeric cadastral numbers
|
solicitant: "",
|
||||||
conditions.push(`${cadRefField}='${escapedSearch}'`);
|
immovablePk: "",
|
||||||
} else {
|
});
|
||||||
conditions.push(
|
continue;
|
||||||
`${cadRefField} LIKE '%${escapedSearch}%'`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (hasCadNr) {
|
|
||||||
const cadNrField =
|
for (const item of items) {
|
||||||
fields[upperFields.indexOf("NATIONAL_CADNR")]!;
|
const immPk = item?.immovablePk;
|
||||||
if (isNumericOnly) {
|
const immPkStr = String(immPk ?? "");
|
||||||
conditions.push(`${cadNrField}='${escapedSearch}'`);
|
|
||||||
} else {
|
// Basic data from immovable list
|
||||||
conditions.push(`${cadNrField} LIKE '%${escapedSearch}%'`);
|
let nrCF = String(item?.paperLbNo ?? item?.paperCadNo ?? "");
|
||||||
}
|
let nrCFVechi = "";
|
||||||
}
|
let nrTopo = String(
|
||||||
if (hasInspire) {
|
item?.topNo ?? item?.paperCadNo ?? "",
|
||||||
const inspireField =
|
|
||||||
fields[upperFields.indexOf("INSPIRE_ID")]!;
|
|
||||||
conditions.push(
|
|
||||||
`${inspireField} LIKE '%${escapedSearch}%'`,
|
|
||||||
);
|
);
|
||||||
}
|
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, {
|
// Extract doc details
|
||||||
limit: 50,
|
const docImm = (docResponse?.immovables ?? []).find(
|
||||||
outFields: "*",
|
// 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();
|
// Extract owners from partTwoRegs
|
||||||
for (const f of features) {
|
const owners: string[] = [];
|
||||||
const attrs = f.attributes;
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const objId =
|
(docResponse?.partTwoRegs ?? []).forEach((reg: any) => {
|
||||||
typeof attrs.OBJECTID === "number"
|
if (
|
||||||
? attrs.OBJECTID
|
String(reg?.nodeType ?? "").toUpperCase() === "P" &&
|
||||||
: Number(attrs.OBJECTID ?? 0);
|
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({
|
// 3. Fetch application data (solicitant, folosință, intravilan)
|
||||||
id: `live-${layer.id}-${objId}`,
|
if (immPk) {
|
||||||
layerId: layer.id,
|
try {
|
||||||
siruta,
|
const apps = await client.fetchImmAppsByImmovable(
|
||||||
objectId: objId,
|
immPk,
|
||||||
inspireId: (attrs.INSPIRE_ID as string | undefined) ?? undefined,
|
workspaceId,
|
||||||
cadastralRef:
|
);
|
||||||
(attrs.NATIONAL_CADASTRAL_REFERENCE as string | undefined) ??
|
// Pick most recent application
|
||||||
(attrs.NATIONAL_CADNR as string | undefined) ??
|
const chosen =
|
||||||
undefined,
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
areaValue:
|
(apps ?? []).filter((a: any) => a?.dataCerere)
|
||||||
typeof attrs.AREA_VALUE === "number"
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
? attrs.AREA_VALUE
|
.sort((a: any, b: any) =>
|
||||||
: undefined,
|
(b.dataCerere ?? 0) - (a.dataCerere ?? 0),
|
||||||
isActive: attrs.IS_ACTIVE !== 0,
|
)[0] ?? apps?.[0];
|
||||||
attributes: attrs as Record<string, unknown>,
|
|
||||||
createdAt: now,
|
if (chosen) {
|
||||||
updatedAt: now,
|
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 {
|
} catch {
|
||||||
// Skip layer on error and try next
|
// Error for this particular cadNr — add placeholder
|
||||||
continue;
|
results.push({
|
||||||
|
nrCad: cadNr,
|
||||||
|
nrCF: "",
|
||||||
|
nrCFVechi: "",
|
||||||
|
nrTopo: "",
|
||||||
|
intravilan: "",
|
||||||
|
categorieFolosinta: "",
|
||||||
|
adresa: "",
|
||||||
|
proprietari: "",
|
||||||
|
suprafata: null,
|
||||||
|
solicitant: "",
|
||||||
|
immovablePk: "",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
features: allResults,
|
results,
|
||||||
total: allResults.length,
|
total: results.length,
|
||||||
source: "eterra-live",
|
source: "eterra-app",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Eroare server";
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
|
|||||||
@@ -3,13 +3,10 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
RefreshCw,
|
|
||||||
Download,
|
Download,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
MapPin,
|
MapPin,
|
||||||
Layers,
|
Layers,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@@ -19,6 +16,9 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Wifi,
|
Wifi,
|
||||||
WifiOff,
|
WifiOff,
|
||||||
|
ClipboardCopy,
|
||||||
|
Trash2,
|
||||||
|
Plus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
import { Button } from "@/shared/components/ui/button";
|
||||||
import { Input } from "@/shared/components/ui/input";
|
import { Input } from "@/shared/components/ui/input";
|
||||||
@@ -45,7 +45,7 @@ import {
|
|||||||
type LayerCategory,
|
type LayerCategory,
|
||||||
type LayerCatalogItem,
|
type LayerCatalogItem,
|
||||||
} from "../services/eterra-layers";
|
} from "../services/eterra-layers";
|
||||||
import type { ParcelFeature } from "../types";
|
import type { ParcelDetail } from "@/app/api/eterra/search/route";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Types */
|
/* Types */
|
||||||
@@ -295,13 +295,11 @@ export function ParcelSyncModule() {
|
|||||||
const [downloadingLayer, setDownloadingLayer] = useState<string | null>(null);
|
const [downloadingLayer, setDownloadingLayer] = useState<string | null>(null);
|
||||||
|
|
||||||
/* ── Parcel search tab ──────────────────────────────────────── */
|
/* ── Parcel search tab ──────────────────────────────────────── */
|
||||||
const [features, setFeatures] = useState<ParcelFeature[]>([]);
|
const [searchResults, setSearchResults] = useState<ParcelDetail[]>([]);
|
||||||
const [featuresTotal, setFeaturesTotal] = useState(0);
|
const [searchList, setSearchList] = useState<ParcelDetail[]>([]);
|
||||||
const [featuresPage, setFeaturesPage] = useState(1);
|
|
||||||
const [featuresSearch, setFeaturesSearch] = useState("");
|
const [featuresSearch, setFeaturesSearch] = useState("");
|
||||||
const [featuresLayerFilter, setFeaturesLayerFilter] = useState("");
|
|
||||||
const [loadingFeatures, setLoadingFeatures] = useState(false);
|
const [loadingFeatures, setLoadingFeatures] = useState(false);
|
||||||
const PAGE_SIZE = 50;
|
const [searchError, setSearchError] = useState("");
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════════════ */
|
/* ════════════════════════════════════════════════════════════ */
|
||||||
/* Load UAT data + check server session on mount */
|
/* Load UAT data + check server session on mount */
|
||||||
@@ -594,67 +592,109 @@ export function ParcelSyncModule() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════════════ */
|
/* ════════════════════════════════════════════════════════════ */
|
||||||
/* Load features (parcel search tab) */
|
/* Search parcels by cadastral number (eTerra app API) */
|
||||||
/* - 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 handleSearch = useCallback(async () => {
|
||||||
|
|
||||||
const loadFeatures = useCallback(async () => {
|
|
||||||
if (!siruta || !/^\d+$/.test(siruta)) return;
|
if (!siruta || !/^\d+$/.test(siruta)) return;
|
||||||
if (!featuresSearch.trim()) {
|
if (!featuresSearch.trim()) {
|
||||||
// No search term → clear results
|
setSearchResults([]);
|
||||||
setFeatures([]);
|
setSearchError("");
|
||||||
setFeaturesTotal(0);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoadingFeatures(true);
|
setLoadingFeatures(true);
|
||||||
|
setSearchError("");
|
||||||
try {
|
try {
|
||||||
// Live search against eTerra
|
|
||||||
const res = await fetch("/api/eterra/search", {
|
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(),
|
search: featuresSearch.trim(),
|
||||||
layerId: featuresLayerFilter || undefined,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = (await res.json()) as {
|
const data = (await res.json()) as {
|
||||||
features?: ParcelFeature[];
|
results?: ParcelDetail[];
|
||||||
total?: number;
|
total?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
setFeatures([]);
|
setSearchResults([]);
|
||||||
setFeaturesTotal(0);
|
setSearchError(data.error);
|
||||||
} else {
|
} else {
|
||||||
if (data.features) setFeatures(data.features);
|
setSearchResults(data.results ?? []);
|
||||||
if (data.total != null) setFeaturesTotal(data.total);
|
setSearchError("");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
setSearchError("Eroare de rețea.");
|
||||||
}
|
}
|
||||||
setLoadingFeatures(false);
|
setLoadingFeatures(false);
|
||||||
}, [siruta, featuresLayerFilter, featuresSearch]);
|
}, [siruta, featuresSearch]);
|
||||||
|
|
||||||
// Debounced search — waits 600ms after user stops typing
|
// No auto-search — user clicks button or presses Enter
|
||||||
useEffect(() => {
|
const handleSearchKeyDown = useCallback(
|
||||||
if (!siruta || !/^\d+$/.test(siruta)) return;
|
(e: React.KeyboardEvent) => {
|
||||||
if (!featuresSearch.trim()) {
|
if (e.key === "Enter") {
|
||||||
setFeatures([]);
|
e.preventDefault();
|
||||||
setFeaturesTotal(0);
|
void handleSearch();
|
||||||
return;
|
}
|
||||||
}
|
},
|
||||||
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
|
[handleSearch],
|
||||||
searchDebounceRef.current = setTimeout(() => {
|
);
|
||||||
void loadFeatures();
|
|
||||||
}, 600);
|
// Add result(s) to list for CSV export
|
||||||
return () => {
|
const addToList = useCallback(
|
||||||
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
|
(item: ParcelDetail) => {
|
||||||
};
|
setSearchList((prev) => {
|
||||||
}, [siruta, featuresLayerFilter, featuresSearch, loadFeatures]);
|
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 */
|
/* Derived data */
|
||||||
@@ -670,7 +710,6 @@ export function ParcelSyncModule() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const sirutaValid = siruta.length > 0 && /^\d+$/.test(siruta);
|
const sirutaValid = siruta.length > 0 && /^\d+$/.test(siruta);
|
||||||
const totalPages = Math.ceil(featuresTotal / PAGE_SIZE);
|
|
||||||
|
|
||||||
const progressPct =
|
const progressPct =
|
||||||
exportProgress?.total && exportProgress.total > 0
|
exportProgress?.total && exportProgress.total > 0
|
||||||
@@ -738,7 +777,7 @@ export function ParcelSyncModule() {
|
|||||||
setUatQuery(label);
|
setUatQuery(label);
|
||||||
setSiruta(item.siruta);
|
setSiruta(item.siruta);
|
||||||
setShowUatResults(false);
|
setShowUatResults(false);
|
||||||
setFeaturesPage(1);
|
setSearchResults([]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="font-medium">{item.name}</span>
|
<span className="font-medium">{item.name}</span>
|
||||||
@@ -795,170 +834,335 @@ export function ParcelSyncModule() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Filters */}
|
{/* Search input */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<div className="flex gap-3 flex-wrap items-end">
|
<div className="flex gap-3 items-end">
|
||||||
<div className="space-y-1 flex-1 min-w-[200px]">
|
<div className="space-y-1 flex-1">
|
||||||
<Label className="text-xs">Căutare</Label>
|
<Label className="text-xs">
|
||||||
|
Numere cadastrale (separate prin virgulă sau Enter)
|
||||||
|
</Label>
|
||||||
<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="Număr cadastral (ex: 62580)..."
|
placeholder="ex: 62580 sau 62580, 62581, 62582"
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
value={featuresSearch}
|
value={featuresSearch}
|
||||||
onChange={(e) => {
|
onChange={(e) => setFeaturesSearch(e.target.value)}
|
||||||
setFeaturesSearch(e.target.value);
|
onKeyDown={handleSearchKeyDown}
|
||||||
setFeaturesPage(1);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 min-w-[200px]">
|
|
||||||
<Label className="text-xs">Layer</Label>
|
|
||||||
<select
|
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
||||||
value={featuresLayerFilter}
|
|
||||||
onChange={(e) => {
|
|
||||||
setFeaturesLayerFilter(e.target.value);
|
|
||||||
setFeaturesPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">Toate layerele</option>
|
|
||||||
{LAYER_CATALOG.map((l) => (
|
|
||||||
<option key={l.id} value={l.id}>
|
|
||||||
{l.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
onClick={() => void handleSearch()}
|
||||||
variant="outline"
|
disabled={loadingFeatures || !featuresSearch.trim()}
|
||||||
onClick={loadFeatures}
|
|
||||||
disabled={loadingFeatures}
|
|
||||||
>
|
>
|
||||||
{loadingFeatures ? (
|
{loadingFeatures ? (
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<RefreshCw className="h-3.5 w-3.5" />
|
<Search className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
|
Caută
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
{searchError && (
|
||||||
</Card>
|
<p className="text-xs text-destructive mt-2">{searchError}</p>
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b bg-muted/40">
|
|
||||||
<th className="px-4 py-2.5 text-left font-medium">
|
|
||||||
OBJECTID
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-2.5 text-left font-medium">
|
|
||||||
Ref. cadastrală
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-2.5 text-left font-medium hidden md:table-cell">
|
|
||||||
INSPIRE ID
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-2.5 text-right font-medium hidden sm:table-cell">
|
|
||||||
Suprafață
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-2.5 text-left font-medium hidden lg:table-cell">
|
|
||||||
Layer
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-2.5 text-left font-medium hidden lg:table-cell">
|
|
||||||
Actualizat
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{features.length === 0 && !loadingFeatures ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={6}
|
|
||||||
className="px-4 py-8 text-center text-muted-foreground"
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
) : (
|
|
||||||
features.map((f) => {
|
|
||||||
const layerLabel =
|
|
||||||
LAYER_CATALOG.find((l) => l.id === f.layerId)
|
|
||||||
?.label ?? f.layerId;
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={f.id}
|
|
||||||
className="border-b hover:bg-muted/30 transition-colors"
|
|
||||||
>
|
|
||||||
<td className="px-4 py-2 font-mono text-xs">
|
|
||||||
{f.objectId}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
{f.cadastralRef ?? "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 hidden md:table-cell text-xs text-muted-foreground">
|
|
||||||
{f.inspireId ?? "—"}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-right hidden sm:table-cell tabular-nums">
|
|
||||||
{formatArea(f.areaValue)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 hidden lg:table-cell">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[11px] font-normal"
|
|
||||||
>
|
|
||||||
{layerLabel}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 hidden lg:table-cell text-xs text-muted-foreground">
|
|
||||||
{formatDate(f.updatedAt)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{featuresTotal > PAGE_SIZE && (
|
|
||||||
<div className="flex items-center justify-between border-t px-4 py-3">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{featuresTotal.toLocaleString("ro-RO")} total — pagina{" "}
|
|
||||||
{featuresPage} / {totalPages}
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={featuresPage <= 1}
|
|
||||||
onClick={() =>
|
|
||||||
setFeaturesPage((p) => Math.max(1, p - 1))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={featuresPage >= totalPages}
|
|
||||||
onClick={() => setFeaturesPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{loadingFeatures && searchResults.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
<Loader2 className="h-10 w-10 mx-auto mb-3 animate-spin opacity-50" />
|
||||||
|
<p>Se caută în eTerra...</p>
|
||||||
|
<p className="text-xs mt-1 opacity-60">
|
||||||
|
Prima căutare pe un UAT nou poate dura ~10-30s (se încarcă lista de județe).
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<>
|
||||||
|
{/* Action bar */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{searchResults.length} rezultat{searchResults.length > 1 ? "e" : ""}
|
||||||
|
{searchList.length > 0 && (
|
||||||
|
<span className="ml-2">
|
||||||
|
· <strong>{searchList.length}</strong> în listă
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
for (const r of searchResults) addToList(r);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||||
|
Adaugă toate în listă
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
onClick={downloadCSV}
|
||||||
|
disabled={searchResults.length === 0 && searchList.length === 0}
|
||||||
|
>
|
||||||
|
<FileDown className="mr-1 h-3.5 w-3.5" />
|
||||||
|
Descarcă CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail cards */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{searchResults.map((p, idx) => (
|
||||||
|
<Card
|
||||||
|
key={`${p.nrCad}-${p.immovablePk}-${idx}`}
|
||||||
|
className={cn(
|
||||||
|
"transition-colors",
|
||||||
|
!p.immovablePk && "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold tabular-nums">
|
||||||
|
Nr. Cad. {p.nrCad}
|
||||||
|
</h3>
|
||||||
|
{!p.immovablePk && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
Parcela nu a fost găsită în eTerra.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
title="Adaugă în listă"
|
||||||
|
onClick={() => addToList(p)}
|
||||||
|
disabled={!p.immovablePk}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
title="Copiază detalii"
|
||||||
|
onClick={() => {
|
||||||
|
const text = [
|
||||||
|
`Nr. Cad: ${p.nrCad}`,
|
||||||
|
`Nr. CF: ${p.nrCF || "—"}`,
|
||||||
|
p.nrCFVechi ? `CF vechi: ${p.nrCFVechi}` : null,
|
||||||
|
p.nrTopo ? `Nr. Topo: ${p.nrTopo}` : null,
|
||||||
|
p.suprafata != null
|
||||||
|
? `Suprafață: ${p.suprafata.toLocaleString("ro-RO")} mp`
|
||||||
|
: null,
|
||||||
|
`Intravilan: ${p.intravilan || "—"}`,
|
||||||
|
p.categorieFolosinta
|
||||||
|
? `Categorie: ${p.categorieFolosinta}`
|
||||||
|
: null,
|
||||||
|
p.adresa ? `Adresă: ${p.adresa}` : null,
|
||||||
|
p.proprietari ? `Proprietari: ${p.proprietari}` : null,
|
||||||
|
p.solicitant ? `Solicitant: ${p.solicitant}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
void navigator.clipboard.writeText(text);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClipboardCopy className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{p.immovablePk && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground block">
|
||||||
|
Nr. CF
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{p.nrCF || "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{p.nrCFVechi && (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground block">
|
||||||
|
CF vechi
|
||||||
|
</span>
|
||||||
|
<span>{p.nrCFVechi}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground block">
|
||||||
|
Nr. Topo
|
||||||
|
</span>
|
||||||
|
<span>{p.nrTopo || "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground block">
|
||||||
|
Suprafață
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{p.suprafata != null
|
||||||
|
? formatArea(p.suprafata)
|
||||||
|
: "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground block">
|
||||||
|
Intravilan
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
p.intravilan === "Da"
|
||||||
|
? "default"
|
||||||
|
: p.intravilan === "Nu"
|
||||||
|
? "secondary"
|
||||||
|
: "outline"
|
||||||
|
}
|
||||||
|
className="text-[11px]"
|
||||||
|
>
|
||||||
|
{p.intravilan || "—"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{p.categorieFolosinta && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<span className="text-xs text-muted-foreground block">
|
||||||
|
Categorii folosință
|
||||||
|
</span>
|
||||||
|
<span className="text-xs">
|
||||||
|
{p.categorieFolosinta}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{p.adresa && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<span className="text-xs text-muted-foreground block">
|
||||||
|
Adresă
|
||||||
|
</span>
|
||||||
|
<span>{p.adresa}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{p.proprietari && (
|
||||||
|
<div className="col-span-2 lg:col-span-3">
|
||||||
|
<span className="text-xs text-muted-foreground block">
|
||||||
|
Proprietari
|
||||||
|
</span>
|
||||||
|
<span>{p.proprietari}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{p.solicitant && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<span className="text-xs text-muted-foreground block">
|
||||||
|
Solicitant
|
||||||
|
</span>
|
||||||
|
<span>{p.solicitant}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state when no search has been done */}
|
||||||
|
{searchResults.length === 0 && !loadingFeatures && !searchError && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
<Search className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||||
|
<p>Introdu un număr cadastral și apasă Caută.</p>
|
||||||
|
<p className="text-xs mt-1 opacity-60">
|
||||||
|
Poți căuta mai multe parcele simultan, separate prin virgulă.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Saved list */}
|
||||||
|
{searchList.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-medium">
|
||||||
|
Lista mea ({searchList.length} parcele)
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setSearchList([])}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||||
|
Golește
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={downloadCSV}>
|
||||||
|
<FileDown className="mr-1 h-3.5 w-3.5" />
|
||||||
|
CSV din listă
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/40">
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Nr. Cad</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium">Nr. CF</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium hidden sm:table-cell">Suprafață</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium hidden md:table-cell">Proprietari</th>
|
||||||
|
<th className="px-3 py-2 w-8"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{searchList.map((p) => (
|
||||||
|
<tr
|
||||||
|
key={`list-${p.nrCad}-${p.immovablePk}`}
|
||||||
|
className="border-b hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs font-medium">
|
||||||
|
{p.nrCad}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-xs">
|
||||||
|
{p.nrCF || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right hidden sm:table-cell tabular-nums text-xs">
|
||||||
|
{p.suprafata != null ? formatArea(p.suprafata) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 hidden md:table-cell text-xs truncate max-w-[300px]">
|
||||||
|
{p.proprietari || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
onClick={() => removeFromList(p.nrCad)}
|
||||||
|
>
|
||||||
|
<XCircle className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -463,6 +463,50 @@ export class EterraClient {
|
|||||||
return this.getRawJson(url);
|
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<any> {
|
||||||
|
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<any[]> {
|
||||||
|
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<any[]> {
|
||||||
|
const url = `${BASE_URL}/api/adm/nomen/ADMINISTRATIVEUNIT/filterByParent/${countyNomenPk}`;
|
||||||
|
return this.getRawJson(url);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Internals ------------------------------------------------ */
|
/* ---- Internals ------------------------------------------------ */
|
||||||
|
|
||||||
private layerQueryUrl(layer: LayerConfig) {
|
private layerQueryUrl(layer: LayerConfig) {
|
||||||
|
|||||||
Reference in New Issue
Block a user