ParcelSync: DB status card on Export tab + Baza de Date tab with per-category sync
This commit is contained in:
@@ -22,6 +22,8 @@ import {
|
||||
RefreshCw,
|
||||
Database,
|
||||
HardDrive,
|
||||
Clock,
|
||||
ArrowDownToLine,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
@@ -813,6 +815,59 @@ export function ParcelSyncModule() {
|
||||
[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) */
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
@@ -1052,6 +1107,38 @@ export function ParcelSyncModule() {
|
||||
? Math.round((exportProgress.downloaded / exportProgress.total) * 100)
|
||||
: 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 */
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
@@ -1163,6 +1250,10 @@ export function ParcelSyncModule() {
|
||||
<Download className="h-4 w-4" />
|
||||
Export
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="database" className="gap-1.5">
|
||||
<Database className="h-4 w-4" />
|
||||
Baza de Date
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
@@ -2042,6 +2133,67 @@ export function ParcelSyncModule() {
|
||||
{/* Tab 3: Export */}
|
||||
{/* ═══════════════════════════════════════════════════════ */}
|
||||
<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 */}
|
||||
{sirutaValid && session.connected ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
@@ -2195,6 +2347,216 @@ export function ParcelSyncModule() {
|
||||
</Card>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user