ParcelSync: PROPRIETARI_VECHI in enrichment + global DB summary tab (all UATs without login)

This commit is contained in:
AI Assistant
2026-03-07 12:16:34 +02:00
parent abd00aecfb
commit d50b9ea0e2
4 changed files with 382 additions and 188 deletions
@@ -47,6 +47,7 @@ import { cn } from "@/shared/lib/utils";
import {
LAYER_CATALOG,
LAYER_CATEGORY_LABELS,
findLayerById,
type LayerCategory,
type LayerCatalogItem,
} from "../services/eterra-layers";
@@ -341,6 +342,28 @@ export function ParcelSyncModule() {
const [exportingLocal, setExportingLocal] = useState(false);
const refreshSyncRef = useRef<(() => void) | null>(null);
/* ── Global DB summary (all UATs) ────────────────────────────── */
type DbUatSummary = {
siruta: string;
uatName: string;
county: string | null;
layers: {
layerId: string;
count: number;
enrichedCount: number;
lastSynced: string | null;
}[];
totalFeatures: number;
totalEnriched: number;
};
type DbSummary = {
uats: DbUatSummary[];
totalFeatures: number;
totalUats: number;
};
const [dbSummary, setDbSummary] = useState<DbSummary | null>(null);
const [dbSummaryLoading, setDbSummaryLoading] = useState(false);
/* ── PostGIS setup ───────────────────────────────────────────── */
const [postgisRunning, setPostgisRunning] = useState(false);
const [postgisResult, setPostgisResult] = useState<{
@@ -407,6 +430,23 @@ export function ParcelSyncModule() {
};
}, [fetchSession]);
/* ── Fetch global DB summary ─────────────────────────────────── */
const fetchDbSummary = useCallback(async () => {
setDbSummaryLoading(true);
try {
const res = await fetch("/api/eterra/db-summary");
const data = (await res.json()) as DbSummary;
if (data.uats) setDbSummary(data);
} catch {
// silent
}
setDbSummaryLoading(false);
}, []);
useEffect(() => {
void fetchDbSummary();
}, [fetchDbSummary]);
/* ════════════════════════════════════════════════════════════ */
/* (Sync effect removed — POST seeds from uat.json, no */
/* eTerra nomenclature needed. Workspace resolved lazily.) */
@@ -2351,15 +2391,15 @@ export function ParcelSyncModule() {
{/* ═══════════════════════════════════════════════════════ */}
{/* Tab 4: Baza de Date */}
{/* ═══════════════════════════════════════════════════════ */}
<TabsContent value="database" className="space-y-4">
{!sirutaValid ? (
<TabsContent value="database" className="space-y-3">
{dbSummaryLoading && !dbSummary ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<MapPin className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>Selectează un UAT pentru a vedea datele locale.</p>
<Loader2 className="h-8 w-8 mx-auto mb-3 animate-spin opacity-40" />
<p>Se încarcă datele din baza de date</p>
</CardContent>
</Card>
) : dbLayersSummary.length === 0 ? (
) : !dbSummary || dbSummary.totalFeatures === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Database className="h-10 w-10 mx-auto mb-3 opacity-30" />
@@ -2371,187 +2411,155 @@ export function ParcelSyncModule() {
</Card>
) : (
<>
{/* Summary + Sync All */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-3">
<Database className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">
{dbTotalFeatures.toLocaleString("ro-RO")} entități din{" "}
{dbLayersSummary.length} layere
</p>
<p className="text-xs text-muted-foreground">
{dbLayersSummary.filter((l) => l.isFresh).length} proaspete,{" "}
{dbLayersSummary.filter((l) => !l.isFresh).length} vechi
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={
!!syncingLayer || exportingLocal || syncQueue.length > 0
}
onClick={() =>
void handleExportLocal(dbLayersSummary.map((l) => l.id))
}
>
{exportingLocal ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<ArrowDownToLine className="h-3.5 w-3.5 mr-1.5" />
)}
Export GPKG
</Button>
<Button
size="sm"
disabled={
!!syncingLayer || syncQueue.length > 0 || !session.connected
}
onClick={() =>
void handleSyncMultiple(dbLayersSummary.map((l) => l.id))
}
>
{syncQueue.length > 0 ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
)}
Sync tot
</Button>
{/* Header row */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{dbSummary.totalFeatures.toLocaleString("ro-RO")} entități
</span>
<span className="text-xs text-muted-foreground">
din {dbSummary.totalUats} UAT-uri
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={dbSummaryLoading}
onClick={() => void fetchDbSummary()}
>
{dbSummaryLoading ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<RefreshCw className="h-3 w-3 mr-1" />
)}
Reîncarcă
</Button>
</div>
{/* Sync progress banner */}
{(syncingLayer || syncQueue.length > 0) && syncProgress && (
<Card className="border-emerald-200 dark:border-emerald-800">
<CardContent className="py-2.5 px-4">
<div className="flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin text-emerald-600 shrink-0" />
<span>{syncProgress}</span>
{syncQueue.length > 0 && (
<Badge variant="secondary" className="ml-auto text-xs">
{syncQueue.length} rămase
</Badge>
)}
</div>
</CardContent>
</Card>
)}
{/* Layers grouped by category */}
{(
Object.entries(LAYER_CATEGORY_LABELS) as [LayerCategory, string][]
).map(([cat, catLabel]) => {
const catLayers = dbLayersSummary.filter(
(l) => l.category === cat,
);
if (catLayers.length === 0) return null;
{/* UAT cards */}
{dbSummary.uats.map((uat) => {
const catCounts: Record<string, number> = {};
let enrichedTotal = 0;
let oldestSync: Date | null = null;
for (const layer of uat.layers) {
const cat =
findLayerById(layer.layerId)?.category ?? "administrativ";
catCounts[cat] = (catCounts[cat] ?? 0) + layer.count;
enrichedTotal += layer.enrichedCount;
if (layer.lastSynced) {
const d = new Date(layer.lastSynced);
if (!oldestSync || d < oldestSync) oldestSync = d;
}
}
const isCurrentUat = sirutaValid && uat.siruta === siruta;
return (
<div key={cat} className="space-y-2">
{/* Category header */}
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{catLabel}
<span className="ml-2 font-normal">
(
{catLayers
.reduce((s, l) => s + l.count, 0)
.toLocaleString("ro-RO")}
)
<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>
</h3>
{catLayers.length > 1 && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={
!!syncingLayer ||
syncQueue.length > 0 ||
!session.connected
}
onClick={() =>
void handleSyncMultiple(catLayers.map((l) => l.id))
}
>
<RefreshCw className="h-3 w-3 mr-1" />
Sync categorie
</Button>
)}
</div>
{/* Layer rows */}
<Card>
<CardContent className="p-0 divide-y">
{catLayers.map((layer) => (
<div
key={layer.id}
className="flex items-center gap-3 px-4 py-2.5"
{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"
>
{/* Layer info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">
{layer.label}
</span>
<Badge
variant="secondary"
className="text-[10px] h-5 shrink-0"
>
{layer.count.toLocaleString("ro-RO")}
</Badge>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground">
{relativeTime(layer.lastSynced)}
</span>
{layer.isFresh ? (
<span className="inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
) : (
<span className="inline-flex h-1.5 w-1.5 rounded-full bg-amber-500" />
)}
</div>
</div>
selectat
</Badge>
)}
<span className="ml-auto text-xs text-muted-foreground">
{oldestSync ? relativeTime(oldestSync) : "—"}
</span>
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
title="Export GPKG din DB"
disabled={exportingLocal}
onClick={() => void handleExportLocal([layer.id])}
>
<ArrowDownToLine className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
title="Re-sincronizare din eTerra"
disabled={
!!syncingLayer ||
syncQueue.length > 0 ||
!session.connected
}
onClick={() => void handleSyncLayer(layer.id)}
>
{syncingLayer === layer.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
{/* 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"
>
<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")}
</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",
)}
</Button>
</div>
</div>
))}
</CardContent>
</Card>
</div>
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>
);
})}
</>