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:
@@ -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ă "Sync fundal — Magic" în tab-ul Export.
|
||||
Pentru rezultate complete, lansează "Sync fundal —
|
||||
Magic" î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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user