feat(parcel-sync): sync-to-DB + local export + layer catalog enhancements
Layer catalog now has 3 actions per layer: - Sync: downloads from eTerra, stores in PostgreSQL (GisFeature table), incremental — only new OBJECTIDs fetched, removed ones deleted - GPKG: direct download from eTerra (existing behavior) - Local export: generates GPKG from local DB (no eTerra needed) New features: - /api/eterra/export-local endpoint — builds GPKG from DB, ZIP for multi-layer - /api/eterra/sync now uses session-based auth (no credentials in request) - Category headers show both remote + local feature counts - Each layer shows local DB count (violet badge) + last sync timestamp - 'Export local' button in action bar when any layer has local data - Sync progress message with auto-dismiss DB schema already had GisFeature + GisSyncRun tables from prior work.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* POST /api/eterra/export-local
|
||||
*
|
||||
* Export features from local PostgreSQL database as GPKG.
|
||||
* No eTerra connection needed — serves from previously synced data.
|
||||
*
|
||||
* Body: { siruta, layerIds?: string[], allLayers?: boolean }
|
||||
*/
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { buildGpkg } from "@/modules/parcel-sync/services/gpkg-export";
|
||||
import { getEpsg3844Wkt } from "@/modules/parcel-sync/services/reproject";
|
||||
import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers";
|
||||
import type { GeoJsonFeature } from "@/modules/parcel-sync/services/esri-geojson";
|
||||
import JSZip from "jszip";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Body = {
|
||||
siruta?: string;
|
||||
layerIds?: string[];
|
||||
allLayers?: boolean;
|
||||
};
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = (await req.json()) as Body;
|
||||
const siruta = String(body.siruta ?? "").trim();
|
||||
|
||||
if (!siruta || !/^\d+$/.test(siruta)) {
|
||||
return new Response(JSON.stringify({ error: "SIRUTA invalid" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Determine which layers to export
|
||||
let layerIds: string[];
|
||||
if (body.layerIds?.length) {
|
||||
layerIds = body.layerIds;
|
||||
} else if (body.allLayers) {
|
||||
// Find all layers that have data for this siruta
|
||||
const layerGroups = await prisma.gisFeature.groupBy({
|
||||
by: ["layerId"],
|
||||
where: { siruta },
|
||||
_count: { id: true },
|
||||
});
|
||||
layerIds = layerGroups
|
||||
.filter((g) => g._count.id > 0)
|
||||
.map((g) => g.layerId);
|
||||
} else {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Specifică layerIds sau allLayers=true" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
if (layerIds.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Niciun layer sincronizat în baza de date pentru acest UAT",
|
||||
}),
|
||||
{ status: 404, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// If single layer, return GPKG directly. If multiple, ZIP them.
|
||||
if (layerIds.length === 1) {
|
||||
const layerId = layerIds[0]!;
|
||||
const gpkg = await buildLayerGpkg(siruta, layerId);
|
||||
const layer = findLayerById(layerId);
|
||||
const filename = `eterra_local_${siruta}_${layer?.name ?? layerId}.gpkg`;
|
||||
return new Response(new Uint8Array(gpkg), {
|
||||
headers: {
|
||||
"Content-Type": "application/geopackage+sqlite3",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Multiple layers — ZIP
|
||||
const zip = new JSZip();
|
||||
for (const layerId of layerIds) {
|
||||
const gpkg = await buildLayerGpkg(siruta, layerId);
|
||||
const layer = findLayerById(layerId);
|
||||
const name = layer?.name ?? layerId;
|
||||
zip.file(`${name}.gpkg`, gpkg);
|
||||
}
|
||||
|
||||
const zipBuffer = await zip.generateAsync({ type: "uint8array" });
|
||||
const filename = `eterra_local_${siruta}_${layerIds.length}layers.zip`;
|
||||
return new Response(Buffer.from(zipBuffer), {
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Eroare server";
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a GPKG from local DB features for one layer+siruta */
|
||||
async function buildLayerGpkg(siruta: string, layerId: string) {
|
||||
const features = await prisma.gisFeature.findMany({
|
||||
where: { layerId, siruta },
|
||||
select: { attributes: true, geometry: true },
|
||||
});
|
||||
|
||||
if (features.length === 0) {
|
||||
throw new Error(`Niciun feature local pentru ${layerId} / ${siruta}`);
|
||||
}
|
||||
|
||||
// Reconstruct GeoJSON features from DB records
|
||||
const geoFeatures: GeoJsonFeature[] = features
|
||||
.filter((f) => f.geometry != null)
|
||||
.map((f) => ({
|
||||
type: "Feature" as const,
|
||||
geometry: f.geometry as GeoJsonFeature["geometry"],
|
||||
properties: f.attributes as Record<string, unknown>,
|
||||
}));
|
||||
|
||||
// Collect field names from first feature
|
||||
const fields = Object.keys(geoFeatures[0]?.properties ?? {});
|
||||
|
||||
const layer = findLayerById(layerId);
|
||||
const name = layer?.name ?? layerId;
|
||||
|
||||
return buildGpkg({
|
||||
srsId: 3844,
|
||||
srsWkt: getEpsg3844Wkt(),
|
||||
layers: [{ name, fields, features: geoFeatures }],
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
|
||||
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -18,15 +19,12 @@ type Body = {
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = (await req.json()) as Body;
|
||||
const username = (
|
||||
body.username ??
|
||||
process.env.ETERRA_USERNAME ??
|
||||
""
|
||||
const session = getSessionCredentials();
|
||||
const username = String(
|
||||
body.username || session?.username || process.env.ETERRA_USERNAME || "",
|
||||
).trim();
|
||||
const password = (
|
||||
body.password ??
|
||||
process.env.ETERRA_PASSWORD ??
|
||||
""
|
||||
const password = String(
|
||||
body.password || session?.password || process.env.ETERRA_PASSWORD || "",
|
||||
).trim();
|
||||
const siruta = String(body.siruta ?? "").trim();
|
||||
const layerId = String(body.layerId ?? "TERENURI_ACTIVE").trim();
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
ClipboardCopy,
|
||||
Trash2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Database,
|
||||
HardDrive,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
@@ -305,9 +308,36 @@ export function ParcelSyncModule() {
|
||||
const [countingLayers, setCountingLayers] = useState(false);
|
||||
const [layerCountSiruta, setLayerCountSiruta] = useState(""); // siruta for which counts were fetched
|
||||
const [layerHistory, setLayerHistory] = useState<
|
||||
{ layerId: string; label: string; count: number; time: string; siruta: string }[]
|
||||
{
|
||||
layerId: string;
|
||||
label: string;
|
||||
count: number;
|
||||
time: string;
|
||||
siruta: string;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
/* ── Sync status ────────────────────────────────────────────── */
|
||||
type SyncRunInfo = {
|
||||
id: string;
|
||||
layerId: string;
|
||||
status: string;
|
||||
totalRemote: number;
|
||||
totalLocal: number;
|
||||
newFeatures: number;
|
||||
removedFeatures: number;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
};
|
||||
const [syncLocalCounts, setSyncLocalCounts] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
const [syncRuns, setSyncRuns] = useState<SyncRunInfo[]>([]);
|
||||
const [syncingSiruta, setSyncingSiruta] = useState("");
|
||||
const [syncingLayer, setSyncingLayer] = useState<string | null>(null);
|
||||
const [syncProgress, setSyncProgress] = useState("");
|
||||
const [exportingLocal, setExportingLocal] = useState(false);
|
||||
|
||||
/* ── Parcel search tab ──────────────────────────────────────── */
|
||||
const [searchResults, setSearchResults] = useState<ParcelDetail[]>([]);
|
||||
const [searchList, setSearchList] = useState<ParcelDetail[]>([]);
|
||||
@@ -640,6 +670,119 @@ export function ParcelSyncModule() {
|
||||
setCountingLayers(false);
|
||||
}, [siruta, countingLayers]);
|
||||
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
/* Sync status — load local feature counts for current UAT */
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
|
||||
const fetchSyncStatus = useCallback(async () => {
|
||||
if (!siruta) return;
|
||||
try {
|
||||
const res = await fetch(`/api/eterra/sync-status?siruta=${siruta}`);
|
||||
const data = (await res.json()) as {
|
||||
localCounts?: Record<string, number>;
|
||||
runs?: SyncRunInfo[];
|
||||
};
|
||||
if (data.localCounts) setSyncLocalCounts(data.localCounts);
|
||||
if (data.runs) setSyncRuns(data.runs);
|
||||
setSyncingSiruta(siruta);
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}, [siruta]);
|
||||
|
||||
// Auto-fetch sync status when siruta changes
|
||||
useEffect(() => {
|
||||
if (siruta && /^\d+$/.test(siruta)) {
|
||||
void fetchSyncStatus();
|
||||
}
|
||||
}, [siruta, fetchSyncStatus]);
|
||||
|
||||
const handleSyncLayer = useCallback(
|
||||
async (layerId: string) => {
|
||||
if (!siruta || syncingLayer) return;
|
||||
setSyncingLayer(layerId);
|
||||
setSyncProgress("Sincronizare pornită…");
|
||||
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 {
|
||||
status?: string;
|
||||
newFeatures?: number;
|
||||
removedFeatures?: number;
|
||||
totalLocal?: number;
|
||||
error?: string;
|
||||
};
|
||||
if (data.error) {
|
||||
setSyncProgress(`Eroare: ${data.error}`);
|
||||
} else {
|
||||
setSyncProgress(
|
||||
`Finalizat — ${data.newFeatures ?? 0} noi, ${data.removedFeatures ?? 0} șterse, ${data.totalLocal ?? 0} total local`,
|
||||
);
|
||||
// Refresh sync status
|
||||
await fetchSyncStatus();
|
||||
}
|
||||
} catch {
|
||||
setSyncProgress("Eroare rețea");
|
||||
}
|
||||
// Clear progress after 8s
|
||||
setTimeout(() => {
|
||||
setSyncingLayer(null);
|
||||
setSyncProgress("");
|
||||
}, 8_000);
|
||||
},
|
||||
[siruta, syncingLayer, fetchSyncStatus],
|
||||
);
|
||||
|
||||
const handleExportLocal = useCallback(
|
||||
async (layerIds?: string[]) => {
|
||||
if (!siruta || exportingLocal) return;
|
||||
setExportingLocal(true);
|
||||
try {
|
||||
const res = await fetch("/api/eterra/export-local", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
siruta,
|
||||
...(layerIds ? { layerIds } : { allLayers: true }),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
};
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const cd = res.headers.get("Content-Disposition") ?? "";
|
||||
const match = /filename="?([^"]+)"?/.exec(cd);
|
||||
const filename =
|
||||
match?.[1] ??
|
||||
`eterra_local_${siruta}.${layerIds?.length === 1 ? "gpkg" : "zip"}`;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare export";
|
||||
setSyncProgress(msg);
|
||||
setTimeout(() => setSyncProgress(""), 5_000);
|
||||
}
|
||||
setExportingLocal(false);
|
||||
},
|
||||
[siruta, exportingLocal],
|
||||
);
|
||||
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
/* Export individual layer */
|
||||
/* ════════════════════════════════════════════════════════════ */
|
||||
@@ -1379,28 +1522,61 @@ export function ParcelSyncModule() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Count all button */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Action bar */}
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{layerCountSiruta === siruta && Object.keys(layerCounts).length > 0
|
||||
{layerCountSiruta === siruta &&
|
||||
Object.keys(layerCounts).length > 0
|
||||
? `Număr features pentru SIRUTA ${siruta}`
|
||||
: "Apasă pentru a număra features-urile din fiecare layer."}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={countingLayers}
|
||||
onClick={() => void fetchLayerCounts()}
|
||||
>
|
||||
{countingLayers ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
|
||||
) : (
|
||||
<Search className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
{countingLayers ? "Se numără…" : "Numără toate"}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Export from local DB */}
|
||||
{syncingSiruta === siruta &&
|
||||
Object.values(syncLocalCounts).some((c) => c > 0) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={exportingLocal}
|
||||
onClick={() => void handleExportLocal()}
|
||||
className="border-violet-300 text-violet-700 dark:border-violet-700 dark:text-violet-300"
|
||||
>
|
||||
{exportingLocal ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
|
||||
) : (
|
||||
<HardDrive className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
Export local
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={countingLayers}
|
||||
onClick={() => void fetchLayerCounts()}
|
||||
>
|
||||
{countingLayers ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
|
||||
) : (
|
||||
<Search className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
{countingLayers ? "Se numără…" : "Numără"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync progress message */}
|
||||
{syncProgress && (
|
||||
<div className="flex items-center gap-2 rounded-lg border px-3 py-2 text-xs">
|
||||
{syncingLayer ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-blue-500 shrink-0" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
|
||||
)}
|
||||
<span>{syncProgress}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(Object.keys(LAYER_CATEGORY_LABELS) as LayerCategory[]).map(
|
||||
(cat) => {
|
||||
const layers = layersByCategory[cat];
|
||||
@@ -1416,6 +1592,15 @@ export function ParcelSyncModule() {
|
||||
)
|
||||
: null;
|
||||
|
||||
// Sum local counts for category
|
||||
const catLocal =
|
||||
syncingSiruta === siruta
|
||||
? layers.reduce(
|
||||
(sum, l) => sum + (syncLocalCounts[l.id] ?? 0),
|
||||
0,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card key={cat}>
|
||||
<button
|
||||
@@ -1428,7 +1613,7 @@ export function ParcelSyncModule() {
|
||||
}))
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold">
|
||||
{LAYER_CATEGORY_LABELS[cat]}
|
||||
</span>
|
||||
@@ -1440,7 +1625,12 @@ export function ParcelSyncModule() {
|
||||
</Badge>
|
||||
{catTotal != null && catTotal > 0 && (
|
||||
<Badge className="font-mono text-[10px] bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 border-0">
|
||||
{catTotal.toLocaleString("ro-RO")} feat.
|
||||
{catTotal.toLocaleString("ro-RO")} remote
|
||||
</Badge>
|
||||
)}
|
||||
{catLocal != null && catLocal > 0 && (
|
||||
<Badge className="font-mono text-[10px] bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300 border-0">
|
||||
{catLocal.toLocaleString("ro-RO")} local
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -1455,63 +1645,143 @@ export function ParcelSyncModule() {
|
||||
<CardContent className="pt-0 pb-3 space-y-1.5">
|
||||
{layers.map((layer) => {
|
||||
const isDownloading = downloadingLayer === layer.id;
|
||||
const isSyncing = syncingLayer === layer.id;
|
||||
const lc =
|
||||
layerCountSiruta === siruta
|
||||
? layerCounts[layer.id]
|
||||
: undefined;
|
||||
const localCount =
|
||||
syncingSiruta === siruta
|
||||
? (syncLocalCounts[layer.id] ?? 0)
|
||||
: 0;
|
||||
|
||||
// Find last sync run for this layer
|
||||
const lastRun = syncRuns.find(
|
||||
(r) =>
|
||||
r.layerId === layer.id && r.status === "done",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-3 rounded-lg border px-3 py-2.5 transition-colors",
|
||||
isDownloading
|
||||
"rounded-lg border px-3 py-2.5 transition-colors",
|
||||
isDownloading || 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">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{layer.label}
|
||||
</p>
|
||||
{lc != null && !lc.error && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"font-mono text-[10px] shrink-0",
|
||||
lc.count === 0
|
||||
? "opacity-40"
|
||||
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
|
||||
)}
|
||||
>
|
||||
{lc.count.toLocaleString("ro-RO")}
|
||||
</Badge>
|
||||
)}
|
||||
{lc?.error && (
|
||||
<span className="text-[10px] text-rose-500">
|
||||
eroare
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{layer.label}
|
||||
</p>
|
||||
{lc != null && !lc.error && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"font-mono text-[10px] shrink-0",
|
||||
lc.count === 0
|
||||
? "opacity-40"
|
||||
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
|
||||
)}
|
||||
>
|
||||
{lc.count.toLocaleString("ro-RO")}
|
||||
</Badge>
|
||||
)}
|
||||
{lc?.error && (
|
||||
<span className="text-[10px] text-rose-500">
|
||||
eroare
|
||||
</span>
|
||||
)}
|
||||
{localCount > 0 && (
|
||||
<Badge className="font-mono text-[10px] bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300 border-0 shrink-0">
|
||||
<Database className="h-2.5 w-2.5 mr-0.5" />
|
||||
{localCount.toLocaleString("ro-RO")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[11px] text-muted-foreground font-mono">
|
||||
{layer.id}
|
||||
</p>
|
||||
{lastRun && (
|
||||
<span className="text-[10px] text-muted-foreground/70">
|
||||
sync{" "}
|
||||
{new Date(
|
||||
lastRun.completedAt ??
|
||||
lastRun.startedAt,
|
||||
).toLocaleDateString("ro-RO", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{/* Sync to DB */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
!!syncingLayer ||
|
||||
!!downloadingLayer ||
|
||||
exporting
|
||||
}
|
||||
onClick={() =>
|
||||
void handleSyncLayer(layer.id)
|
||||
}
|
||||
className="border-violet-200 dark:border-violet-800"
|
||||
title="Sincronizează în baza de date"
|
||||
>
|
||||
{isSyncing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span className="ml-1.5 hidden sm:inline">
|
||||
Sync
|
||||
</span>
|
||||
</Button>
|
||||
{/* Direct GPKG from eTerra */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!!downloadingLayer || exporting}
|
||||
onClick={() =>
|
||||
void handleExportLayer(layer.id)
|
||||
}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<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">
|
||||
GPKG
|
||||
</span>
|
||||
</Button>
|
||||
{/* Export from local DB */}
|
||||
{localCount > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={exportingLocal}
|
||||
onClick={() =>
|
||||
void handleExportLocal([layer.id])
|
||||
}
|
||||
title="Exportă din baza de date locală"
|
||||
className="text-violet-600 dark:text-violet-400"
|
||||
>
|
||||
<HardDrive className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground font-mono">
|
||||
{layer.id}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!!downloadingLayer || exporting}
|
||||
onClick={() => void handleExportLayer(layer.id)}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<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">
|
||||
GPKG
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -1529,7 +1799,10 @@ export function ParcelSyncModule() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-amber-500" />
|
||||
<span className="text-sm font-semibold">Drumul de azi</span>
|
||||
<Badge variant="outline" className="font-normal text-[11px]">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="font-normal text-[11px]"
|
||||
>
|
||||
{layerHistory.length}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -1549,7 +1822,11 @@ export function ParcelSyncModule() {
|
||||
<p className="text-[11px] font-semibold text-muted-foreground">
|
||||
SIRUTA {sir}{" "}
|
||||
<span className="font-normal opacity-70">
|
||||
— {new Date(entries[0]!.time).toLocaleTimeString("ro-RO", { hour: "2-digit", minute: "2-digit" })}
|
||||
—{" "}
|
||||
{new Date(entries[0]!.time).toLocaleTimeString(
|
||||
"ro-RO",
|
||||
{ hour: "2-digit", minute: "2-digit" },
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-1">
|
||||
|
||||
Reference in New Issue
Block a user