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:
AI Assistant
2026-03-26 22:24:35 +02:00
parent bde25d8d84
commit 24b565f5ea
4 changed files with 152 additions and 57 deletions
+22
View File
@@ -562,6 +562,19 @@ export async function POST(req: Request) {
zip.file("terenuri.gpkg", terenuriGpkg); zip.file("terenuri.gpkg", terenuriGpkg);
zip.file("cladiri.gpkg", cladiriGpkg); 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 ── // ── Comprehensive quality analysis ──
const withGeomRecords = dbTerenuri.filter( const withGeomRecords = dbTerenuri.filter(
(r) => (r) =>
@@ -685,6 +698,15 @@ export async function POST(req: Request) {
if (validated.mode === "magic" && magicGpkg && csvContent) { if (validated.mode === "magic" && magicGpkg && csvContent) {
zip.file("terenuri_magic.gpkg", magicGpkg); 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); zip.file("terenuri_complet.csv", csvContent);
report.magic = { report.magic = {
csvRows: csvContent.split("\n").length - 1, csvRows: csvContent.split("\n").length - 1,
+11
View File
@@ -182,6 +182,15 @@ async function buildFullZip(siruta: string, mode: "base" | "magic") {
zip.file("terenuri.gpkg", terenuriGpkg); zip.file("terenuri.gpkg", terenuriGpkg);
zip.file("cladiri.gpkg", cladiriGpkg); 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") { if (mode === "magic") {
// ── Magic: enrichment-merged GPKG + CSV + quality report ── // ── Magic: enrichment-merged GPKG + CSV + quality report ──
const headers = [ const headers = [
@@ -295,6 +304,8 @@ async function buildFullZip(siruta: string, mode: "base" | "magic") {
}); });
zip.file("terenuri_magic.gpkg", magicGpkg); 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")); zip.file("terenuri_complet.csv", csvRows.join("\n"));
// ── Quality analysis ── // ── Quality analysis ──
@@ -680,65 +680,97 @@ export function ExportTab({
{/* Hero buttons */} {/* Hero buttons */}
{sirutaValid && (session.connected || canExportLocal) ? ( {sirutaValid && (session.connected || canExportLocal) ? (
<div className="space-y-2"> <div className="space-y-2">
<div className="grid gap-3 sm:grid-cols-2"> {(() => {
<Button // Build tooltip with layer details for hero buttons
size="lg" const layerLines = dbLayersSummary
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" .filter((l) => l.count > 0)
disabled={exporting || downloadingFromDb} .sort((a, b) => b.count - a.count)
onClick={() => .map(
canExportLocal (l) =>
? void handleDownloadFromDb("base") `${l.label}: ${l.count.toLocaleString("ro")} entitati${l.lastSynced ? ` (sync ${relativeTime(l.lastSynced)})` : ""}`,
: void handleExportBundle("base") );
} const enriched = dbLayersSummary.reduce(
> (sum, l) => {
{(exporting || downloadingFromDb) && const enrichCount =
exportProgress?.phase !== "Detalii parcele" ? ( syncRuns.find(
<Loader2 className="mr-2 h-5 w-5 animate-spin" /> (r) => r.layerId === l.id && r.status === "done",
) : canExportLocal ? ( )?.totalLocal ?? 0;
<Database className="mr-2 h-5 w-5" /> return sum + enrichCount;
) : ( },
<FileDown className="mr-2 h-5 w-5" /> 0,
)} );
<div className="text-left"> const baseTooltip = layerLines.length > 0
<div className="font-semibold"> ? `ZIP contine:\n• ${layerLines.join("\n• ")}\n\nFormate: GPKG + DXF per layer`
Descarcă Terenuri și Clădiri : "Nicio data in DB";
</div> const magicTooltip = layerLines.length > 0
<div className="text-xs opacity-70 font-normal"> ? `ZIP contine:\n• ${layerLines.join("\n• ")}\n\nFormate: GPKG + DXF + CSV complet\n+ Raport calitate enrichment`
{canExportLocal : "Nicio data in DB";
? `Din DB (sync ${oldestSyncDate ? relativeTime(oldestSyncDate) : "recent"})`
: hasData
? "Sync incremental + GPKG"
: "Sync complet + GPKG"}
</div>
</div>
</Button>
<Button return (
size="lg" <div className="grid gap-3 sm:grid-cols-2">
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" <Button
disabled={exporting || downloadingFromDb} size="lg"
onClick={() => 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"
canExportLocal disabled={exporting || downloadingFromDb}
? void handleDownloadFromDb("magic") title={baseTooltip}
: void handleExportBundle("magic") onClick={() =>
} canExportLocal
> ? void handleDownloadFromDb("base")
{(exporting || downloadingFromDb) && : void handleExportBundle("base")
exportProgress?.phase === "Detalii parcele" ? ( }
<Loader2 className="mr-2 h-5 w-5 animate-spin" /> >
) : ( {(exporting || downloadingFromDb) &&
<Sparkles className="mr-2 h-5 w-5" /> exportProgress?.phase !== "Detalii parcele" ? (
)} <Loader2 className="mr-2 h-5 w-5 animate-spin" />
<div className="text-left"> ) : canExportLocal ? (
<div className="font-semibold">Magic</div> <Database className="mr-2 h-5 w-5" />
<div className="text-xs opacity-70 font-normal"> ) : (
{canExportLocal <FileDown className="mr-2 h-5 w-5" />
? `GPKG + CSV din DB (sync ${oldestSyncDate ? relativeTime(oldestSyncDate) : "recent"})` )}
: "Sync + îmbogățire (CF, proprietari, adresă) + GPKG + CSV"} <div className="text-left">
</div> <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> </div>
</Button> );
</div> })()}
{canExportLocal && session.connected && ( {canExportLocal && session.connected && (
<div className="text-center"> <div className="text-center">
<button <button
@@ -175,3 +175,33 @@ export const buildGpkg = async (options: GpkgBuildOptions): Promise<Buffer> => {
await fs.rm(tmpDir, { recursive: true, force: true }); await fs.rm(tmpDir, { recursive: true, force: true });
return buffer; 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 });
}
};