feat(parcel-sync): per-UAT analytics dashboard in Database tab

- New API route /api/eterra/uat-dashboard with SQL aggregates
  (area stats, intravilan/extravilan split, land use, top owners, fun facts)
- CSS-only dashboard component: KPI cards, donut ring, bar charts
- Dashboard button on each UAT card in DB tab, expands panel below
This commit is contained in:
AI Assistant
2026-03-08 10:18:34 +02:00
parent 6558c690f5
commit 6557cd5374
3 changed files with 1373 additions and 355 deletions
@@ -25,6 +25,7 @@ import {
Clock,
ArrowDownToLine,
AlertTriangle,
BarChart3,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
@@ -55,6 +56,7 @@ import {
import type { ParcelDetail } from "@/app/api/eterra/search/route";
import type { OwnerSearchResult } from "@/app/api/eterra/search-owner/route";
import { User } from "lucide-react";
import { UatDashboard } from "./uat-dashboard";
/* ------------------------------------------------------------------ */
/* Types */
@@ -379,7 +381,9 @@ export function ParcelSyncModule() {
} | null>(null);
/* ── Parcel search tab ──────────────────────────────────────── */
const [searchMode, setSearchMode] = useState<"cadastral" | "owner">("cadastral");
const [searchMode, setSearchMode] = useState<"cadastral" | "owner">(
"cadastral",
);
const [searchResults, setSearchResults] = useState<ParcelDetail[]>([]);
const [searchList, setSearchList] = useState<ParcelDetail[]>([]);
const [featuresSearch, setFeaturesSearch] = useState("");
@@ -391,6 +395,8 @@ export function ParcelSyncModule() {
const [ownerLoading, setOwnerLoading] = useState(false);
const [ownerError, setOwnerError] = useState("");
const [ownerNote, setOwnerNote] = useState("");
/* dashboard */
const [dashboardSiruta, setDashboardSiruta] = useState<string | null>(null);
/* ── No-geometry import option ──────────────────────────────── */
const [includeNoGeom, setIncludeNoGeom] = useState(false);
@@ -1791,13 +1797,18 @@ export function ParcelSyncModule() {
</div>
{!session.connected && (
<p className="text-xs text-muted-foreground">
Necesită conexiune eTerra. Folosește modul Proprietar pentru a căuta offline în DB.
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}
disabled={
loadingFeatures ||
!featuresSearch.trim() ||
!session.connected
}
>
{loadingFeatures ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
@@ -1858,270 +1869,275 @@ export function ParcelSyncModule() {
<>
{/* 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>
)}
<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ă
{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>
)}
</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>
<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.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>
</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 && (
{/* 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>
<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>
<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>
)}
{p.proprietariVechi && (
</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.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>
</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">
Proprietari anteriori
CF vechi
</span>
<span className="text-[11px] text-muted-foreground/80">
{p.proprietariVechi}
<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.proprietariActuali &&
!p.proprietariVechi &&
p.proprietari && (
<div>
<span className="text-xs text-muted-foreground block">
Proprietari
</span>
<span>{p.proprietari}</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>
)}
{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 */}
{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>
))}
</div>
</>
)}
{/* Empty state when no search has been done */}
{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>
)}
)}
</>
)}
@@ -2166,8 +2182,7 @@ export function ParcelSyncModule() {
variant="default"
onClick={downloadCSV}
disabled={
ownerResults.length === 0 &&
searchList.length === 0
ownerResults.length === 0 && searchList.length === 0
}
>
<FileDown className="mr-1 h-3.5 w-3.5" />
@@ -2330,7 +2345,8 @@ export function ParcelSyncModule() {
<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.
Pentru rezultate complete, lansează &quot;Sync fundal
Magic&quot; în tab-ul Export.
</p>
</CardContent>
</Card>
@@ -3885,120 +3901,149 @@ export function ParcelSyncModule() {
const isCurrentUat = sirutaValid && uat.siruta === siruta;
return (
<Card
key={uat.siruta}
className={cn(
"transition-colors",
isCurrentUat && "ring-1 ring-emerald-400/50",
)}
>
<CardContent className="py-3 px-4 space-y-2">
{/* UAT header row */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold">
{uat.uatName}
</span>
{uat.county && (
<span className="text-[10px] text-muted-foreground">
({uat.county})
<div key={uat.siruta} className="space-y-3">
<Card
className={cn(
"transition-colors",
isCurrentUat && "ring-1 ring-emerald-400/50",
)}
>
<CardContent className="py-3 px-4 space-y-2">
{/* UAT header row */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold">
{uat.uatName}
</span>
)}
<span className="text-[10px] font-mono text-muted-foreground">
#{uat.siruta}
</span>
{isCurrentUat && (
<Badge
variant="outline"
className="text-[9px] h-4 text-emerald-600 border-emerald-300 dark:text-emerald-400 dark:border-emerald-700"
>
selectat
</Badge>
)}
<span className="ml-auto text-xs text-muted-foreground">
{oldestSync ? relativeTime(oldestSync) : "—"}
</span>
</div>
{/* Category counts in a single compact row */}
<div className="flex items-center gap-2 flex-wrap text-xs">
{(
Object.entries(LAYER_CATEGORY_LABELS) as [
LayerCategory,
string,
][]
).map(([cat, label]) => {
const count = catCounts[cat] ?? 0;
if (count === 0) return null;
return (
<span
key={cat}
className="inline-flex items-center gap-1"
{uat.county && (
<span className="text-[10px] text-muted-foreground">
({uat.county})
</span>
)}
<span className="text-[10px] font-mono text-muted-foreground">
#{uat.siruta}
</span>
{isCurrentUat && (
<Badge
variant="outline"
className="text-[9px] h-4 text-emerald-600 border-emerald-300 dark:text-emerald-400 dark:border-emerald-700"
>
<span className="text-muted-foreground">
{label}:
</span>
<span className="font-medium tabular-nums">
{count.toLocaleString("ro-RO")}
</span>
</span>
);
})}
{enrichedTotal > 0 && (
<span className="inline-flex items-center gap-1">
<Sparkles className="h-3 w-3 text-teal-600 dark:text-teal-400" />
<span className="text-muted-foreground">Magic:</span>
<span className="font-medium tabular-nums text-teal-700 dark:text-teal-400">
{enrichedTotal.toLocaleString("ro-RO")}
selectat
</Badge>
)}
<span className="ml-auto flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="h-5 px-2 text-[10px] gap-1 text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 hover:bg-indigo-50 dark:hover:bg-indigo-950"
onClick={() =>
setDashboardSiruta(
dashboardSiruta === uat.siruta
? null
: uat.siruta,
)
}
>
<BarChart3 className="h-3 w-3" />
Dashboard
</Button>
<span className="text-xs text-muted-foreground">
{oldestSync ? relativeTime(oldestSync) : "—"}
</span>
</span>
)}
{noGeomTotal > 0 && (
<span className="inline-flex items-center gap-1">
<span className="text-muted-foreground">
Fără geom:
</span>
<span className="font-medium tabular-nums text-amber-600 dark:text-amber-400">
{noGeomTotal.toLocaleString("ro-RO")}
</span>
</span>
)}
</div>
</div>
{/* Layer detail pills */}
<div className="flex gap-1.5 flex-wrap">
{uat.layers
.sort((a, b) => b.count - a.count)
.map((layer) => {
const meta = findLayerById(layer.layerId);
const label =
meta?.label ?? layer.layerId.replace(/_/g, " ");
const isEnriched = layer.enrichedCount > 0;
{/* Category counts in a single compact row */}
<div className="flex items-center gap-2 flex-wrap text-xs">
{(
Object.entries(LAYER_CATEGORY_LABELS) as [
LayerCategory,
string,
][]
).map(([cat, label]) => {
const count = catCounts[cat] ?? 0;
if (count === 0) return null;
return (
<span
key={layer.layerId}
className={cn(
"inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[10px]",
isEnriched
? "border-teal-200 bg-teal-50/50 dark:border-teal-800 dark:bg-teal-950/30"
: "border-muted bg-muted/30",
)}
title={`${label}: ${layer.count} entități${isEnriched ? `, ${layer.enrichedCount} îmbogățite` : ""}${layer.lastSynced ? `, sync: ${new Date(layer.lastSynced).toLocaleDateString("ro-RO")}` : ""}`}
key={cat}
className="inline-flex items-center gap-1"
>
<span className="truncate max-w-[120px]">
{label}
<span className="text-muted-foreground">
{label}:
</span>
<span className="font-medium tabular-nums">
{layer.count.toLocaleString("ro-RO")}
{count.toLocaleString("ro-RO")}
</span>
{isEnriched && (
<Sparkles className="h-2.5 w-2.5 text-teal-600 dark:text-teal-400" />
)}
</span>
);
})}
</div>
</CardContent>
</Card>
{enrichedTotal > 0 && (
<span className="inline-flex items-center gap-1">
<Sparkles className="h-3 w-3 text-teal-600 dark:text-teal-400" />
<span className="text-muted-foreground">
Magic:
</span>
<span className="font-medium tabular-nums text-teal-700 dark:text-teal-400">
{enrichedTotal.toLocaleString("ro-RO")}
</span>
</span>
)}
{noGeomTotal > 0 && (
<span className="inline-flex items-center gap-1">
<span className="text-muted-foreground">
Fără geom:
</span>
<span className="font-medium tabular-nums text-amber-600 dark:text-amber-400">
{noGeomTotal.toLocaleString("ro-RO")}
</span>
</span>
)}
</div>
{/* Layer detail pills */}
<div className="flex gap-1.5 flex-wrap">
{uat.layers
.sort((a, b) => b.count - a.count)
.map((layer) => {
const meta = findLayerById(layer.layerId);
const label =
meta?.label ?? layer.layerId.replace(/_/g, " ");
const isEnriched = layer.enrichedCount > 0;
return (
<span
key={layer.layerId}
className={cn(
"inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[10px]",
isEnriched
? "border-teal-200 bg-teal-50/50 dark:border-teal-800 dark:bg-teal-950/30"
: "border-muted bg-muted/30",
)}
title={`${label}: ${layer.count} entități${isEnriched ? `, ${layer.enrichedCount} îmbogățite` : ""}${layer.lastSynced ? `, sync: ${new Date(layer.lastSynced).toLocaleDateString("ro-RO")}` : ""}`}
>
<span className="truncate max-w-[120px]">
{label}
</span>
<span className="font-medium tabular-nums">
{layer.count.toLocaleString("ro-RO")}
</span>
{isEnriched && (
<Sparkles className="h-2.5 w-2.5 text-teal-600 dark:text-teal-400" />
)}
</span>
);
})}
</div>
</CardContent>
</Card>
{/* Dashboard panel (expanded below card) */}
{dashboardSiruta === uat.siruta && (
<UatDashboard
siruta={uat.siruta}
uatName={uat.uatName}
onClose={() => setDashboardSiruta(null)}
/>
)}
</div>
);
})}
</>