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:
AI Assistant
2026-03-08 03:48:23 +02:00
parent 8bb4a47ac5
commit 6558c690f5
3 changed files with 855 additions and 39 deletions
@@ -53,6 +53,8 @@ import {
type LayerCatalogItem,
} from "../services/eterra-layers";
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 */
@@ -377,11 +379,18 @@ export function ParcelSyncModule() {
} | null>(null);
/* ── Parcel search tab ──────────────────────────────────────── */
const [searchMode, setSearchMode] = useState<"cadastral" | "owner">("cadastral");
const [searchResults, setSearchResults] = useState<ParcelDetail[]>([]);
const [searchList, setSearchList] = useState<ParcelDetail[]>([]);
const [featuresSearch, setFeaturesSearch] = useState("");
const [loadingFeatures, setLoadingFeatures] = useState(false);
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 ──────────────────────────────── */
const [includeNoGeom, setIncludeNoGeom] = useState(false);
@@ -1407,6 +1416,85 @@ export function ParcelSyncModule() {
[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
const addToList = useCallback((item: ParcelDetail) => {
setSearchList((prev) => {
@@ -1643,58 +1731,133 @@ export function ParcelSyncModule() {
{/* Tab 1: Parcel search */}
{/* ═══════════════════════════════════════════════════════ */}
<TabsContent value="search" className="space-y-4">
{!sirutaValid || !session.connected ? (
{!sirutaValid ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Search className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>
{!session.connected
? "Conectează-te la eTerra și selectează un UAT."
: "Selectează un UAT mai sus pentru a căuta parcele."}
</p>
<p>Selectează un UAT mai sus pentru a căuta parcele.</p>
</CardContent>
</Card>
) : (
<>
{/* Search input */}
{/* Search input — mode toggle + input */}
<Card>
<CardContent className="pt-4">
<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}
/>
</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" />
<CardContent className="pt-4 space-y-3">
{/* Mode toggle */}
<div className="flex gap-1 p-0.5 bg-muted rounded-md w-fit">
<button
onClick={() => setSearchMode("cadastral")}
className={cn(
"px-3 py-1 text-xs rounded font-medium transition-colors",
searchMode === "cadastral"
? "bg-background shadow text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
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>
{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>
</Card>
{/* Results */}
{loadingFeatures && searchResults.length === 0 && (
{/* ─── Cadastral search results ────────────── */}
{searchMode === "cadastral" && (
<>
{/* 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" />
@@ -1947,7 +2110,7 @@ export function ParcelSyncModule() {
)}
{/* Empty state when no search has been done */}
{searchResults.length === 0 && !loadingFeatures && !searchError && (
{searchMode === "cadastral" && 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" />
@@ -1959,6 +2122,221 @@ export function ParcelSyncModule() {
</CardContent>
</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 &quot;
{ownerSearch}&quot;
</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ă &quot;Sync fundal Magic&quot; în tab-ul Export.
</p>
</CardContent>
</Card>
)}
</>
)}
{/* Saved list */}
{searchList.length > 0 && (