feat(parcel-sync): DXF export in ZIP + detailed tooltips on hero buttons
DXF Export: - Add gpkgToDxf() helper using ogr2ogr -f DXF (non-fatal fallback) - export-local: terenuri.dxf, cladiri.dxf, terenuri_magic.dxf in ZIP - export-bundle: same DXF files alongside GPKGs - Zero overhead — conversion runs locally from DB data, no eTerra calls Hero Button Tooltips: - Hover shows ZIP contents: layer names, entity counts, sync dates - Base tooltip: "GPKG + DXF per layer" - Magic tooltip: "GPKG + DXF + CSV complet + Raport calitate" Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -562,6 +562,19 @@ export async function POST(req: Request) {
|
||||
zip.file("terenuri.gpkg", terenuriGpkg);
|
||||
zip.file("cladiri.gpkg", cladiriGpkg);
|
||||
|
||||
// DXF versions (non-fatal)
|
||||
try {
|
||||
const { gpkgToDxf } = await import(
|
||||
"@/modules/parcel-sync/services/gpkg-export"
|
||||
);
|
||||
const tDxf = await gpkgToDxf(terenuriGpkg, "TERENURI_ACTIVE");
|
||||
if (tDxf) zip.file("terenuri.dxf", tDxf);
|
||||
const cDxf = await gpkgToDxf(cladiriGpkg, "CLADIRI_ACTIVE");
|
||||
if (cDxf) zip.file("cladiri.dxf", cDxf);
|
||||
} catch {
|
||||
// DXF conversion not available — skip silently
|
||||
}
|
||||
|
||||
// ── Comprehensive quality analysis ──
|
||||
const withGeomRecords = dbTerenuri.filter(
|
||||
(r) =>
|
||||
@@ -685,6 +698,15 @@ export async function POST(req: Request) {
|
||||
|
||||
if (validated.mode === "magic" && magicGpkg && csvContent) {
|
||||
zip.file("terenuri_magic.gpkg", magicGpkg);
|
||||
try {
|
||||
const { gpkgToDxf } = await import(
|
||||
"@/modules/parcel-sync/services/gpkg-export"
|
||||
);
|
||||
const mDxf = await gpkgToDxf(magicGpkg, "TERENURI_MAGIC");
|
||||
if (mDxf) zip.file("terenuri_magic.dxf", mDxf);
|
||||
} catch {
|
||||
// DXF conversion not available
|
||||
}
|
||||
zip.file("terenuri_complet.csv", csvContent);
|
||||
report.magic = {
|
||||
csvRows: csvContent.split("\n").length - 1,
|
||||
|
||||
@@ -182,6 +182,15 @@ async function buildFullZip(siruta: string, mode: "base" | "magic") {
|
||||
zip.file("terenuri.gpkg", terenuriGpkg);
|
||||
zip.file("cladiri.gpkg", cladiriGpkg);
|
||||
|
||||
// DXF versions (non-fatal — ogr2ogr may not be available)
|
||||
const { gpkgToDxf } = await import(
|
||||
"@/modules/parcel-sync/services/gpkg-export"
|
||||
);
|
||||
const terenuriDxf = await gpkgToDxf(terenuriGpkg, "TERENURI_ACTIVE");
|
||||
if (terenuriDxf) zip.file("terenuri.dxf", terenuriDxf);
|
||||
const cladiriDxf = await gpkgToDxf(cladiriGpkg, "CLADIRI_ACTIVE");
|
||||
if (cladiriDxf) zip.file("cladiri.dxf", cladiriDxf);
|
||||
|
||||
if (mode === "magic") {
|
||||
// ── Magic: enrichment-merged GPKG + CSV + quality report ──
|
||||
const headers = [
|
||||
@@ -295,6 +304,8 @@ async function buildFullZip(siruta: string, mode: "base" | "magic") {
|
||||
});
|
||||
|
||||
zip.file("terenuri_magic.gpkg", magicGpkg);
|
||||
const magicDxf = await gpkgToDxf(magicGpkg, "TERENURI_MAGIC");
|
||||
if (magicDxf) zip.file("terenuri_magic.dxf", magicDxf);
|
||||
zip.file("terenuri_complet.csv", csvRows.join("\n"));
|
||||
|
||||
// ── Quality analysis ──
|
||||
|
||||
@@ -680,65 +680,97 @@ export function ExportTab({
|
||||
{/* Hero buttons */}
|
||||
{sirutaValid && (session.connected || canExportLocal) ? (
|
||||
<div className="space-y-2">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-auto py-4 text-base bg-zinc-900 hover:bg-zinc-800 text-white dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
disabled={exporting || downloadingFromDb}
|
||||
onClick={() =>
|
||||
canExportLocal
|
||||
? void handleDownloadFromDb("base")
|
||||
: void handleExportBundle("base")
|
||||
}
|
||||
>
|
||||
{(exporting || downloadingFromDb) &&
|
||||
exportProgress?.phase !== "Detalii parcele" ? (
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
) : canExportLocal ? (
|
||||
<Database className="mr-2 h-5 w-5" />
|
||||
) : (
|
||||
<FileDown className="mr-2 h-5 w-5" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">
|
||||
Descarcă Terenuri și Clădiri
|
||||
</div>
|
||||
<div className="text-xs opacity-70 font-normal">
|
||||
{canExportLocal
|
||||
? `Din DB (sync ${oldestSyncDate ? relativeTime(oldestSyncDate) : "recent"})`
|
||||
: hasData
|
||||
? "Sync incremental + GPKG"
|
||||
: "Sync complet + GPKG"}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
{(() => {
|
||||
// Build tooltip with layer details for hero buttons
|
||||
const layerLines = dbLayersSummary
|
||||
.filter((l) => l.count > 0)
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.map(
|
||||
(l) =>
|
||||
`${l.label}: ${l.count.toLocaleString("ro")} entitati${l.lastSynced ? ` (sync ${relativeTime(l.lastSynced)})` : ""}`,
|
||||
);
|
||||
const enriched = dbLayersSummary.reduce(
|
||||
(sum, l) => {
|
||||
const enrichCount =
|
||||
syncRuns.find(
|
||||
(r) => r.layerId === l.id && r.status === "done",
|
||||
)?.totalLocal ?? 0;
|
||||
return sum + enrichCount;
|
||||
},
|
||||
0,
|
||||
);
|
||||
const baseTooltip = layerLines.length > 0
|
||||
? `ZIP contine:\n• ${layerLines.join("\n• ")}\n\nFormate: GPKG + DXF per layer`
|
||||
: "Nicio data in DB";
|
||||
const magicTooltip = layerLines.length > 0
|
||||
? `ZIP contine:\n• ${layerLines.join("\n• ")}\n\nFormate: GPKG + DXF + CSV complet\n+ Raport calitate enrichment`
|
||||
: "Nicio data in DB";
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-auto py-4 text-base bg-teal-700 hover:bg-teal-600 text-white dark:bg-teal-600 dark:hover:bg-teal-500"
|
||||
disabled={exporting || downloadingFromDb}
|
||||
onClick={() =>
|
||||
canExportLocal
|
||||
? void handleDownloadFromDb("magic")
|
||||
: void handleExportBundle("magic")
|
||||
}
|
||||
>
|
||||
{(exporting || downloadingFromDb) &&
|
||||
exportProgress?.phase === "Detalii parcele" ? (
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-5 w-5" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Magic</div>
|
||||
<div className="text-xs opacity-70 font-normal">
|
||||
{canExportLocal
|
||||
? `GPKG + CSV din DB (sync ${oldestSyncDate ? relativeTime(oldestSyncDate) : "recent"})`
|
||||
: "Sync + îmbogățire (CF, proprietari, adresă) + GPKG + CSV"}
|
||||
</div>
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-auto py-4 text-base bg-zinc-900 hover:bg-zinc-800 text-white dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
disabled={exporting || downloadingFromDb}
|
||||
title={baseTooltip}
|
||||
onClick={() =>
|
||||
canExportLocal
|
||||
? void handleDownloadFromDb("base")
|
||||
: void handleExportBundle("base")
|
||||
}
|
||||
>
|
||||
{(exporting || downloadingFromDb) &&
|
||||
exportProgress?.phase !== "Detalii parcele" ? (
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
) : canExportLocal ? (
|
||||
<Database className="mr-2 h-5 w-5" />
|
||||
) : (
|
||||
<FileDown className="mr-2 h-5 w-5" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">
|
||||
Descarcă Terenuri și Clădiri
|
||||
</div>
|
||||
<div className="text-xs opacity-70 font-normal">
|
||||
{canExportLocal
|
||||
? `Din DB (sync ${oldestSyncDate ? relativeTime(oldestSyncDate) : "recent"})`
|
||||
: hasData
|
||||
? "Sync incremental + GPKG + DXF"
|
||||
: "Sync complet + GPKG + DXF"}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-auto py-4 text-base bg-teal-700 hover:bg-teal-600 text-white dark:bg-teal-600 dark:hover:bg-teal-500"
|
||||
disabled={exporting || downloadingFromDb}
|
||||
title={magicTooltip}
|
||||
onClick={() =>
|
||||
canExportLocal
|
||||
? void handleDownloadFromDb("magic")
|
||||
: void handleExportBundle("magic")
|
||||
}
|
||||
>
|
||||
{(exporting || downloadingFromDb) &&
|
||||
exportProgress?.phase === "Detalii parcele" ? (
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-5 w-5" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Magic</div>
|
||||
<div className="text-xs opacity-70 font-normal">
|
||||
{canExportLocal
|
||||
? `GPKG + DXF + CSV din DB (sync ${oldestSyncDate ? relativeTime(oldestSyncDate) : "recent"})`
|
||||
: "Sync + îmbogățire + GPKG + DXF + CSV"}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{canExportLocal && session.connected && (
|
||||
<div className="text-center">
|
||||
<button
|
||||
|
||||
@@ -175,3 +175,33 @@ export const buildGpkg = async (options: GpkgBuildOptions): Promise<Buffer> => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
return buffer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a GPKG buffer to DXF using ogr2ogr.
|
||||
* Returns null if ogr2ogr is not available or conversion fails.
|
||||
*/
|
||||
export const gpkgToDxf = async (
|
||||
gpkgBuffer: Buffer,
|
||||
layerName: string,
|
||||
): Promise<Buffer | null> => {
|
||||
if (!hasOgr2Ogr()) return null;
|
||||
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "eterra-dxf-"));
|
||||
const gpkgPath = path.join(tmpDir, "input.gpkg");
|
||||
const dxfPath = path.join(tmpDir, `${layerName}.dxf`);
|
||||
|
||||
try {
|
||||
await fs.writeFile(gpkgPath, gpkgBuffer);
|
||||
await runOgr(
|
||||
["-f", "DXF", dxfPath, gpkgPath, layerName],
|
||||
{ ...process.env, OGR_CT_FORCE_TRADITIONAL_GIS_ORDER: "YES" },
|
||||
);
|
||||
const buffer = Buffer.from(await fs.readFile(dxfPath));
|
||||
return buffer;
|
||||
} catch {
|
||||
// DXF conversion failed — not critical
|
||||
return null;
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user