Files
ArchiTools/src/modules/parcel-sync/components/tabs/search-tab.tsx
T
AI Assistant 2b8d144924 fix(parcel-sync): replace Unicode escapes with actual Romanian diacritics
The \u0103, \u00ee etc. escape sequences were rendering literally in JSX
text nodes instead of displaying ă, î, ț, ș characters.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:24:11 +02:00

1450 lines
57 KiB
TypeScript

"use client";
import { useState, useCallback, useEffect } from "react";
import {
Search,
Loader2,
Plus,
FileDown,
ClipboardCopy,
Trash2,
XCircle,
Download,
Archive,
FileText,
User,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Badge } from "@/shared/components/ui/badge";
import { Card, CardContent } from "@/shared/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import { cn } from "@/shared/lib/utils";
import { EpayOrderButton } from "../epay-order-button";
import type { EpaySessionStatus } from "../epay-connect";
import type {
SessionStatus,
UatEntry,
ParcelDetail,
OwnerSearchResult,
} from "../parcel-sync-types";
import { formatArea, formatShortDate } from "../parcel-sync-types";
/* ------------------------------------------------------------------ */
/* Props */
/* ------------------------------------------------------------------ */
type SearchTabProps = {
siruta: string;
workspacePk: number | null;
sirutaValid: boolean;
session: SessionStatus;
selectedUat?: UatEntry;
epayStatus: EpaySessionStatus;
};
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function SearchTab({
siruta,
workspacePk,
sirutaValid,
session,
selectedUat,
epayStatus,
}: SearchTabProps) {
/* ── Parcel search ────────────────────────────────────────── */
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("");
/* ── CF extract statuses ──────────────────────────────────── */
const [cfStatusMap, setCfStatusMap] = useState<Record<string, string>>({});
const [cfLatestIds, setCfLatestIds] = useState<Record<string, string>>({});
const [cfExpiryDates, setCfExpiryDates] = useState<Record<string, string>>(
{},
);
const [cfStatusLoading, setCfStatusLoading] = useState(false);
const [listCfOrdering, setListCfOrdering] = useState(false);
const [listCfOrderResult, setListCfOrderResult] = useState("");
const [listCfDownloading, setListCfDownloading] = useState(false);
/* ── Search handlers ──────────────────────────────────────── */
const handleSearch = useCallback(async () => {
if (!siruta || !/^\d+$/.test(siruta)) return;
if (!featuresSearch.trim()) {
setSearchResults([]);
setSearchError("");
return;
}
setLoadingFeatures(true);
setSearchError("");
try {
const res = await fetch("/api/eterra/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
siruta,
search: featuresSearch.trim(),
...(workspacePk ? { workspacePk } : {}),
}),
});
const data = (await res.json()) as {
results?: ParcelDetail[];
total?: number;
error?: string;
};
if (data.error) {
setSearchResults([]);
setSearchError(data.error);
} else {
setSearchResults(data.results ?? []);
setSearchError("");
}
} catch {
setSearchError("Eroare de rețea.");
}
setLoadingFeatures(false);
}, [siruta, featuresSearch, workspacePk]);
const handleSearchKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
void handleSearch();
}
},
[handleSearch],
);
/* ── Owner search ──────────────────────────────────────────── */
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],
);
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,
}),
[],
);
/* ── List management ───────────────────────────────────────── */
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));
}, []);
const csvEscape = useCallback((val: string | number | null | undefined) => {
const s = val != null ? String(val) : "";
return `"${s.replace(/"/g, '""')}"`;
}, []);
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_ACTUALI",
"PROPRIETARI_VECHI",
"SOLICITANT",
];
const rows = items.map((p) => [
csvEscape(p.nrCad),
csvEscape(p.nrCF),
csvEscape(p.nrCFVechi),
csvEscape(p.nrTopo),
csvEscape(p.suprafata),
csvEscape(p.intravilan),
csvEscape(p.categorieFolosinta),
csvEscape(p.adresa),
csvEscape(p.proprietariActuali ?? p.proprietari),
csvEscape(p.proprietariVechi),
csvEscape(p.solicitant),
]);
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, csvEscape]);
/* ── CF extract status fetching ────────────────────────────── */
const fetchCfStatuses = useCallback(async (cadastralNumbers: string[]) => {
if (cadastralNumbers.length === 0) return;
setCfStatusLoading(true);
try {
const nrs = cadastralNumbers.join(",");
const res = await fetch(
`/api/ancpi/orders?nrCadastral=${encodeURIComponent(nrs)}&limit=1`,
);
const data = (await res.json()) as {
statusMap?: Record<string, string>;
latestById?: Record<
string,
{ id: string; expiresAt: string | null }
>;
};
if (data.statusMap) {
setCfStatusMap((prev) => ({ ...prev, ...data.statusMap }));
}
if (data.latestById) {
const idMap: Record<string, string> = {};
const expiryMap: Record<string, string> = {};
for (const [nr, rec] of Object.entries(data.latestById)) {
if (rec && typeof rec === "object" && "id" in rec) {
idMap[nr] = (rec as { id: string }).id;
const expires = (rec as { expiresAt: string | null }).expiresAt;
if (expires) {
expiryMap[nr] = expires;
}
}
}
setCfLatestIds((prev) => ({ ...prev, ...idMap }));
setCfExpiryDates((prev) => ({ ...prev, ...expiryMap }));
}
} catch {
/* silent */
} finally {
setCfStatusLoading(false);
}
}, []);
const refreshCfStatuses = useCallback(() => {
const allNrs = new Set<string>();
for (const r of searchResults) {
if (r.nrCad) allNrs.add(r.nrCad);
}
for (const p of searchList) {
if (p.nrCad) allNrs.add(p.nrCad);
}
if (allNrs.size > 0) {
void fetchCfStatuses(Array.from(allNrs));
}
}, [searchResults, searchList, fetchCfStatuses]);
// Auto-fetch CF statuses when search results change
useEffect(() => {
const nrs = searchResults.map((r) => r.nrCad).filter(Boolean);
if (nrs.length > 0) void fetchCfStatuses(nrs);
}, [searchResults, fetchCfStatuses]);
// Auto-fetch CF statuses when list changes
useEffect(() => {
const nrs = searchList.map((p) => p.nrCad).filter(Boolean);
if (nrs.length > 0) void fetchCfStatuses(nrs);
}, [searchList, fetchCfStatuses]);
/* ── List CF ordering + ZIP download ──────────────────────── */
const handleListCfOrder = useCallback(async () => {
if (!siruta || searchList.length === 0 || listCfOrdering) return;
const toOrder: typeof searchList = [];
const toReorder: typeof searchList = [];
const alreadyValid: typeof searchList = [];
for (const p of searchList) {
const status = cfStatusMap[p.nrCad];
if (status === "valid") {
alreadyValid.push(p);
} else if (status === "expired") {
toReorder.push(p);
} else {
if (status !== "processing") toOrder.push(p);
}
}
const newCount = toOrder.length;
const updateCount = toReorder.length;
const existingCount = alreadyValid.length;
if (newCount === 0 && updateCount === 0) {
setListCfOrderResult(
`Toate cele ${existingCount} extrase sunt valide.`,
);
return;
}
const msg = [
newCount > 0 ? `${newCount} extrase noi` : null,
updateCount > 0 ? `${updateCount} actualizari` : null,
existingCount > 0 ? `${existingCount} existente (skip)` : null,
]
.filter(Boolean)
.join(", ");
if (!window.confirm(`Comanda extrase CF:\n${msg}\n\nContinui?`)) return;
setListCfOrdering(true);
setListCfOrderResult("");
try {
const allToProcess = [...toOrder, ...toReorder];
const parcels = allToProcess.map((p) => ({
nrCadastral: p.nrCad,
siruta,
judetIndex: 0,
judetName: selectedUat?.county ?? "",
uatId: 0,
uatName: selectedUat?.name ?? "",
}));
const res = await fetch("/api/ancpi/order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parcels }),
});
const data = (await res.json()) as {
orders?: unknown[];
error?: string;
};
if (!res.ok || data.error) {
setListCfOrderResult(
`Eroare: ${data.error ?? "Eroare la comanda"}`,
);
} else {
const count = data.orders?.length ?? allToProcess.length;
setListCfOrderResult(
`${count} extras${count > 1 ? "e" : ""} CF trimis${count > 1 ? "e" : ""} la procesare.`,
);
const pollInterval = setInterval(() => {
void refreshCfStatuses();
}, 10_000);
setTimeout(() => clearInterval(pollInterval), 5 * 60 * 1000);
}
} catch {
setListCfOrderResult("Eroare retea.");
} finally {
setListCfOrdering(false);
}
}, [
siruta,
searchList,
listCfOrdering,
cfStatusMap,
selectedUat,
refreshCfStatuses,
]);
const handleListCfDownloadZip = useCallback(async () => {
if (searchList.length === 0 || listCfDownloading) return;
const ids: string[] = [];
for (const p of searchList) {
const status = cfStatusMap[p.nrCad];
const extractId = cfLatestIds[p.nrCad];
if (status === "valid" && extractId) ids.push(extractId);
}
if (ids.length === 0) {
setListCfOrderResult("Niciun extras CF valid in lista.");
return;
}
setListCfDownloading(true);
try {
const res = await fetch(`/api/ancpi/download-zip?ids=${ids.join(",")}`);
if (!res.ok) throw new Error("Eroare descarcare ZIP");
const blob = await res.blob();
const cd = res.headers.get("Content-Disposition") ?? "";
const match = /filename="?([^"]+)"?/.exec(cd);
const filename = match?.[1]
? decodeURIComponent(match[1])
: "Extrase_CF_lista.zip";
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch {
setListCfOrderResult("Eroare la descarcarea ZIP.");
} finally {
setListCfDownloading(false);
}
}, [searchList, cfStatusMap, cfLatestIds, listCfDownloading]);
/* ── CF status badge helper ────────────────────────────────── */
const CfStatusBadge = useCallback(
({
nrCad,
immovablePk,
}: {
nrCad: string;
immovablePk?: number | string | null;
}) => {
if (!immovablePk || !sirutaValid) return null;
const cfStatus = cfStatusMap[nrCad];
const extractId = cfLatestIds[nrCad];
const cfExpiry = cfExpiryDates[nrCad];
if (cfStatus === "valid") {
return (
<TooltipProvider>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="text-[10px] border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-400 cursor-default"
>
Extras CF
</Badge>
</TooltipTrigger>
<TooltipContent>
{cfExpiry
? `Valid pana la ${formatShortDate(cfExpiry)}`
: "Extras CF valid"}
</TooltipContent>
</Tooltip>
{extractId && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-emerald-600"
asChild
>
<a
href={`/api/ancpi/download?id=${extractId}`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-3.5 w-3.5" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent>Descarca extras CF</TooltipContent>
</Tooltip>
)}
</div>
</TooltipProvider>
);
}
if (cfStatus === "expired") {
return (
<TooltipProvider>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="text-[10px] border-orange-200 text-orange-600 dark:border-orange-800 dark:text-orange-400 cursor-default"
>
Expirat
</Badge>
</TooltipTrigger>
<TooltipContent>
{cfExpiry
? `Expirat pe ${formatShortDate(cfExpiry)}`
: "Extras CF expirat"}
</TooltipContent>
</Tooltip>
<EpayOrderButton
nrCadastral={nrCad}
siruta={siruta}
judetName={selectedUat?.county ?? ""}
uatName={selectedUat?.name ?? ""}
label="Actualizeaza"
tooltipText="Comanda extras CF nou (1 credit)"
/>
</div>
</TooltipProvider>
);
}
if (cfStatus === "processing") {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="text-[10px] border-yellow-200 text-yellow-600 dark:border-yellow-800 dark:text-yellow-400 animate-pulse cursor-default"
>
Se proceseaza...
</Badge>
</TooltipTrigger>
<TooltipContent>Comanda in curs de procesare</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
// "none" or unknown
return (
<EpayOrderButton
nrCadastral={nrCad}
siruta={siruta}
judetName={selectedUat?.county ?? ""}
uatName={selectedUat?.name ?? ""}
tooltipText="Comanda extras CF (1 credit)"
/>
);
},
[cfStatusMap, cfLatestIds, cfExpiryDates, sirutaValid, siruta, selectedUat],
);
/* ── Render ─────────────────────────────────────────────────── */
if (!sirutaValid) {
return (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Search className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>Selectează un UAT mai sus pentru a căuta parcele.</p>
</CardContent>
</Card>
);
}
return (
<>
{/* Search input — mode toggle + input */}
<Card>
<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",
)}
>
<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>
{/* 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>
{/* ─── Cadastral search results ────────────── */}
{searchMode === "cadastral" && (
<>
{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="Copiaza 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
? `Suprafata: ${p.suprafata.toLocaleString("ro-RO")} mp`
: null,
`Intravilan: ${p.intravilan || "—"}`,
p.categorieFolosinta
? `Categorie: ${p.categorieFolosinta}`
: null,
p.adresa ? `Adresa: ${p.adresa}` : null,
p.proprietariActuali
? `Proprietari actuali: ${p.proprietariActuali}`
: null,
p.proprietariVechi
? `Proprietari vechi: ${p.proprietariVechi}`
: null,
!p.proprietariActuali &&
!p.proprietariVechi &&
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>
<CfStatusBadge
nrCad={p.nrCad}
immovablePk={p.immovablePk}
/>
</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.proprietariActuali || p.proprietariVechi) && (
<div className="col-span-2 lg:col-span-4">
{p.proprietariActuali && (
<div className="mb-1">
<span className="text-xs text-muted-foreground block">
Proprietari actuali
</span>
<span className="font-medium text-sm">
{p.proprietariActuali}
</span>
</div>
)}
{p.proprietariVechi && (
<div>
<span className="text-xs text-muted-foreground block">
Proprietari anteriori
</span>
<span className="text-[11px] text-muted-foreground/80">
{p.proprietariVechi}
</span>
</div>
)}
{!p.proprietariActuali &&
!p.proprietariVechi &&
p.proprietari && (
<div>
<span className="text-xs text-muted-foreground block">
Proprietari
</span>
<span>{p.proprietari}</span>
</div>
)}
</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 */}
{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" />
<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>
)}
</>
)}
{/* ─── 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="Copiaza 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 ? `Adresa: ${r.adresa}` : null,
r.suprafata
? `Suprafata: ${r.suprafata} mp`
: null,
]
.filter(Boolean)
.join("\n");
void navigator.clipboard.writeText(text);
}}
>
<ClipboardCopy className="h-3.5 w-3.5" />
</Button>
<CfStatusBadge
nrCad={r.nrCad}
immovablePk={r.immovablePk}
/>
</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 && (
<Card>
<CardContent className="pt-4">
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<h3 className="text-sm font-medium">
Lista mea ({searchList.length} parcele)
</h3>
<div className="flex gap-2 flex-wrap">
<Button
size="sm"
variant="outline"
onClick={() => {
setSearchList([]);
setListCfOrderResult("");
}}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
Goleste
</Button>
<Button size="sm" variant="outline" onClick={downloadCSV}>
<FileDown className="mr-1 h-3.5 w-3.5" />
CSV din lista
</Button>
{/* Download all valid CF extracts as ZIP */}
{searchList.some(
(p) => cfStatusMap[p.nrCad] === "valid",
) &&
(() => {
const validCount = searchList.filter(
(p) => cfStatusMap[p.nrCad] === "valid",
).length;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-400"
disabled={listCfDownloading}
onClick={() =>
void handleListCfDownloadZip()
}
>
{listCfDownloading ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Archive className="mr-1 h-3.5 w-3.5" />
)}
Descarca Extrase CF
</Button>
</TooltipTrigger>
<TooltipContent>{`Descarca ZIP cu ${validCount} extrase valide din lista`}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
})()}
{/* Order CF extracts for list */}
{epayStatus.connected &&
(() => {
const newCount = searchList.filter((p) => {
const s = cfStatusMap[p.nrCad];
return (
s !== "valid" &&
s !== "expired" &&
s !== "processing"
);
}).length;
const updateCount = searchList.filter(
(p) => cfStatusMap[p.nrCad] === "expired",
).length;
const totalCredits = newCount + updateCount;
const validCount = searchList.filter(
(p) => cfStatusMap[p.nrCad] === "valid",
).length;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
disabled={listCfOrdering}
onClick={() => void handleListCfOrder()}
>
{listCfOrdering ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<FileText className="mr-1 h-3.5 w-3.5" />
)}
Scoate Extrase CF
</Button>
</TooltipTrigger>
<TooltipContent>
{`Comanda ${newCount} extrase noi + ${updateCount} actualizari = ${totalCredits} credite. ${validCount} existente valide raman.`}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
})()}
</div>
</div>
{/* Order result message */}
{listCfOrderResult && (
<p
className={cn(
"text-xs mb-2",
listCfOrderResult.startsWith("Eroare")
? "text-destructive"
: "text-emerald-600 dark:text-emerald-400",
)}
>
{listCfOrderResult}
</p>
)}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40">
<th className="px-2 py-2 text-center font-medium w-8 text-muted-foreground">
#
</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">
Suprafata
</th>
<th className="px-3 py-2 text-left font-medium hidden md:table-cell">
Proprietari
</th>
<th className="px-3 py-2 text-center font-medium">
Extras CF
</th>
<th className="px-3 py-2 w-8"></th>
</tr>
</thead>
<tbody>
{searchList.map((p, idx) => {
const cfStatus = cfStatusMap[p.nrCad];
const cfExpiry = cfExpiryDates[p.nrCad];
return (
<tr
key={`list-${p.nrCad}-${p.immovablePk}`}
className="border-b hover:bg-muted/30 transition-colors"
>
<td className="px-2 py-2 text-center text-xs text-muted-foreground tabular-nums">
{idx + 1}
</td>
<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 text-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{cfStatus === "valid" ? (
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-400 dark:border-emerald-800 cursor-default">
Valid
</span>
) : cfStatus === "expired" ? (
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950/40 dark:text-orange-400 dark:border-orange-800 cursor-default">
Expirat
</span>
) : cfStatus === "processing" ? (
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-yellow-100 text-yellow-700 border-yellow-200 dark:bg-yellow-950/40 dark:text-yellow-400 dark:border-yellow-800 animate-pulse cursor-default">
Procesare
</span>
) : (
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium bg-muted text-muted-foreground border-muted-foreground/20 cursor-default">
Lipsa
</span>
)}
</TooltipTrigger>
<TooltipContent>
{cfStatus === "valid"
? cfExpiry
? `Extras CF valid pana la ${formatShortDate(cfExpiry)}`
: "Extras CF valid"
: cfStatus === "expired"
? cfExpiry
? `Extras CF expirat pe ${formatShortDate(cfExpiry)}. Va fi actualizat automat la 'Scoate Extrase CF'.`
: "Extras CF expirat. Va fi actualizat automat la 'Scoate Extrase CF'."
: cfStatus === "processing"
? "Comanda in curs de procesare"
: "Nu exista extras CF. Apasa 'Scoate Extrase CF' pentru a comanda."}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</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>
)}
</>
);
}