feat(parcel-sync): county-aware UAT autocomplete with workspace resolution

- New /api/eterra/uats endpoint fetches all counties + UATs from eTerra,
  caches server-side for 1 hour, returns enriched data with county name
  and workspacePk for each UAT
- When eTerra is connected, auto-fetches enriched UAT list (replaces
  static uat.json fallback)  shows 'FELEACU (57582), CLUJ' format
- UAT autocomplete now searches both UAT name and county name
- Selected UAT stores workspacePk in state, passes it directly to
  /api/eterra/search  eliminates slow per-search county resolution
- Search route accepts optional workspacePk, falls back to resolveWorkspace()
- Dropdown shows UAT name, SIRUTA code, and county prominently
- Increased autocomplete results from 8 to 12 items
This commit is contained in:
AI Assistant
2026-03-06 20:46:44 +02:00
parent 540b02d8d2
commit d948e5c1cf
4 changed files with 320 additions and 61 deletions
@@ -51,7 +51,12 @@ import type { ParcelDetail } from "@/app/api/eterra/search/route";
/* Types */
/* ------------------------------------------------------------------ */
type UatEntry = { siruta: string; name: string; county?: string };
type UatEntry = {
siruta: string;
name: string;
county?: string;
workspacePk?: number;
};
type SessionStatus = {
connected: boolean;
@@ -277,7 +282,9 @@ export function ParcelSyncModule() {
const [uatResults, setUatResults] = useState<UatEntry[]>([]);
const [showUatResults, setShowUatResults] = useState(false);
const [siruta, setSiruta] = useState("");
const [workspacePk, setWorkspacePk] = useState<number | null>(null);
const uatRef = useRef<HTMLDivElement>(null);
const enrichedUatsFetched = useRef(false);
/* ── Export state ────────────────────────────────────────────── */
const [exportJobId, setExportJobId] = useState<string | null>(null);
@@ -318,6 +325,7 @@ export function ParcelSyncModule() {
}, []);
useEffect(() => {
// Load static UAT data as fallback
fetch("/uat.json")
.then((res) => res.json())
.then((data: UatEntry[]) => setUatData(data))
@@ -333,6 +341,36 @@ export function ParcelSyncModule() {
};
}, [fetchSession]);
/* ════════════════════════════════════════════════════════════ */
/* Fetch enriched UAT list (with county + workspace) when */
/* connected to eTerra. Falls back to static uat.json. */
/* ════════════════════════════════════════════════════════════ */
useEffect(() => {
if (!session.connected || enrichedUatsFetched.current) return;
enrichedUatsFetched.current = true;
fetch("/api/eterra/uats")
.then((res) => res.json())
.then(
(data: {
uats?: {
siruta: string;
name: string;
county: string;
workspacePk: number;
}[];
}) => {
if (data.uats && data.uats.length > 0) {
setUatData(data.uats);
}
},
)
.catch(() => {
// Keep static uat.json data
});
}, [session.connected]);
/* ════════════════════════════════════════════════════════════ */
/* UAT autocomplete filter */
/* ════════════════════════════════════════════════════════════ */
@@ -346,12 +384,15 @@ export function ParcelSyncModule() {
const isDigit = /^\d+$/.test(raw);
const query = normalizeText(raw);
const results = uatData
.filter((item) =>
isDigit
? item.siruta.startsWith(raw)
: normalizeText(item.name).includes(query),
)
.slice(0, 8);
.filter((item) => {
if (isDigit) return item.siruta.startsWith(raw);
// Match UAT name or county name
if (normalizeText(item.name).includes(query)) return true;
if (item.county && normalizeText(item.county).includes(query))
return true;
return false;
})
.slice(0, 12);
setUatResults(results);
}, [uatQuery, uatData]);
@@ -611,6 +652,7 @@ export function ParcelSyncModule() {
body: JSON.stringify({
siruta,
search: featuresSearch.trim(),
...(workspacePk ? { workspacePk } : {}),
}),
});
const data = (await res.json()) as {
@@ -629,7 +671,7 @@ export function ParcelSyncModule() {
setSearchError("Eroare de rețea.");
}
setLoadingFeatures(false);
}, [siruta, featuresSearch]);
}, [siruta, featuresSearch, workspacePk]);
// No auto-search — user clicks button or presses Enter
const handleSearchKeyDown = useCallback(
@@ -643,16 +685,17 @@ export function ParcelSyncModule() {
);
// Add result(s) to list for CSV export
const addToList = useCallback(
(item: ParcelDetail) => {
setSearchList((prev) => {
if (prev.some((p) => p.nrCad === item.nrCad && p.immovablePk === item.immovablePk))
return prev;
return [...prev, item];
});
},
[],
);
const addToList = useCallback((item: ParcelDetail) => {
setSearchList((prev) => {
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));
@@ -772,18 +815,31 @@ export function ParcelSyncModule() {
onMouseDown={(e) => {
e.preventDefault();
const label = item.county
? `${item.name} (${item.siruta}) ${item.county}`
? `${item.name} (${item.siruta}), ${item.county}`
: `${item.name} (${item.siruta})`;
setUatQuery(label);
setSiruta(item.siruta);
setWorkspacePk(item.workspacePk ?? null);
setShowUatResults(false);
setSearchResults([]);
}}
>
<span className="font-medium">{item.name}</span>
<span className="text-xs text-muted-foreground font-mono ml-2">
<span>
<span className="font-medium">{item.name}</span>
<span className="text-muted-foreground ml-1.5">
({item.siruta})
</span>
{item.county && (
<span className="text-muted-foreground">
,{" "}
<span className="font-medium text-foreground/70">
{item.county}
</span>
</span>
)}
</span>
<span className="text-xs text-muted-foreground font-mono ml-2 shrink-0">
{item.siruta}
{item.county ? ` · ${item.county}` : ""}
</span>
</button>
))}
@@ -878,7 +934,8 @@ export function ParcelSyncModule() {
<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).
Prima căutare pe un UAT nou poate dura ~10-30s (se încarcă
lista de județe).
</p>
</CardContent>
</Card>
@@ -889,7 +946,8 @@ export function ParcelSyncModule() {
{/* Action bar */}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{searchResults.length} rezultat{searchResults.length > 1 ? "e" : ""}
{searchResults.length} rezultat
{searchResults.length > 1 ? "e" : ""}
{searchList.length > 0 && (
<span className="ml-2">
· <strong>{searchList.length}</strong> în listă
@@ -913,7 +971,9 @@ export function ParcelSyncModule() {
size="sm"
variant="default"
onClick={downloadCSV}
disabled={searchResults.length === 0 && searchList.length === 0}
disabled={
searchResults.length === 0 && searchList.length === 0
}
>
<FileDown className="mr-1 h-3.5 w-3.5" />
Descarcă CSV
@@ -963,7 +1023,9 @@ export function ParcelSyncModule() {
const text = [
`Nr. Cad: ${p.nrCad}`,
`Nr. CF: ${p.nrCF || "—"}`,
p.nrCFVechi ? `CF vechi: ${p.nrCFVechi}` : null,
p.nrCFVechi
? `CF vechi: ${p.nrCFVechi}`
: null,
p.nrTopo ? `Nr. Topo: ${p.nrTopo}` : null,
p.suprafata != null
? `Suprafață: ${p.suprafata.toLocaleString("ro-RO")} mp`
@@ -973,8 +1035,12 @@ export function ParcelSyncModule() {
? `Categorie: ${p.categorieFolosinta}`
: null,
p.adresa ? `Adresă: ${p.adresa}` : null,
p.proprietari ? `Proprietari: ${p.proprietari}` : null,
p.solicitant ? `Solicitant: ${p.solicitant}` : null,
p.proprietari
? `Proprietari: ${p.proprietari}`
: null,
p.solicitant
? `Solicitant: ${p.solicitant}`
: null,
]
.filter(Boolean)
.join("\n");
@@ -1087,7 +1153,8 @@ export function ParcelSyncModule() {
<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ă.
Poți căuta mai multe parcele simultan, separate prin
virgulă.
</p>
</CardContent>
</Card>
@@ -1120,10 +1187,18 @@ export function ParcelSyncModule() {
<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 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>
@@ -1140,7 +1215,9 @@ export function ParcelSyncModule() {
{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) : "—"}
{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 || "—"}
@@ -408,23 +408,52 @@ export class EterraClient {
/* ---- Magic-mode methods (eTerra application APIs) ------------- */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async fetchImmAppsByImmovable(immovableId: string | number, workspaceId: string | number): Promise<any[]> {
async fetchImmAppsByImmovable(
immovableId: string | number,
workspaceId: string | number,
): Promise<any[]> {
const url = `${BASE_URL}/api/immApps/byImm/list/${immovableId}/${workspaceId}`;
return this.getRawJson(url);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async fetchParcelFolosinte(workspaceId: string | number, immovableId: string | number, applicationId: string | number, page = 1): Promise<any[]> {
async fetchParcelFolosinte(
workspaceId: string | number,
immovableId: string | number,
applicationId: string | number,
page = 1,
): Promise<any[]> {
const url = `${BASE_URL}/api/immApps/parcels/list/${workspaceId}/${immovableId}/${applicationId}/${page}`;
return this.getRawJson(url);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async fetchImmovableListByAdminUnit(workspaceId: string | number, adminUnitId: string | number, page = 0, size = 200, includeInscrisCF = true): Promise<any> {
async fetchImmovableListByAdminUnit(
workspaceId: string | number,
adminUnitId: string | number,
page = 0,
size = 200,
includeInscrisCF = true,
): 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: "=" },
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: "C", type: "STRING", key: "immovableType", op: "<>C" },
];
if (includeInscrisCF) {
@@ -440,7 +469,10 @@ export class EterraClient {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async fetchDocumentationData(workspaceId: string | number, immovableIds: Array<string | number>): Promise<any> {
async fetchDocumentationData(
workspaceId: string | number,
immovableIds: Array<string | number>,
): Promise<any> {
const url = `${BASE_URL}/api/documentation/data/`;
const payload = {
workflowCode: "EXPLORE_DATABASE",
@@ -458,7 +490,12 @@ export class EterraClient {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async fetchImmovableParcelDetails(workspaceId: string | number, immovableId: string | number, page = 1, size = 1): Promise<any[]> {
async fetchImmovableParcelDetails(
workspaceId: string | number,
immovableId: string | number,
page = 1,
size = 1,
): Promise<any[]> {
const url = `${BASE_URL}/api/immovable/details/parcels/list/${workspaceId}/${immovableId}/${page}/${size}`;
return this.getRawJson(url);
}
@@ -469,12 +506,38 @@ export class EterraClient {
* 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> {
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: "=" },
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" },
];
@@ -502,7 +565,9 @@ export class EterraClient {
* Returns array of { nomenPk, name, parentNomenPk, ... }
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async fetchAdminUnitsByCounty(countyNomenPk: string | number): Promise<any[]> {
async fetchAdminUnitsByCounty(
countyNomenPk: string | number,
): Promise<any[]> {
const url = `${BASE_URL}/api/adm/nomen/ADMINISTRATIVEUNIT/filterByParent/${countyNomenPk}`;
return this.getRawJson(url);
}