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:
@@ -3,13 +3,10 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import {
|
||||
Search,
|
||||
RefreshCw,
|
||||
Download,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
MapPin,
|
||||
Layers,
|
||||
Sparkles,
|
||||
@@ -19,6 +16,9 @@ import {
|
||||
LogOut,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
ClipboardCopy,
|
||||
Trash2,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
type LayerCategory,
|
||||
type LayerCatalogItem,
|
||||
} from "../services/eterra-layers";
|
||||
import type { ParcelFeature } from "../types";
|
||||
import type { ParcelDetail } from "@/app/api/eterra/search/route";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
@@ -295,13 +295,11 @@ export function ParcelSyncModule() {
|
||||
const [downloadingLayer, setDownloadingLayer] = useState<string | null>(null);
|
||||
|
||||
/* ── Parcel search tab ──────────────────────────────────────── */
|
||||
const [features, setFeatures] = useState<ParcelFeature[]>([]);
|
||||
const [featuresTotal, setFeaturesTotal] = useState(0);
|
||||
const [featuresPage, setFeaturesPage] = useState(1);
|
||||
const [searchResults, setSearchResults] = useState<ParcelDetail[]>([]);
|
||||
const [searchList, setSearchList] = useState<ParcelDetail[]>([]);
|
||||
const [featuresSearch, setFeaturesSearch] = useState("");
|
||||
const [featuresLayerFilter, setFeaturesLayerFilter] = useState("");
|
||||
const [loadingFeatures, setLoadingFeatures] = useState(false);
|
||||
const PAGE_SIZE = 50;
|
||||
const [searchError, setSearchError] = useState("");
|
||||
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
/* Load UAT data + check server session on mount */
|
||||
@@ -594,67 +592,109 @@ export function ParcelSyncModule() {
|
||||
);
|
||||
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
/* Load features (parcel search tab) */
|
||||
/* - When search term is present → live query eTerra */
|
||||
/* - When search is empty → show nothing (prompt user) */
|
||||
/* Search parcels by cadastral number (eTerra app API) */
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
|
||||
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const loadFeatures = useCallback(async () => {
|
||||
const handleSearch = useCallback(async () => {
|
||||
if (!siruta || !/^\d+$/.test(siruta)) return;
|
||||
if (!featuresSearch.trim()) {
|
||||
// No search term → clear results
|
||||
setFeatures([]);
|
||||
setFeaturesTotal(0);
|
||||
setSearchResults([]);
|
||||
setSearchError("");
|
||||
return;
|
||||
}
|
||||
setLoadingFeatures(true);
|
||||
setSearchError("");
|
||||
try {
|
||||
// Live search against eTerra
|
||||
const res = await fetch("/api/eterra/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
siruta,
|
||||
search: featuresSearch.trim(),
|
||||
layerId: featuresLayerFilter || undefined,
|
||||
}),
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
features?: ParcelFeature[];
|
||||
results?: ParcelDetail[];
|
||||
total?: number;
|
||||
error?: string;
|
||||
};
|
||||
if (data.error) {
|
||||
setFeatures([]);
|
||||
setFeaturesTotal(0);
|
||||
setSearchResults([]);
|
||||
setSearchError(data.error);
|
||||
} else {
|
||||
if (data.features) setFeatures(data.features);
|
||||
if (data.total != null) setFeaturesTotal(data.total);
|
||||
setSearchResults(data.results ?? []);
|
||||
setSearchError("");
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
setSearchError("Eroare de rețea.");
|
||||
}
|
||||
setLoadingFeatures(false);
|
||||
}, [siruta, featuresLayerFilter, featuresSearch]);
|
||||
}, [siruta, featuresSearch]);
|
||||
|
||||
// Debounced search — waits 600ms after user stops typing
|
||||
useEffect(() => {
|
||||
if (!siruta || !/^\d+$/.test(siruta)) return;
|
||||
if (!featuresSearch.trim()) {
|
||||
setFeatures([]);
|
||||
setFeaturesTotal(0);
|
||||
return;
|
||||
}
|
||||
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
|
||||
searchDebounceRef.current = setTimeout(() => {
|
||||
void loadFeatures();
|
||||
}, 600);
|
||||
return () => {
|
||||
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
|
||||
};
|
||||
}, [siruta, featuresLayerFilter, featuresSearch, loadFeatures]);
|
||||
// No auto-search — user clicks button or presses Enter
|
||||
const handleSearchKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleSearch();
|
||||
}
|
||||
},
|
||||
[handleSearch],
|
||||
);
|
||||
|
||||
// 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 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 */
|
||||
@@ -670,7 +710,6 @@ export function ParcelSyncModule() {
|
||||
}, []);
|
||||
|
||||
const sirutaValid = siruta.length > 0 && /^\d+$/.test(siruta);
|
||||
const totalPages = Math.ceil(featuresTotal / PAGE_SIZE);
|
||||
|
||||
const progressPct =
|
||||
exportProgress?.total && exportProgress.total > 0
|
||||
@@ -738,7 +777,7 @@ export function ParcelSyncModule() {
|
||||
setUatQuery(label);
|
||||
setSiruta(item.siruta);
|
||||
setShowUatResults(false);
|
||||
setFeaturesPage(1);
|
||||
setSearchResults([]);
|
||||
}}
|
||||
>
|
||||
<span className="font-medium">{item.name}</span>
|
||||
@@ -795,170 +834,335 @@ export function ParcelSyncModule() {
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Filters */}
|
||||
{/* Search input */}
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex gap-3 flex-wrap items-end">
|
||||
<div className="space-y-1 flex-1 min-w-[200px]">
|
||||
<Label className="text-xs">Căutare</Label>
|
||||
<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="Număr cadastral (ex: 62580)..."
|
||||
placeholder="ex: 62580 sau 62580, 62581, 62582"
|
||||
className="pl-9"
|
||||
value={featuresSearch}
|
||||
onChange={(e) => {
|
||||
setFeaturesSearch(e.target.value);
|
||||
setFeaturesPage(1);
|
||||
}}
|
||||
onChange={(e) => setFeaturesSearch(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
</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
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={loadFeatures}
|
||||
disabled={loadingFeatures}
|
||||
onClick={() => void handleSearch()}
|
||||
disabled={loadingFeatures || !featuresSearch.trim()}
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 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>
|
||||
{searchError && (
|
||||
<p className="text-xs text-destructive mt-2">{searchError}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</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>
|
||||
|
||||
@@ -463,6 +463,50 @@ export class EterraClient {
|
||||
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 ------------------------------------------------ */
|
||||
|
||||
private layerQueryUrl(layer: LayerConfig) {
|
||||
|
||||
Reference in New Issue
Block a user