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,
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user