ParcelSync: DB status card on Export tab + Baza de Date tab with per-category sync

This commit is contained in:
AI Assistant
2026-03-07 12:00:20 +02:00
parent de1e779770
commit abd00aecfb
@@ -22,6 +22,8 @@ import {
RefreshCw, RefreshCw,
Database, Database,
HardDrive, HardDrive,
Clock,
ArrowDownToLine,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input"; import { Input } from "@/shared/components/ui/input";
@@ -813,6 +815,59 @@ export function ParcelSyncModule() {
[siruta, exportingLocal], [siruta, exportingLocal],
); );
// Sync multiple layers sequentially (for "sync all" / "sync category")
const [syncQueue, setSyncQueue] = useState<string[]>([]);
const syncQueueRef = useRef<string[]>([]);
const handleSyncMultiple = useCallback(
async (layerIds: string[]) => {
if (!siruta || syncingLayer || syncQueue.length > 0) return;
syncQueueRef.current = [...layerIds];
setSyncQueue([...layerIds]);
for (const layerId of layerIds) {
setSyncingLayer(layerId);
setSyncProgress(
`Sincronizare ${LAYER_CATALOG.find((l) => l.id === layerId)?.label ?? layerId}`,
);
try {
const res = await fetch("/api/eterra/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
siruta,
layerId,
jobId: crypto.randomUUID(),
}),
});
const data = (await res.json()) as {
error?: string;
newFeatures?: number;
removedFeatures?: number;
totalLocal?: number;
};
if (data.error) {
setSyncProgress(`Eroare: ${data.error}`);
}
} catch {
setSyncProgress("Eroare rețea");
}
// Remove from queue
syncQueueRef.current = syncQueueRef.current.filter(
(id) => id !== layerId,
);
setSyncQueue([...syncQueueRef.current]);
}
// Done — refresh status
await fetchSyncStatus();
setSyncingLayer(null);
setSyncProgress("");
setSyncQueue([]);
syncQueueRef.current = [];
},
[siruta, syncingLayer, syncQueue.length, fetchSyncStatus],
);
/* ════════════════════════════════════════════════════════════ */ /* ════════════════════════════════════════════════════════════ */
/* PostGIS setup (one-time) */ /* PostGIS setup (one-time) */
/* ════════════════════════════════════════════════════════════ */ /* ════════════════════════════════════════════════════════════ */
@@ -1052,6 +1107,38 @@ export function ParcelSyncModule() {
? Math.round((exportProgress.downloaded / exportProgress.total) * 100) ? Math.round((exportProgress.downloaded / exportProgress.total) * 100)
: 0; : 0;
// DB status: which layers have data for the current UAT
const dbLayersSummary = useMemo(() => {
if (!sirutaValid || syncingSiruta !== siruta) return [];
return LAYER_CATALOG.filter((l) => (syncLocalCounts[l.id] ?? 0) > 0).map(
(l) => {
const count = syncLocalCounts[l.id] ?? 0;
const lastRun = syncRuns.find(
(r) => r.layerId === l.id && r.status === "done",
);
const lastSynced = lastRun?.completedAt
? new Date(lastRun.completedAt)
: null;
const ageMs = lastSynced ? Date.now() - lastSynced.getTime() : null;
const isFresh = ageMs !== null ? ageMs < 168 * 60 * 60 * 1000 : false;
return { ...l, count, lastSynced, isFresh };
},
);
}, [sirutaValid, syncingSiruta, siruta, syncLocalCounts, syncRuns]);
const dbTotalFeatures = dbLayersSummary.reduce((sum, l) => sum + l.count, 0);
const relativeTime = (date: Date | null) => {
if (!date) return "niciodată";
const mins = Math.floor((Date.now() - date.getTime()) / 60_000);
if (mins < 1) return "acum";
if (mins < 60) return `acum ${mins} min`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `acum ${hours}h`;
const days = Math.floor(hours / 24);
return `acum ${days}z`;
};
/* ════════════════════════════════════════════════════════════ */ /* ════════════════════════════════════════════════════════════ */
/* Render */ /* Render */
/* ════════════════════════════════════════════════════════════ */ /* ════════════════════════════════════════════════════════════ */
@@ -1163,6 +1250,10 @@ export function ParcelSyncModule() {
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
Export Export
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="database" className="gap-1.5">
<Database className="h-4 w-4" />
Baza de Date
</TabsTrigger>
</TabsList> </TabsList>
</div> </div>
@@ -2042,6 +2133,67 @@ export function ParcelSyncModule() {
{/* Tab 3: Export */} {/* Tab 3: Export */}
{/* ═══════════════════════════════════════════════════════ */} {/* ═══════════════════════════════════════════════════════ */}
<TabsContent value="export" className="space-y-4"> <TabsContent value="export" className="space-y-4">
{/* DB freshness status */}
{sirutaValid && dbLayersSummary.length > 0 && (
<Card className="border-dashed">
<CardContent className="py-3 px-4">
<div className="flex items-center gap-3 flex-wrap">
<Database className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{dbTotalFeatures.toLocaleString("ro-RO")}
</span>{" "}
entități în DB din{" "}
<span className="font-medium text-foreground">
{dbLayersSummary.length}
</span>{" "}
layere
</span>
{(() => {
const freshCount = dbLayersSummary.filter(
(l) => l.isFresh,
).length;
const staleCount = dbLayersSummary.length - freshCount;
const oldestSync = dbLayersSummary.reduce(
(oldest, l) => {
if (!l.lastSynced) return oldest;
if (!oldest || l.lastSynced < oldest) return l.lastSynced;
return oldest;
},
null as Date | null,
);
return (
<>
{staleCount === 0 ? (
<Badge
variant="outline"
className="text-emerald-600 border-emerald-200 dark:text-emerald-400 dark:border-emerald-800"
>
<CheckCircle2 className="h-3 w-3 mr-1" />
Proaspete
</Badge>
) : (
<Badge
variant="outline"
className="text-amber-600 border-amber-200 dark:text-amber-400 dark:border-amber-800"
>
<Clock className="h-3 w-3 mr-1" />
{staleCount} vechi
</Badge>
)}
{oldestSync && (
<span className="text-xs text-muted-foreground">
Ultima sincronizare: {relativeTime(oldestSync)}
</span>
)}
</>
);
})()}
</div>
</CardContent>
</Card>
)}
{/* Hero buttons */} {/* Hero buttons */}
{sirutaValid && session.connected ? ( {sirutaValid && session.connected ? (
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
@@ -2195,6 +2347,216 @@ export function ParcelSyncModule() {
</Card> </Card>
)} )}
</TabsContent> </TabsContent>
{/* ═══════════════════════════════════════════════════════ */}
{/* Tab 4: Baza de Date */}
{/* ═══════════════════════════════════════════════════════ */}
<TabsContent value="database" className="space-y-4">
{!sirutaValid ? (
<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>
</CardContent>
</Card>
) : dbLayersSummary.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Database className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="font-medium">Nicio dată în baza de date</p>
<p className="text-xs mt-1">
Folosește tab-ul Export pentru a sincroniza date din eTerra.
</p>
</CardContent>
</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>
</div>
</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;
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")}
)
</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"
>
{/* 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>
{/* 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" />
)}
</Button>
</div>
</div>
))}
</CardContent>
</Card>
</div>
);
})}
</>
)}
</TabsContent>
</Tabs> </Tabs>
); );
} }