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