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 { NextResponse } from "next/server";
|
||||||
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
|
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
|
||||||
|
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -18,15 +19,12 @@ type Body = {
|
|||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const body = (await req.json()) as Body;
|
const body = (await req.json()) as Body;
|
||||||
const username = (
|
const session = getSessionCredentials();
|
||||||
body.username ??
|
const username = String(
|
||||||
process.env.ETERRA_USERNAME ??
|
body.username || session?.username || process.env.ETERRA_USERNAME || "",
|
||||||
""
|
|
||||||
).trim();
|
).trim();
|
||||||
const password = (
|
const password = String(
|
||||||
body.password ??
|
body.password || session?.password || process.env.ETERRA_PASSWORD || "",
|
||||||
process.env.ETERRA_PASSWORD ??
|
|
||||||
""
|
|
||||||
).trim();
|
).trim();
|
||||||
const siruta = String(body.siruta ?? "").trim();
|
const siruta = String(body.siruta ?? "").trim();
|
||||||
const layerId = String(body.layerId ?? "TERENURI_ACTIVE").trim();
|
const layerId = String(body.layerId ?? "TERENURI_ACTIVE").trim();
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import {
|
|||||||
ClipboardCopy,
|
ClipboardCopy,
|
||||||
Trash2,
|
Trash2,
|
||||||
Plus,
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Database,
|
||||||
|
HardDrive,
|
||||||
} 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";
|
||||||
@@ -305,9 +308,36 @@ export function ParcelSyncModule() {
|
|||||||
const [countingLayers, setCountingLayers] = useState(false);
|
const [countingLayers, setCountingLayers] = useState(false);
|
||||||
const [layerCountSiruta, setLayerCountSiruta] = useState(""); // siruta for which counts were fetched
|
const [layerCountSiruta, setLayerCountSiruta] = useState(""); // siruta for which counts were fetched
|
||||||
const [layerHistory, setLayerHistory] = useState<
|
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 ──────────────────────────────────────── */
|
/* ── Parcel search tab ──────────────────────────────────────── */
|
||||||
const [searchResults, setSearchResults] = useState<ParcelDetail[]>([]);
|
const [searchResults, setSearchResults] = useState<ParcelDetail[]>([]);
|
||||||
const [searchList, setSearchList] = useState<ParcelDetail[]>([]);
|
const [searchList, setSearchList] = useState<ParcelDetail[]>([]);
|
||||||
@@ -640,6 +670,119 @@ export function ParcelSyncModule() {
|
|||||||
setCountingLayers(false);
|
setCountingLayers(false);
|
||||||
}, [siruta, countingLayers]);
|
}, [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 */
|
/* Export individual layer */
|
||||||
/* ════════════════════════════════════════════════════════════ */
|
/* ════════════════════════════════════════════════════════════ */
|
||||||
@@ -1379,28 +1522,61 @@ export function ParcelSyncModule() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Count all button */}
|
{/* Action bar */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
<p className="text-xs text-muted-foreground">
|
<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}`
|
? `Număr features pentru SIRUTA ${siruta}`
|
||||||
: "Apasă pentru a număra features-urile din fiecare layer."}
|
: "Apasă pentru a număra features-urile din fiecare layer."}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
size="sm"
|
{/* Export from local DB */}
|
||||||
variant="outline"
|
{syncingSiruta === siruta &&
|
||||||
disabled={countingLayers}
|
Object.values(syncLocalCounts).some((c) => c > 0) && (
|
||||||
onClick={() => void fetchLayerCounts()}
|
<Button
|
||||||
>
|
size="sm"
|
||||||
{countingLayers ? (
|
variant="outline"
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
|
disabled={exportingLocal}
|
||||||
) : (
|
onClick={() => void handleExportLocal()}
|
||||||
<Search className="h-3.5 w-3.5 mr-1.5" />
|
className="border-violet-300 text-violet-700 dark:border-violet-700 dark:text-violet-300"
|
||||||
)}
|
>
|
||||||
{countingLayers ? "Se numără…" : "Numără toate"}
|
{exportingLocal ? (
|
||||||
</Button>
|
<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>
|
</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(
|
{(Object.keys(LAYER_CATEGORY_LABELS) as LayerCategory[]).map(
|
||||||
(cat) => {
|
(cat) => {
|
||||||
const layers = layersByCategory[cat];
|
const layers = layersByCategory[cat];
|
||||||
@@ -1416,6 +1592,15 @@ export function ParcelSyncModule() {
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Sum local counts for category
|
||||||
|
const catLocal =
|
||||||
|
syncingSiruta === siruta
|
||||||
|
? layers.reduce(
|
||||||
|
(sum, l) => sum + (syncLocalCounts[l.id] ?? 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={cat}>
|
<Card key={cat}>
|
||||||
<button
|
<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">
|
<span className="text-sm font-semibold">
|
||||||
{LAYER_CATEGORY_LABELS[cat]}
|
{LAYER_CATEGORY_LABELS[cat]}
|
||||||
</span>
|
</span>
|
||||||
@@ -1440,7 +1625,12 @@ export function ParcelSyncModule() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
{catTotal != null && catTotal > 0 && (
|
{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">
|
<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>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1455,63 +1645,143 @@ export function ParcelSyncModule() {
|
|||||||
<CardContent className="pt-0 pb-3 space-y-1.5">
|
<CardContent className="pt-0 pb-3 space-y-1.5">
|
||||||
{layers.map((layer) => {
|
{layers.map((layer) => {
|
||||||
const isDownloading = downloadingLayer === layer.id;
|
const isDownloading = downloadingLayer === layer.id;
|
||||||
|
const isSyncing = syncingLayer === layer.id;
|
||||||
const lc =
|
const lc =
|
||||||
layerCountSiruta === siruta
|
layerCountSiruta === siruta
|
||||||
? layerCounts[layer.id]
|
? layerCounts[layer.id]
|
||||||
: undefined;
|
: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={layer.id}
|
key={layer.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center justify-between gap-3 rounded-lg border px-3 py-2.5 transition-colors",
|
"rounded-lg border px-3 py-2.5 transition-colors",
|
||||||
isDownloading
|
isDownloading || isSyncing
|
||||||
? "border-blue-300 bg-blue-50/50 dark:border-blue-700 dark:bg-blue-950/20"
|
? "border-blue-300 bg-blue-50/50 dark:border-blue-700 dark:bg-blue-950/20"
|
||||||
: "hover:bg-muted/50",
|
: "hover:bg-muted/50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium truncate">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{layer.label}
|
<p className="text-sm font-medium truncate">
|
||||||
</p>
|
{layer.label}
|
||||||
{lc != null && !lc.error && (
|
</p>
|
||||||
<Badge
|
{lc != null && !lc.error && (
|
||||||
variant="secondary"
|
<Badge
|
||||||
className={cn(
|
variant="secondary"
|
||||||
"font-mono text-[10px] shrink-0",
|
className={cn(
|
||||||
lc.count === 0
|
"font-mono text-[10px] shrink-0",
|
||||||
? "opacity-40"
|
lc.count === 0
|
||||||
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
|
? "opacity-40"
|
||||||
)}
|
: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
|
||||||
>
|
)}
|
||||||
{lc.count.toLocaleString("ro-RO")}
|
>
|
||||||
</Badge>
|
{lc.count.toLocaleString("ro-RO")}
|
||||||
)}
|
</Badge>
|
||||||
{lc?.error && (
|
)}
|
||||||
<span className="text-[10px] text-rose-500">
|
{lc?.error && (
|
||||||
eroare
|
<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>
|
</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>
|
</div>
|
||||||
<p className="text-[11px] text-muted-foreground font-mono">
|
|
||||||
{layer.id}
|
|
||||||
</p>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1529,7 +1799,10 @@ export function ParcelSyncModule() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="h-4 w-4 text-amber-500" />
|
<Sparkles className="h-4 w-4 text-amber-500" />
|
||||||
<span className="text-sm font-semibold">Drumul de azi</span>
|
<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}
|
{layerHistory.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -1549,7 +1822,11 @@ export function ParcelSyncModule() {
|
|||||||
<p className="text-[11px] font-semibold text-muted-foreground">
|
<p className="text-[11px] font-semibold text-muted-foreground">
|
||||||
SIRUTA {sir}{" "}
|
SIRUTA {sir}{" "}
|
||||||
<span className="font-normal opacity-70">
|
<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>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-1">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user