feat: add parcel-sync module (eTerra ANCPI integration with PostGIS)

- 31 eTerra layer catalog (terenuri, cladiri, documentatii, administrativ)
- Incremental sync engine (OBJECTID comparison, only downloads new features)
- PostGIS-ready Prisma schema (GisFeature, GisSyncRun, GisUat models)
- 7 API routes (/api/eterra/login, count, sync, features, layers/summary, progress, sync-status)
- Full UI with 3 tabs (Sincronizare, Parcele, Istoric)
- Env var auth (ETERRA_USERNAME / ETERRA_PASSWORD)
- Real-time sync progress tracking with polling
This commit is contained in:
AI Assistant
2026-03-06 00:36:29 +02:00
parent 51dbfcb2bd
commit 7cdea66fa2
25 changed files with 3097 additions and 12 deletions
@@ -0,0 +1,928 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import {
Search,
RefreshCw,
Database,
Cloud,
Download,
CheckCircle2,
XCircle,
Loader2,
ChevronLeft,
ChevronRight,
MapPin,
History,
Layers,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import { Badge } from "@/shared/components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/shared/components/ui/tabs";
import { cn } from "@/shared/lib/utils";
import {
LAYER_CATALOG,
LAYER_CATEGORY_LABELS,
type LayerCategory,
type LayerCatalogItem,
} from "../services/eterra-layers";
import type { SyncProgress, ParcelFeature } from "../types";
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function formatDate(iso?: string | null) {
if (!iso) return "—";
const d = new Date(iso);
return d.toLocaleDateString("ro-RO", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function formatArea(val?: number | null) {
if (val == null) return "—";
return val.toLocaleString("ro-RO", { maximumFractionDigits: 2 }) + " mp";
}
function StatusBadge({ status }: { status: string }) {
switch (status) {
case "done":
return (
<Badge className="bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400 hover:bg-emerald-100">
<CheckCircle2 className="mr-1 h-3 w-3" />
Finalizat
</Badge>
);
case "running":
return (
<Badge className="bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 hover:bg-blue-100 animate-pulse">
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
În curs
</Badge>
);
case "error":
return (
<Badge variant="destructive">
<XCircle className="mr-1 h-3 w-3" />
Eroare
</Badge>
);
default:
return <Badge variant="outline">{status}</Badge>;
}
}
/* ------------------------------------------------------------------ */
/* Main Component */
/* ------------------------------------------------------------------ */
export function ParcelSyncModule() {
/* ---- Connection ---- */
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [connectionError, setConnectionError] = useState("");
/* ---- UAT selection ---- */
const [siruta, setSiruta] = useState("");
const [uatName, setUatName] = useState("");
/* ---- Remote / Local counts ---- */
const [remoteCounts, setRemoteCounts] = useState<
Record<string, { count: number; error?: string }>
>({});
const [localCounts, setLocalCounts] = useState<Record<string, number>>({});
const [loadingCounts, setLoadingCounts] = useState(false);
/* ---- Sync ---- */
const [syncingLayer, setSyncingLayer] = useState<string | null>(null);
const [syncProgress, setSyncProgress] = useState<SyncProgress | null>(null);
type SyncRun = {
id: string;
siruta: string;
uatName?: string;
layerId: string;
status: string;
totalRemote: number;
totalLocal: number;
newFeatures: number;
removedFeatures: number;
startedAt: string;
completedAt?: string;
errorMessage?: string;
};
const [syncHistory, setSyncHistory] = useState<SyncRun[]>([]);
/* ---- Features browser ---- */
const [features, setFeatures] = useState<ParcelFeature[]>([]);
const [featuresTotal, setFeaturesTotal] = useState(0);
const [featuresPage, setFeaturesPage] = useState(1);
const [featuresSearch, setFeaturesSearch] = useState("");
const [featuresLayerFilter, setFeaturesLayerFilter] = useState("");
const [loadingFeatures, setLoadingFeatures] = useState(false);
const progressRef = useRef<ReturnType<typeof setInterval> | null>(null);
const PAGE_SIZE = 50;
/* ================================================================ */
/* Connection */
/* ================================================================ */
const handleConnect = useCallback(async () => {
setConnecting(true);
setConnectionError("");
try {
const res = await fetch("/api/eterra/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const data = (await res.json()) as { success?: boolean; error?: string };
if (data.success) setConnected(true);
else setConnectionError(data.error ?? "Eroare conectare");
} catch {
setConnectionError("Eroare rețea");
}
setConnecting(false);
}, []);
/* ================================================================ */
/* Load remote counts */
/* ================================================================ */
const loadRemoteCounts = useCallback(async () => {
if (!siruta || !/^\d+$/.test(siruta)) return;
setLoadingCounts(true);
try {
const res = await fetch("/api/eterra/layers/summary", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ siruta }),
});
const data = (await res.json()) as {
counts?: Record<string, { count: number; error?: string }>;
};
if (data.counts) setRemoteCounts(data.counts);
} catch {
/* ignore */
}
setLoadingCounts(false);
}, [siruta]);
/* ================================================================ */
/* Load sync status (local counts + run history) */
/* ================================================================ */
const loadSyncStatus = useCallback(async () => {
if (!siruta || !/^\d+$/.test(siruta)) return;
try {
const res = await fetch(`/api/eterra/sync-status?siruta=${siruta}`);
const data = (await res.json()) as {
localCounts?: Record<string, number>;
runs?: SyncRun[];
};
if (data.localCounts) setLocalCounts(data.localCounts);
if (data.runs) setSyncHistory(data.runs.slice(0, 30));
} catch {
/* ignore */
}
}, [siruta]);
/* ================================================================ */
/* Sync a layer */
/* ================================================================ */
const handleSync = useCallback(
async (layerId: string, forceFullSync = false) => {
if (!siruta || syncingLayer) return;
const jobId = `sync-${Date.now()}-${layerId}`;
setSyncingLayer(layerId);
setSyncProgress({
jobId,
downloaded: 0,
status: "running",
phase: "Pornire...",
});
// Poll progress
progressRef.current = setInterval(async () => {
try {
const res = await fetch(`/api/eterra/progress?jobId=${jobId}`);
const data = (await res.json()) as SyncProgress;
if (data.status === "done" || data.status === "error") {
setSyncProgress(data);
if (progressRef.current) clearInterval(progressRef.current);
progressRef.current = null;
setTimeout(() => {
setSyncingLayer(null);
setSyncProgress(null);
void loadSyncStatus();
}, 2500);
} else if (data.status === "running") {
setSyncProgress(data);
}
} catch {
/* ignore polling errors */
}
}, 1200);
// Fire sync (may take minutes — progress comes via polling)
try {
await fetch("/api/eterra/sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
siruta,
layerId,
uatName,
jobId,
forceFullSync,
}),
});
} catch {
/* error is reported via sync run DB record */
}
// Cleanup polling if still active
if (progressRef.current) {
clearInterval(progressRef.current);
progressRef.current = null;
}
setSyncingLayer(null);
setSyncProgress(null);
void loadSyncStatus();
},
[siruta, syncingLayer, uatName, loadSyncStatus],
);
/* ================================================================ */
/* Load features from local DB */
/* ================================================================ */
const loadFeatures = useCallback(async () => {
if (!siruta || !/^\d+$/.test(siruta)) return;
setLoadingFeatures(true);
try {
const res = await fetch("/api/eterra/features", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
siruta,
layerId: featuresLayerFilter || undefined,
search: featuresSearch || undefined,
page: featuresPage,
pageSize: PAGE_SIZE,
}),
});
const data = (await res.json()) as {
features?: ParcelFeature[];
total?: number;
};
if (data.features) setFeatures(data.features);
if (data.total != null) setFeaturesTotal(data.total);
} catch {
/* ignore */
}
setLoadingFeatures(false);
}, [siruta, featuresLayerFilter, featuresSearch, featuresPage]);
/* ================================================================ */
/* Effects */
/* ================================================================ */
// Load sync status on siruta change
useEffect(() => {
if (siruta && /^\d+$/.test(siruta)) {
void loadSyncStatus();
} else {
setLocalCounts({});
setSyncHistory([]);
}
}, [siruta, loadSyncStatus]);
// Load features on filter/page change (only when on Data tab, but keep it simple)
useEffect(() => {
if (siruta && /^\d+$/.test(siruta)) void loadFeatures();
}, [siruta, featuresLayerFilter, featuresSearch, featuresPage, loadFeatures]);
// Cleanup interval on unmount
useEffect(() => {
return () => {
if (progressRef.current) clearInterval(progressRef.current);
};
}, []);
/* ================================================================ */
/* Derived data */
/* ================================================================ */
const layersByCategory = useMemo(() => {
const grouped: Record<string, LayerCatalogItem[]> = {};
for (const layer of LAYER_CATALOG) {
if (!grouped[layer.category]) grouped[layer.category] = [];
grouped[layer.category]!.push(layer);
}
return grouped;
}, []);
const totalLocal = Object.values(localCounts).reduce((s, c) => s + c, 0);
const totalRemote = Object.values(remoteCounts).reduce(
(s, c) => s + c.count,
0,
);
const totalPages = Math.ceil(featuresTotal / PAGE_SIZE);
const sirutaValid = siruta.length > 0 && /^\d+$/.test(siruta);
/* ================================================================ */
/* Render helpers */
/* ================================================================ */
function renderSyncProgress() {
if (!syncProgress || !syncingLayer) return null;
const layer = LAYER_CATALOG.find((l) => l.id === syncingLayer);
const pct =
syncProgress.total && syncProgress.total > 0
? Math.round((syncProgress.downloaded / syncProgress.total) * 100)
: 0;
return (
<Card className="border-blue-200 bg-blue-50/50 dark:border-blue-800 dark:bg-blue-950/20">
<CardContent className="pt-4">
<div className="flex items-center gap-3 mb-3">
<Loader2 className="h-5 w-5 text-blue-600 animate-spin" />
<div>
<p className="font-medium text-sm">
Sincronizare: {layer?.label ?? syncingLayer}
</p>
<p className="text-xs text-muted-foreground">
{syncProgress.phase}
{syncProgress.total
? `${syncProgress.downloaded} / ${syncProgress.total}`
: ""}
</p>
</div>
</div>
<div className="h-2 w-full rounded-full bg-blue-200/50 dark:bg-blue-800/30">
<div
className="h-2 rounded-full bg-blue-600 transition-all duration-300"
style={{ width: `${Math.max(2, pct)}%` }}
/>
</div>
</CardContent>
</Card>
);
}
function renderLayerRow(layer: LayerCatalogItem) {
const remote = remoteCounts[layer.id];
const local = localCounts[layer.id] ?? 0;
const isSyncing = syncingLayer === layer.id;
const lastRun = syncHistory.find((r) => r.layerId === layer.id);
return (
<div
key={layer.id}
className={cn(
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors",
isSyncing
? "border-blue-300 bg-blue-50/50 dark:border-blue-700 dark:bg-blue-950/20"
: "hover:bg-muted/50",
)}
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{layer.label}</p>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Database className="h-3 w-3" />
{local.toLocaleString("ro-RO")}
</span>
{remote != null && (
<span className="flex items-center gap-1">
<Cloud className="h-3 w-3" />
{remote.error ? (
<span className="text-destructive">eroare</span>
) : (
remote.count.toLocaleString("ro-RO")
)}
</span>
)}
{lastRun && (
<span className="hidden sm:inline">
Ultima: {formatDate(lastRun.completedAt ?? lastRun.startedAt)}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{local > 0 && remote && remote.count > 0 && local >= remote.count && (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
)}
<Button
size="sm"
variant="outline"
disabled={!sirutaValid || !!syncingLayer}
onClick={() => void handleSync(layer.id)}
>
{isSyncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Download className="h-3.5 w-3.5" />
)}
<span className="ml-1.5 hidden sm:inline">Sync</span>
</Button>
</div>
</div>
);
}
/* ================================================================ */
/* JSX */
/* ================================================================ */
return (
<Tabs defaultValue="sync" className="space-y-4">
<TabsList>
<TabsTrigger value="sync" className="gap-1.5">
<Layers className="h-4 w-4" />
Sincronizare
</TabsTrigger>
<TabsTrigger value="data" className="gap-1.5">
<Database className="h-4 w-4" />
Parcele
</TabsTrigger>
<TabsTrigger value="history" className="gap-1.5">
<History className="h-4 w-4" />
Istoric
</TabsTrigger>
</TabsList>
{/* ─── Tab: Sincronizare ─────────────────────────────────── */}
<TabsContent value="sync" className="space-y-4">
{/* Connection + UAT */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<MapPin className="h-4 w-4" />
Conexiune & UAT
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Connection row */}
<div className="flex items-center gap-3 flex-wrap">
<Button
size="sm"
variant={connected ? "outline" : "default"}
disabled={connecting}
onClick={handleConnect}
>
{connecting && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
{connected ? "Reconectare" : "Conectare eTerra"}
</Button>
{connected && (
<Badge className="bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400 hover:bg-emerald-100">
<CheckCircle2 className="mr-1 h-3 w-3" />
Conectat
</Badge>
)}
{connectionError && (
<span className="text-sm text-destructive">
{connectionError}
</span>
)}
</div>
{/* UAT input */}
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1">
<Label htmlFor="siruta" className="text-xs">
Cod SIRUTA
</Label>
<Input
id="siruta"
placeholder="ex: 179141"
value={siruta}
onChange={(e) => {
setSiruta(e.target.value.replace(/\D/g, ""));
setFeaturesPage(1);
}}
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label htmlFor="uatName" className="text-xs">
Nume UAT (opțional)
</Label>
<Input
id="uatName"
placeholder="ex: Cluj-Napoca"
value={uatName}
onChange={(e) => setUatName(e.target.value)}
/>
</div>
<div className="flex items-end gap-2">
<Button
size="sm"
variant="outline"
disabled={!sirutaValid || loadingCounts}
onClick={loadRemoteCounts}
>
{loadingCounts ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Cloud className="mr-1.5 h-3.5 w-3.5" />
)}
Numără remote
</Button>
<Button
size="sm"
variant="outline"
disabled={!sirutaValid}
onClick={loadSyncStatus}
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* Quick stats */}
{sirutaValid && (
<div className="flex gap-4 text-sm text-muted-foreground pt-1">
<span>
<Database className="inline h-3.5 w-3.5 mr-1" />
Local:{" "}
<strong className="text-foreground">
{totalLocal.toLocaleString("ro-RO")}
</strong>{" "}
features
</span>
{totalRemote > 0 && (
<span>
<Cloud className="inline h-3.5 w-3.5 mr-1" />
Remote:{" "}
<strong className="text-foreground">
{totalRemote.toLocaleString("ro-RO")}
</strong>
</span>
)}
<span>
Layere sincronizate:{" "}
<strong className="text-foreground">
{Object.keys(localCounts).length}
</strong>{" "}
/ {LAYER_CATALOG.length}
</span>
</div>
)}
</CardContent>
</Card>
{/* Sync progress banner */}
{renderSyncProgress()}
{/* Layer grid */}
{sirutaValid && (
<div className="space-y-4">
{(Object.keys(LAYER_CATEGORY_LABELS) as LayerCategory[]).map(
(cat) => {
const layers = layersByCategory[cat];
if (!layers || layers.length === 0) return null;
return (
<Card key={cat}>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">
{LAYER_CATEGORY_LABELS[cat]}
<Badge variant="outline" className="ml-2 font-normal">
{layers.length}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{layers.map(renderLayerRow)}
</CardContent>
</Card>
);
},
)}
</div>
)}
</TabsContent>
{/* ─── Tab: Parcele ──────────────────────────────────────── */}
<TabsContent value="data" className="space-y-4">
{!sirutaValid ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Database className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>
Introduceți un cod SIRUTA în tabul Sincronizare pentru a vedea
datele locale.
</p>
</CardContent>
</Card>
) : (
<>
{/* Filters */}
<Card>
<CardContent className="pt-4">
<div className="flex gap-3 flex-wrap items-end">
<div className="space-y-1 flex-1 min-w-[200px]">
<Label className="text-xs">Căutare</Label>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Ref. cadastrală sau INSPIRE ID..."
className="pl-9"
value={featuresSearch}
onChange={(e) => {
setFeaturesSearch(e.target.value);
setFeaturesPage(1);
}}
/>
</div>
</div>
<div className="space-y-1 min-w-[200px]">
<Label className="text-xs">Layer</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={featuresLayerFilter}
onChange={(e) => {
setFeaturesLayerFilter(e.target.value);
setFeaturesPage(1);
}}
>
<option value="">Toate layerele</option>
{LAYER_CATALOG.map((l) => (
<option key={l.id} value={l.id}>
{l.label}
{localCounts[l.id] != null
? ` (${localCounts[l.id]})`
: ""}
</option>
))}
</select>
</div>
<Button
size="sm"
variant="outline"
onClick={loadFeatures}
disabled={loadingFeatures}
>
{loadingFeatures ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
</Button>
</div>
</CardContent>
</Card>
{/* Table */}
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40">
<th className="px-4 py-2.5 text-left font-medium">
OBJECTID
</th>
<th className="px-4 py-2.5 text-left font-medium">
Ref. cadastrală
</th>
<th className="px-4 py-2.5 text-left font-medium hidden md:table-cell">
INSPIRE ID
</th>
<th className="px-4 py-2.5 text-right font-medium hidden sm:table-cell">
Suprafață
</th>
<th className="px-4 py-2.5 text-left font-medium hidden lg:table-cell">
Layer
</th>
<th className="px-4 py-2.5 text-left font-medium hidden lg:table-cell">
Actualizat
</th>
</tr>
</thead>
<tbody>
{features.length === 0 && !loadingFeatures ? (
<tr>
<td
colSpan={6}
className="px-4 py-8 text-center text-muted-foreground"
>
{featuresSearch
? "Nicio parcela găsită pentru căutarea curentă."
: "Nicio parcelă sincronizată. Folosiți tabul Sincronizare."}
</td>
</tr>
) : (
features.map((f) => {
const layerLabel =
LAYER_CATALOG.find((l) => l.id === f.layerId)
?.label ?? f.layerId;
return (
<tr
key={f.id}
className="border-b hover:bg-muted/30 transition-colors cursor-default"
>
<td className="px-4 py-2 font-mono text-xs">
{f.objectId}
</td>
<td className="px-4 py-2">
{f.cadastralRef ?? "—"}
</td>
<td className="px-4 py-2 hidden md:table-cell text-xs text-muted-foreground">
{f.inspireId ?? "—"}
</td>
<td className="px-4 py-2 text-right hidden sm:table-cell tabular-nums">
{formatArea(f.areaValue)}
</td>
<td className="px-4 py-2 hidden lg:table-cell">
<Badge
variant="outline"
className="text-[11px] font-normal"
>
{layerLabel}
</Badge>
</td>
<td className="px-4 py-2 hidden lg:table-cell text-xs text-muted-foreground">
{formatDate(f.updatedAt)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
{featuresTotal > PAGE_SIZE && (
<div className="flex items-center justify-between border-t px-4 py-3">
<span className="text-xs text-muted-foreground">
{featuresTotal.toLocaleString("ro-RO")} total pagina{" "}
{featuresPage} / {totalPages}
</span>
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
disabled={featuresPage <= 1}
onClick={() =>
setFeaturesPage((p) => Math.max(1, p - 1))
}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
disabled={featuresPage >= totalPages}
onClick={() => setFeaturesPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</>
)}
</TabsContent>
{/* ─── Tab: Istoric ──────────────────────────────────────── */}
<TabsContent value="history" className="space-y-4">
{!sirutaValid ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<History className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>
Introduceți un cod SIRUTA pentru a vedea istoricul
sincronizărilor.
</p>
</CardContent>
</Card>
) : syncHistory.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<History className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>Nicio sincronizare efectuată pentru SIRUTA {siruta}.</p>
</CardContent>
</Card>
) : (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">
Istoric sincronizări
<Badge variant="outline" className="ml-2 font-normal">
{syncHistory.length}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40">
<th className="px-4 py-2.5 text-left font-medium">
Layer
</th>
<th className="px-4 py-2.5 text-left font-medium">
Status
</th>
<th className="px-4 py-2.5 text-right font-medium hidden sm:table-cell">
Noi
</th>
<th className="px-4 py-2.5 text-right font-medium hidden sm:table-cell">
Șterse
</th>
<th className="px-4 py-2.5 text-right font-medium hidden md:table-cell">
Total Remote
</th>
<th className="px-4 py-2.5 text-left font-medium">
Data
</th>
</tr>
</thead>
<tbody>
{syncHistory.map((run) => {
const layerLabel =
LAYER_CATALOG.find((l) => l.id === run.layerId)
?.label ?? run.layerId;
return (
<tr key={run.id} className="border-b hover:bg-muted/30">
<td className="px-4 py-2">
<Badge
variant="outline"
className="text-[11px] font-normal"
>
{layerLabel}
</Badge>
</td>
<td className="px-4 py-2">
<StatusBadge status={run.status} />
</td>
<td className="px-4 py-2 text-right tabular-nums hidden sm:table-cell">
{run.newFeatures > 0 ? (
<span className="text-emerald-600">
+{run.newFeatures}
</span>
) : (
"0"
)}
</td>
<td className="px-4 py-2 text-right tabular-nums hidden sm:table-cell">
{run.removedFeatures > 0 ? (
<span className="text-destructive">
-{run.removedFeatures}
</span>
) : (
"0"
)}
</td>
<td className="px-4 py-2 text-right tabular-nums hidden md:table-cell">
{run.totalRemote.toLocaleString("ro-RO")}
</td>
<td className="px-4 py-2 text-xs text-muted-foreground">
{formatDate(run.completedAt ?? run.startedAt)}
{run.errorMessage && (
<p className="text-destructive mt-0.5 truncate max-w-[200px]">
{run.errorMessage}
</p>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
);
}