feat(parcel-sync): owner name search (proprietar) in Search tab
- New search mode toggle: Nr. Cadastral / Proprietar - Owner search queries: 1. Local DB first (enrichment PROPRIETARI/PROPRIETARI_VECHI ILIKE) 2. eTerra API fallback (tries personName/titularName/ownerName filter keys) - DB search works offline (no eTerra connection needed) — uses enriched data - New API route: POST /api/eterra/search-owner - New eterra-client method: searchImmovableByOwnerName() - Owner results show source badge (DB local / eTerra online) - Results can be added to saved list and exported as CSV - Relaxed search tab guard: only requires UAT selection (not eTerra connection) - Cadastral search still requires eTerra connection (shows hint when offline)
This commit is contained in:
@@ -0,0 +1,358 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||||
|
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
||||||
|
import { prisma } from "@/core/storage/prisma";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Body = {
|
||||||
|
siruta?: string;
|
||||||
|
ownerName?: string;
|
||||||
|
workspacePk?: number;
|
||||||
|
/** "db" = local DB only, "eterra" = eTerra API only, "both" = try both (default) */
|
||||||
|
source?: "db" | "eterra" | "both";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OwnerSearchResult = {
|
||||||
|
nrCad: string;
|
||||||
|
nrCF: string;
|
||||||
|
proprietari: string;
|
||||||
|
proprietariVechi: string;
|
||||||
|
adresa: string;
|
||||||
|
suprafata: number | string;
|
||||||
|
intravilan: string;
|
||||||
|
categorieFolosinta: string;
|
||||||
|
source: "db" | "eterra";
|
||||||
|
immovablePk: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Workspace resolution (same as search route) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const globalRef = globalThis as {
|
||||||
|
__eterraWorkspaceCache?: Map<string, number>;
|
||||||
|
};
|
||||||
|
const workspaceCache =
|
||||||
|
globalRef.__eterraWorkspaceCache ?? new Map<string, number>();
|
||||||
|
globalRef.__eterraWorkspaceCache = workspaceCache;
|
||||||
|
|
||||||
|
async function resolveWorkspace(
|
||||||
|
client: EterraClient,
|
||||||
|
siruta: string,
|
||||||
|
): Promise<number | null> {
|
||||||
|
const cached = workspaceCache.get(siruta);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const features = await client.listLayer(
|
||||||
|
{
|
||||||
|
id: "TERENURI_ACTIVE",
|
||||||
|
name: "TERENURI_ACTIVE",
|
||||||
|
endpoint: "aut",
|
||||||
|
whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1",
|
||||||
|
},
|
||||||
|
siruta,
|
||||||
|
{ limit: 1, outFields: "WORKSPACE_ID" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const wsId = features?.[0]?.attributes?.WORKSPACE_ID;
|
||||||
|
if (wsId != null) {
|
||||||
|
const numWs = Number(wsId);
|
||||||
|
if (Number.isFinite(numWs)) {
|
||||||
|
workspaceCache.set(siruta, numWs);
|
||||||
|
prisma.gisUat
|
||||||
|
.upsert({
|
||||||
|
where: { siruta },
|
||||||
|
update: { workspacePk: numWs },
|
||||||
|
create: { siruta, name: siruta, workspacePk: numWs },
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return numWs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ArcGIS query failed
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* DB search — search enrichment JSON for owner name */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async function searchOwnerInDb(
|
||||||
|
siruta: string,
|
||||||
|
ownerName: string,
|
||||||
|
limit = 50,
|
||||||
|
): Promise<OwnerSearchResult[]> {
|
||||||
|
// Normalize search: remove diacritics for broader matching
|
||||||
|
const normalizedSearch = ownerName
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// Search in both PROPRIETARI and PROPRIETARI_VECHI fields of enrichment JSON
|
||||||
|
// Use raw SQL for ILIKE on JSON text
|
||||||
|
const features = await prisma.$queryRaw<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
cadastral_ref: string | null;
|
||||||
|
enrichment: Record<string, unknown> | null;
|
||||||
|
area_value: number | null;
|
||||||
|
}>
|
||||||
|
>`
|
||||||
|
SELECT id, "cadastralRef" as cadastral_ref, enrichment, "areaValue" as area_value
|
||||||
|
FROM "GisFeature"
|
||||||
|
WHERE siruta = ${siruta}
|
||||||
|
AND "layerId" = 'TERENURI_ACTIVE'
|
||||||
|
AND enrichment IS NOT NULL
|
||||||
|
AND (
|
||||||
|
unaccent(enrichment->>'PROPRIETARI') ILIKE unaccent(${"%" + normalizedSearch + "%"})
|
||||||
|
OR unaccent(enrichment->>'PROPRIETARI_VECHI') ILIKE unaccent(${"%" + normalizedSearch + "%"})
|
||||||
|
)
|
||||||
|
ORDER BY "cadastralRef" ASC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return features.map((f) => {
|
||||||
|
const e = (f.enrichment ?? {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
nrCad: String(e.NR_CAD ?? f.cadastral_ref ?? ""),
|
||||||
|
nrCF: String(e.NR_CF ?? ""),
|
||||||
|
proprietari: String(e.PROPRIETARI ?? ""),
|
||||||
|
proprietariVechi: String(e.PROPRIETARI_VECHI ?? ""),
|
||||||
|
adresa: String(e.ADRESA ?? ""),
|
||||||
|
suprafata:
|
||||||
|
typeof e.SUPRAFATA_2D === "number"
|
||||||
|
? e.SUPRAFATA_2D
|
||||||
|
: (f.area_value ?? ""),
|
||||||
|
intravilan: String(e.INTRAVILAN ?? ""),
|
||||||
|
categorieFolosinta: String(e.CATEGORIE_FOLOSINTA ?? ""),
|
||||||
|
source: "db" as const,
|
||||||
|
immovablePk: "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* DB search fallback — without unaccent (if extension not installed) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async function searchOwnerInDbSimple(
|
||||||
|
siruta: string,
|
||||||
|
ownerName: string,
|
||||||
|
limit = 50,
|
||||||
|
): Promise<OwnerSearchResult[]> {
|
||||||
|
const searchPattern = `%${ownerName.trim()}%`;
|
||||||
|
|
||||||
|
const features = await prisma.$queryRaw<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
cadastral_ref: string | null;
|
||||||
|
enrichment: Record<string, unknown> | null;
|
||||||
|
area_value: number | null;
|
||||||
|
}>
|
||||||
|
>`
|
||||||
|
SELECT id, "cadastralRef" as cadastral_ref, enrichment, "areaValue" as area_value
|
||||||
|
FROM "GisFeature"
|
||||||
|
WHERE siruta = ${siruta}
|
||||||
|
AND "layerId" = 'TERENURI_ACTIVE'
|
||||||
|
AND enrichment IS NOT NULL
|
||||||
|
AND (
|
||||||
|
enrichment->>'PROPRIETARI' ILIKE ${searchPattern}
|
||||||
|
OR enrichment->>'PROPRIETARI_VECHI' ILIKE ${searchPattern}
|
||||||
|
)
|
||||||
|
ORDER BY "cadastralRef" ASC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return features.map((f) => {
|
||||||
|
const e = (f.enrichment ?? {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
nrCad: String(e.NR_CAD ?? f.cadastral_ref ?? ""),
|
||||||
|
nrCF: String(e.NR_CF ?? ""),
|
||||||
|
proprietari: String(e.PROPRIETARI ?? ""),
|
||||||
|
proprietariVechi: String(e.PROPRIETARI_VECHI ?? ""),
|
||||||
|
adresa: String(e.ADRESA ?? ""),
|
||||||
|
suprafata:
|
||||||
|
typeof e.SUPRAFATA_2D === "number"
|
||||||
|
? e.SUPRAFATA_2D
|
||||||
|
: (f.area_value ?? ""),
|
||||||
|
intravilan: String(e.INTRAVILAN ?? ""),
|
||||||
|
categorieFolosinta: String(e.CATEGORIE_FOLOSINTA ?? ""),
|
||||||
|
source: "db" as const,
|
||||||
|
immovablePk: "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* eTerra API search — search by owner name via immovable list */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async function searchOwnerOnEterra(
|
||||||
|
client: EterraClient,
|
||||||
|
workspaceId: number,
|
||||||
|
siruta: string,
|
||||||
|
ownerName: string,
|
||||||
|
): Promise<OwnerSearchResult[]> {
|
||||||
|
const response = await client.searchImmovableByOwnerName(
|
||||||
|
workspaceId,
|
||||||
|
siruta,
|
||||||
|
ownerName,
|
||||||
|
0,
|
||||||
|
50,
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = response?.content ?? [];
|
||||||
|
if (items.length === 0) return [];
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return items.map((item: any) => ({
|
||||||
|
nrCad: String(item?.identifierDetails ?? ""),
|
||||||
|
nrCF: String(item?.paperLbNo ?? item?.paperCadNo ?? ""),
|
||||||
|
proprietari: "", // Not available from immovable list directly
|
||||||
|
proprietariVechi: "",
|
||||||
|
adresa: "",
|
||||||
|
suprafata: item?.measuredArea ?? item?.legalArea ?? item?.area ?? "",
|
||||||
|
intravilan: "",
|
||||||
|
categorieFolosinta: "",
|
||||||
|
source: "eterra" as const,
|
||||||
|
immovablePk: String(item?.immovablePk ?? ""),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Route handler */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/eterra/search-owner
|
||||||
|
*
|
||||||
|
* Search by owner (proprietar/titular) name.
|
||||||
|
*
|
||||||
|
* Priority: 1) Local DB (enriched features), 2) eTerra API.
|
||||||
|
* DB search is fast and rich. eTerra search is slower but works pre-sync.
|
||||||
|
*/
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await req.json()) as Body;
|
||||||
|
const siruta = String(body.siruta ?? "").trim();
|
||||||
|
const ownerName = String(body.ownerName ?? "").trim();
|
||||||
|
const source = body.source ?? "both";
|
||||||
|
|
||||||
|
if (!siruta || !/^\d+$/.test(siruta)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "SIRUTA obligatoriu" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!ownerName || ownerName.length < 2) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Numele proprietarului trebuie să aibă min. 2 caractere." },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: OwnerSearchResult[] = [];
|
||||||
|
let dbSearched = false;
|
||||||
|
let eterraSearched = false;
|
||||||
|
let eterraNote = "";
|
||||||
|
|
||||||
|
/* ── Step 1: DB search ──────────────────────────── */
|
||||||
|
if (source !== "eterra") {
|
||||||
|
try {
|
||||||
|
const dbResults = await searchOwnerInDb(siruta, ownerName);
|
||||||
|
results.push(...dbResults);
|
||||||
|
dbSearched = true;
|
||||||
|
} catch {
|
||||||
|
// unaccent extension might not be installed — try simple ILIKE
|
||||||
|
try {
|
||||||
|
const dbResults = await searchOwnerInDbSimple(siruta, ownerName);
|
||||||
|
results.push(...dbResults);
|
||||||
|
dbSearched = true;
|
||||||
|
} catch (e2) {
|
||||||
|
console.log(
|
||||||
|
"[search-owner] DB search failed:",
|
||||||
|
e2 instanceof Error ? e2.message : e2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Step 2: eTerra API search (if no DB results or source=both) ─ */
|
||||||
|
if (source !== "db" && results.length === 0) {
|
||||||
|
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) {
|
||||||
|
try {
|
||||||
|
const client = await EterraClient.create(username, password);
|
||||||
|
|
||||||
|
// Resolve workspace
|
||||||
|
let workspaceId = body.workspacePk ?? null;
|
||||||
|
if (!workspaceId || !Number.isFinite(workspaceId)) {
|
||||||
|
try {
|
||||||
|
const dbUat = await prisma.gisUat.findUnique({
|
||||||
|
where: { siruta },
|
||||||
|
select: { workspacePk: true },
|
||||||
|
});
|
||||||
|
if (dbUat?.workspacePk) workspaceId = dbUat.workspacePk;
|
||||||
|
} catch {
|
||||||
|
// DB lookup failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!workspaceId || !Number.isFinite(workspaceId)) {
|
||||||
|
workspaceId = await resolveWorkspace(client, siruta);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workspaceId) {
|
||||||
|
const eterraResults = await searchOwnerOnEterra(
|
||||||
|
client,
|
||||||
|
workspaceId,
|
||||||
|
siruta,
|
||||||
|
ownerName,
|
||||||
|
);
|
||||||
|
// Deduplicate against DB results by nrCad
|
||||||
|
const existingCads = new Set(results.map((r) => r.nrCad));
|
||||||
|
for (const r of eterraResults) {
|
||||||
|
if (!existingCads.has(r.nrCad)) {
|
||||||
|
results.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eterraSearched = true;
|
||||||
|
} else {
|
||||||
|
eterraNote =
|
||||||
|
"Nu s-a putut determina County/workspace pentru eTerra.";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
eterraNote = `eTerra: ${e instanceof Error ? e.message : "eroare"}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
eterraNote =
|
||||||
|
results.length === 0
|
||||||
|
? "Conectează-te la eTerra pentru a căuta online."
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
results,
|
||||||
|
total: results.length,
|
||||||
|
dbSearched,
|
||||||
|
eterraSearched,
|
||||||
|
eterraNote,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,8 @@ import {
|
|||||||
type LayerCatalogItem,
|
type LayerCatalogItem,
|
||||||
} from "../services/eterra-layers";
|
} from "../services/eterra-layers";
|
||||||
import type { ParcelDetail } from "@/app/api/eterra/search/route";
|
import type { ParcelDetail } from "@/app/api/eterra/search/route";
|
||||||
|
import type { OwnerSearchResult } from "@/app/api/eterra/search-owner/route";
|
||||||
|
import { User } from "lucide-react";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Types */
|
/* Types */
|
||||||
@@ -377,11 +379,18 @@ export function ParcelSyncModule() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
/* ── Parcel search tab ──────────────────────────────────────── */
|
/* ── Parcel search tab ──────────────────────────────────────── */
|
||||||
|
const [searchMode, setSearchMode] = useState<"cadastral" | "owner">("cadastral");
|
||||||
const [searchResults, setSearchResults] = useState<ParcelDetail[]>([]);
|
const [searchResults, setSearchResults] = useState<ParcelDetail[]>([]);
|
||||||
const [searchList, setSearchList] = useState<ParcelDetail[]>([]);
|
const [searchList, setSearchList] = useState<ParcelDetail[]>([]);
|
||||||
const [featuresSearch, setFeaturesSearch] = useState("");
|
const [featuresSearch, setFeaturesSearch] = useState("");
|
||||||
const [loadingFeatures, setLoadingFeatures] = useState(false);
|
const [loadingFeatures, setLoadingFeatures] = useState(false);
|
||||||
const [searchError, setSearchError] = useState("");
|
const [searchError, setSearchError] = useState("");
|
||||||
|
/* owner search */
|
||||||
|
const [ownerSearch, setOwnerSearch] = useState("");
|
||||||
|
const [ownerResults, setOwnerResults] = useState<OwnerSearchResult[]>([]);
|
||||||
|
const [ownerLoading, setOwnerLoading] = useState(false);
|
||||||
|
const [ownerError, setOwnerError] = useState("");
|
||||||
|
const [ownerNote, setOwnerNote] = useState("");
|
||||||
|
|
||||||
/* ── No-geometry import option ──────────────────────────────── */
|
/* ── No-geometry import option ──────────────────────────────── */
|
||||||
const [includeNoGeom, setIncludeNoGeom] = useState(false);
|
const [includeNoGeom, setIncludeNoGeom] = useState(false);
|
||||||
@@ -1407,6 +1416,85 @@ export function ParcelSyncModule() {
|
|||||||
[handleSearch],
|
[handleSearch],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* ── Owner search handler ────────────────────────────────── */
|
||||||
|
const handleOwnerSearch = useCallback(async () => {
|
||||||
|
if (!siruta || !/^\d+$/.test(siruta)) return;
|
||||||
|
if (!ownerSearch.trim() || ownerSearch.trim().length < 2) {
|
||||||
|
setOwnerError("Minim 2 caractere.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOwnerLoading(true);
|
||||||
|
setOwnerError("");
|
||||||
|
setOwnerNote("");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/eterra/search-owner", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
siruta,
|
||||||
|
ownerName: ownerSearch.trim(),
|
||||||
|
...(workspacePk ? { workspacePk } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
results?: OwnerSearchResult[];
|
||||||
|
total?: number;
|
||||||
|
dbSearched?: boolean;
|
||||||
|
eterraSearched?: boolean;
|
||||||
|
eterraNote?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
if (data.error) {
|
||||||
|
setOwnerResults([]);
|
||||||
|
setOwnerError(data.error);
|
||||||
|
} else {
|
||||||
|
setOwnerResults(data.results ?? []);
|
||||||
|
const notes: string[] = [];
|
||||||
|
if (data.dbSearched) notes.push("DB local");
|
||||||
|
if (data.eterraSearched) notes.push("eTerra API");
|
||||||
|
if (data.eterraNote) notes.push(data.eterraNote);
|
||||||
|
setOwnerNote(
|
||||||
|
notes.length > 0
|
||||||
|
? `Surse: ${notes.join(" + ")}${data.total ? ` · ${data.total} rezultate` : ""}`
|
||||||
|
: "",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setOwnerError("Eroare de rețea.");
|
||||||
|
}
|
||||||
|
setOwnerLoading(false);
|
||||||
|
}, [siruta, ownerSearch, workspacePk]);
|
||||||
|
|
||||||
|
const handleOwnerKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleOwnerSearch();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleOwnerSearch],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Convert an OwnerSearchResult → ParcelDetail so it can be added to the list */
|
||||||
|
const ownerResultToParcelDetail = useCallback(
|
||||||
|
(r: OwnerSearchResult): ParcelDetail => ({
|
||||||
|
nrCad: r.nrCad,
|
||||||
|
nrCF: r.nrCF,
|
||||||
|
nrCFVechi: "",
|
||||||
|
nrTopo: "",
|
||||||
|
intravilan: r.intravilan,
|
||||||
|
categorieFolosinta: r.categorieFolosinta,
|
||||||
|
adresa: r.adresa,
|
||||||
|
proprietari: r.proprietari || r.proprietariVechi,
|
||||||
|
proprietariActuali: r.proprietari,
|
||||||
|
proprietariVechi: r.proprietariVechi,
|
||||||
|
suprafata: typeof r.suprafata === "number" ? r.suprafata : null,
|
||||||
|
solicitant: "",
|
||||||
|
immovablePk: r.immovablePk,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Add result(s) to list for CSV export
|
// Add result(s) to list for CSV export
|
||||||
const addToList = useCallback((item: ParcelDetail) => {
|
const addToList = useCallback((item: ParcelDetail) => {
|
||||||
setSearchList((prev) => {
|
setSearchList((prev) => {
|
||||||
@@ -1643,58 +1731,133 @@ 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 || !session.connected ? (
|
{!sirutaValid ? (
|
||||||
<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>
|
<p>Selectează un UAT mai sus pentru a căuta parcele.</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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Search input */}
|
{/* Search input — mode toggle + input */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4 space-y-3">
|
||||||
<div className="flex gap-3 items-end">
|
{/* Mode toggle */}
|
||||||
<div className="space-y-1 flex-1">
|
<div className="flex gap-1 p-0.5 bg-muted rounded-md w-fit">
|
||||||
<Label className="text-xs">
|
<button
|
||||||
Numere cadastrale (separate prin virgulă sau Enter)
|
onClick={() => setSearchMode("cadastral")}
|
||||||
</Label>
|
className={cn(
|
||||||
<div className="relative">
|
"px-3 py-1 text-xs rounded font-medium transition-colors",
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
searchMode === "cadastral"
|
||||||
<Input
|
? "bg-background shadow text-foreground"
|
||||||
placeholder="ex: 62580 sau 62580, 62581, 62582"
|
: "text-muted-foreground hover:text-foreground",
|
||||||
className="pl-9"
|
|
||||||
value={featuresSearch}
|
|
||||||
onChange={(e) => setFeaturesSearch(e.target.value)}
|
|
||||||
onKeyDown={handleSearchKeyDown}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={() => void handleSearch()}
|
|
||||||
disabled={loadingFeatures || !featuresSearch.trim()}
|
|
||||||
>
|
|
||||||
{loadingFeatures ? (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Search className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
)}
|
||||||
Caută
|
>
|
||||||
</Button>
|
<Search className="inline h-3 w-3 mr-1 -mt-0.5" />
|
||||||
|
Nr. Cadastral
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchMode("owner")}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-1 text-xs rounded font-medium transition-colors",
|
||||||
|
searchMode === "owner"
|
||||||
|
? "bg-background shadow text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<User className="inline h-3 w-3 mr-1 -mt-0.5" />
|
||||||
|
Proprietar
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{searchError && (
|
|
||||||
<p className="text-xs text-destructive mt-2">{searchError}</p>
|
{/* Cadastral search input */}
|
||||||
|
{searchMode === "cadastral" && (
|
||||||
|
<div className="flex gap-3 items-end">
|
||||||
|
<div className="space-y-1 flex-1">
|
||||||
|
<Label className="text-xs">
|
||||||
|
Numere cadastrale (separate prin virgulă sau Enter)
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="ex: 62580 sau 62580, 62581, 62582"
|
||||||
|
className="pl-9"
|
||||||
|
value={featuresSearch}
|
||||||
|
onChange={(e) => setFeaturesSearch(e.target.value)}
|
||||||
|
onKeyDown={handleSearchKeyDown}
|
||||||
|
disabled={!session.connected}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!session.connected && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Necesită conexiune eTerra. Folosește modul Proprietar pentru a căuta offline în DB.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => void handleSearch()}
|
||||||
|
disabled={loadingFeatures || !featuresSearch.trim() || !session.connected}
|
||||||
|
>
|
||||||
|
{loadingFeatures ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Search className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Caută
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Owner search input */}
|
||||||
|
{searchMode === "owner" && (
|
||||||
|
<div className="flex gap-3 items-end">
|
||||||
|
<div className="space-y-1 flex-1">
|
||||||
|
<Label className="text-xs">
|
||||||
|
Nume proprietar (caută în DB local + eTerra)
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="ex: Popescu Ion"
|
||||||
|
className="pl-9"
|
||||||
|
value={ownerSearch}
|
||||||
|
onChange={(e) => setOwnerSearch(e.target.value)}
|
||||||
|
onKeyDown={handleOwnerKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => void handleOwnerSearch()}
|
||||||
|
disabled={ownerLoading || ownerSearch.trim().length < 2}
|
||||||
|
>
|
||||||
|
{ownerLoading ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Search className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Caută
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchMode === "cadastral" && searchError && (
|
||||||
|
<p className="text-xs text-destructive">{searchError}</p>
|
||||||
|
)}
|
||||||
|
{searchMode === "owner" && ownerError && (
|
||||||
|
<p className="text-xs text-destructive">{ownerError}</p>
|
||||||
|
)}
|
||||||
|
{searchMode === "owner" && ownerNote && (
|
||||||
|
<p className="text-xs text-muted-foreground">{ownerNote}</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Results */}
|
{/* ─── Cadastral search results ────────────── */}
|
||||||
{loadingFeatures && searchResults.length === 0 && (
|
{searchMode === "cadastral" && (
|
||||||
|
<>
|
||||||
|
{/* Results */}
|
||||||
|
{loadingFeatures && searchResults.length === 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-12 text-center text-muted-foreground">
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
<Loader2 className="h-10 w-10 mx-auto mb-3 animate-spin opacity-50" />
|
<Loader2 className="h-10 w-10 mx-auto mb-3 animate-spin opacity-50" />
|
||||||
@@ -1947,7 +2110,7 @@ export function ParcelSyncModule() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty state when no search has been done */}
|
{/* Empty state when no search has been done */}
|
||||||
{searchResults.length === 0 && !loadingFeatures && !searchError && (
|
{searchMode === "cadastral" && searchResults.length === 0 && !loadingFeatures && !searchError && (
|
||||||
<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" />
|
||||||
@@ -1959,6 +2122,221 @@ export function ParcelSyncModule() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── Owner search results ────────────────── */}
|
||||||
|
{searchMode === "owner" && (
|
||||||
|
<>
|
||||||
|
{ownerLoading && ownerResults.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ă proprietar...</p>
|
||||||
|
<p className="text-xs mt-1 opacity-60">
|
||||||
|
Caută mai întâi în DB local (date îmbogățite), apoi pe
|
||||||
|
eTerra.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ownerResults.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{ownerResults.length} rezultat
|
||||||
|
{ownerResults.length > 1 ? "e" : ""} pentru "
|
||||||
|
{ownerSearch}"
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
for (const r of ownerResults)
|
||||||
|
addToList(ownerResultToParcelDetail(r));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||||
|
Adaugă toate în listă
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
onClick={downloadCSV}
|
||||||
|
disabled={
|
||||||
|
ownerResults.length === 0 &&
|
||||||
|
searchList.length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FileDown className="mr-1 h-3.5 w-3.5" />
|
||||||
|
Descarcă CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{ownerResults.map((r, idx) => (
|
||||||
|
<Card key={`owner-${r.nrCad}-${idx}`}>
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold tabular-nums">
|
||||||
|
Nr. Cad. {r.nrCad}
|
||||||
|
</h3>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] mt-1"
|
||||||
|
>
|
||||||
|
{r.source === "db"
|
||||||
|
? "din baza de date"
|
||||||
|
: "eTerra online"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
title="Adaugă în listă"
|
||||||
|
onClick={() =>
|
||||||
|
addToList(ownerResultToParcelDetail(r))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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: ${r.nrCad}`,
|
||||||
|
r.nrCF ? `Nr. CF: ${r.nrCF}` : null,
|
||||||
|
r.proprietari
|
||||||
|
? `Proprietari: ${r.proprietari}`
|
||||||
|
: null,
|
||||||
|
r.proprietariVechi
|
||||||
|
? `Proprietari vechi: ${r.proprietariVechi}`
|
||||||
|
: null,
|
||||||
|
r.adresa ? `Adresă: ${r.adresa}` : null,
|
||||||
|
r.suprafata
|
||||||
|
? `Suprafață: ${r.suprafata} mp`
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
void navigator.clipboard.writeText(text);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClipboardCopy className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-2 text-sm">
|
||||||
|
{r.nrCF && (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground block">
|
||||||
|
Nr. CF
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">{r.nrCF}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{r.suprafata && (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground block">
|
||||||
|
Suprafață
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{typeof r.suprafata === "number"
|
||||||
|
? formatArea(r.suprafata)
|
||||||
|
: `${r.suprafata} mp`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{r.intravilan && (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-muted-foreground block">
|
||||||
|
Intravilan
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
r.intravilan === "Da"
|
||||||
|
? "default"
|
||||||
|
: r.intravilan === "Nu"
|
||||||
|
? "secondary"
|
||||||
|
: "outline"
|
||||||
|
}
|
||||||
|
className="text-[11px]"
|
||||||
|
>
|
||||||
|
{r.intravilan}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{r.categorieFolosinta && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<span className="text-xs text-muted-foreground block">
|
||||||
|
Categorii folosință
|
||||||
|
</span>
|
||||||
|
<span className="text-xs">
|
||||||
|
{r.categorieFolosinta}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{r.adresa && (
|
||||||
|
<div className="col-span-2">
|
||||||
|
<span className="text-xs text-muted-foreground block">
|
||||||
|
Adresă
|
||||||
|
</span>
|
||||||
|
<span>{r.adresa}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{r.proprietari && (
|
||||||
|
<div className="col-span-2 lg:col-span-4">
|
||||||
|
<span className="text-xs text-muted-foreground block">
|
||||||
|
Proprietari actuali
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-sm">
|
||||||
|
{r.proprietari}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{r.proprietariVechi && (
|
||||||
|
<div className="col-span-2 lg:col-span-4">
|
||||||
|
<span className="text-xs text-muted-foreground block">
|
||||||
|
Proprietari anteriori
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground/80">
|
||||||
|
{r.proprietariVechi}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ownerResults.length === 0 && !ownerLoading && !ownerError && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-muted-foreground">
|
||||||
|
<User className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||||
|
<p>Introdu numele proprietarului și apasă Caută.</p>
|
||||||
|
<p className="text-xs mt-1 opacity-60">
|
||||||
|
Caută în datele îmbogățite (DB local) și pe eTerra.
|
||||||
|
<br />
|
||||||
|
Pentru rezultate complete, lansează "Sync fundal — Magic" în tab-ul Export.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Saved list */}
|
{/* Saved list */}
|
||||||
{searchList.length > 0 && (
|
{searchList.length > 0 && (
|
||||||
|
|||||||
@@ -643,6 +643,86 @@ export class EterraClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search immovable list by owner/titular name.
|
||||||
|
*
|
||||||
|
* Uses the same `/api/immovable/list` endpoint, with a `personName`
|
||||||
|
* filter key. eTerra supports partial matching on this field.
|
||||||
|
*
|
||||||
|
* Falls back to `titularName` key if personName returns empty.
|
||||||
|
*
|
||||||
|
* @returns Paginated response with `content[]`, `totalPages`, `totalElements`
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
async searchImmovableByOwnerName(
|
||||||
|
workspaceId: string | number,
|
||||||
|
adminUnitId: string | number,
|
||||||
|
ownerName: string,
|
||||||
|
page = 0,
|
||||||
|
size = 20,
|
||||||
|
): Promise<any> {
|
||||||
|
const url = `${BASE_URL}/api/immovable/list`;
|
||||||
|
const baseFilters: 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: -1, type: "NUMBER", key: "inscrisCF", op: "=" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Try primary key: personName (used in some eTerra versions)
|
||||||
|
const keysToTry = ["personName", "titularName", "ownerName"];
|
||||||
|
for (const filterKey of keysToTry) {
|
||||||
|
try {
|
||||||
|
const filters = [
|
||||||
|
...baseFilters,
|
||||||
|
{
|
||||||
|
value: ownerName,
|
||||||
|
type: "STRING" as const,
|
||||||
|
key: filterKey,
|
||||||
|
op: "=",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const payload = { filters, nrElements: size, page, sorters: [] };
|
||||||
|
const result = await this.requestRaw(() =>
|
||||||
|
this.client.post(url, payload, {
|
||||||
|
headers: { "Content-Type": "application/json;charset=UTF-8" },
|
||||||
|
timeout: this.timeoutMs,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// If we got content back (even empty), it means the key is valid
|
||||||
|
if (result && typeof result === "object" && "content" in result) {
|
||||||
|
console.log(
|
||||||
|
`[searchByOwner] Key "${filterKey}" worked — ${(result as any).totalElements ?? 0} results`,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(
|
||||||
|
`[searchByOwner] Key "${filterKey}" failed:`,
|
||||||
|
e instanceof Error ? e.message : e,
|
||||||
|
);
|
||||||
|
// Try next key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All keys failed — return empty result
|
||||||
|
return { content: [], totalElements: 0, totalPages: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all counties (workspaces) from eTerra nomenclature.
|
* Fetch all counties (workspaces) from eTerra nomenclature.
|
||||||
* Returns array of { nomenPk, name, parentNomenPk, ... }
|
* Returns array of { nomenPk, name, parentNomenPk, ... }
|
||||||
|
|||||||
Reference in New Issue
Block a user