feat(parcel-sync): full GPKG export workflow with UAT autocomplete, hero buttons, layer catalog

- Fix login button (return success instead of ok)
- Add UAT autocomplete with NFD-normalized search (3186 entries)
- Add export-bundle API: base mode (terenuri+cladiri) + magic mode (enriched parcels)
- Add export-layer-gpkg API: individual layer GPKG download
- Add gpkg-export service: ogr2ogr with GeoJSON fallback
- Add reproject service: EPSG:3844 projection support
- Add magic-mode methods to eterra-client (immApps, folosinte, immovableList, docs, parcelDetails)
- Rewrite UI: 3-tab layout (Export/Catalog/Search), progress tracking, phase trail
This commit is contained in:
AI Assistant
2026-03-06 06:53:49 +02:00
parent 7cdea66fa2
commit 09a24233bb
10 changed files with 15102 additions and 566 deletions
@@ -0,0 +1,285 @@
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers";
import { esriToGeojson } from "@/modules/parcel-sync/services/esri-geojson";
import { getEpsg3844Wkt } from "@/modules/parcel-sync/services/reproject";
import { buildGpkg } from "@/modules/parcel-sync/services/gpkg-export";
import { fetchUatGeometry } from "@/modules/parcel-sync/services/uat-geometry";
import {
clearProgress,
setProgress,
} from "@/modules/parcel-sync/services/progress-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type ExportLayerRequest = {
username?: string;
password?: string;
siruta?: string | number;
layerId?: string;
jobId?: string;
};
const validate = (body: ExportLayerRequest) => {
const username = String(body.username ?? "").trim();
const password = String(body.password ?? "").trim();
const siruta = String(body.siruta ?? "").trim();
const layerId = String(body.layerId ?? "").trim();
const jobId = body.jobId ? String(body.jobId).trim() : undefined;
if (!username) throw new Error("Email is required");
if (!password) throw new Error("Password is required");
if (!/^\d+$/.test(siruta)) throw new Error("SIRUTA must be numeric");
if (!layerId) throw new Error("Layer ID missing");
return { username, password, siruta, layerId, jobId };
};
const scheduleClear = (jobId?: string) => {
if (!jobId) return;
setTimeout(() => clearProgress(jobId), 60_000);
};
export async function POST(req: Request) {
let jobId: string | undefined;
let message: string | undefined;
let phase = "Initializare";
let note: string | undefined;
let status: "running" | "done" | "error" = "running";
let downloaded = 0;
let total: number | undefined;
let completedWeight = 0;
let currentWeight = 0;
let phaseTotal: number | undefined;
let phaseCurrent: number | undefined;
const pushProgress = () => {
if (!jobId) return;
setProgress({
jobId,
downloaded,
total,
status,
phase,
note,
message,
phaseCurrent,
phaseTotal,
});
};
const updateOverall = (fraction = 0) => {
const overall = completedWeight + currentWeight * fraction;
downloaded = Number(Math.min(100, Math.max(0, overall)).toFixed(1));
total = 100;
pushProgress();
};
const setPhaseState = (
next: string,
weight: number,
nextTotal?: number,
) => {
phase = next;
currentWeight = weight;
phaseTotal = nextTotal;
phaseCurrent = nextTotal ? 0 : undefined;
note = undefined;
updateOverall(0);
};
const updatePhaseProgress = (value: number, nextTotal?: number) => {
if (typeof nextTotal === "number") phaseTotal = nextTotal;
if (phaseTotal && phaseTotal > 0) {
phaseCurrent = value;
updateOverall(Math.min(1, value / phaseTotal));
} else {
phaseCurrent = undefined;
updateOverall(0);
}
};
const finishPhase = () => {
completedWeight += currentWeight;
currentWeight = 0;
phaseTotal = undefined;
phaseCurrent = undefined;
note = undefined;
updateOverall(0);
};
const withHeartbeat = async <T,>(task: () => Promise<T>) => {
let tick = 0.1;
updatePhaseProgress(tick, 1);
const interval = setInterval(() => {
tick = Math.min(0.9, tick + 0.05);
updatePhaseProgress(tick, 1);
}, 1200);
try {
return await task();
} finally {
clearInterval(interval);
}
};
try {
const body = (await req.json()) as ExportLayerRequest;
const validated = validate(body);
jobId = validated.jobId;
pushProgress();
const layer = findLayerById(validated.layerId);
if (!layer) throw new Error("Layer not configured");
const weights = {
auth: 5,
count: 5,
download: 70,
gpkg: 15,
finalize: 5,
};
/* Auth */
setPhaseState("Autentificare", weights.auth, 1);
const client = await EterraClient.create(
validated.username,
validated.password,
{ timeoutMs: 120_000 },
);
updatePhaseProgress(1, 1);
finishPhase();
/* Count */
let geometry;
setPhaseState("Numarare", weights.count, 2);
let count: number | undefined;
try {
if (layer.spatialFilter) {
geometry = await fetchUatGeometry(client, validated.siruta);
count = await client.countLayerByGeometry(layer, geometry);
} else {
count = await client.countLayer(layer, validated.siruta);
}
} catch (error) {
const msg =
error instanceof Error ? error.message : "Count error";
if (!msg.toLowerCase().includes("count unavailable")) throw error;
}
updatePhaseProgress(2, 2);
finishPhase();
if (layer.spatialFilter && !geometry) {
geometry = await fetchUatGeometry(client, validated.siruta);
}
const pageSize =
typeof count === "number"
? Math.min(1000, Math.max(200, Math.ceil(count / 8)))
: 500;
/* Download */
setPhaseState("Descarcare", weights.download, count);
const features = layer.spatialFilter
? await client.fetchAllLayerByGeometry(layer, geometry!, {
total: count,
pageSize,
delayMs: 250,
onProgress: (value, totalCount) => {
updatePhaseProgress(
value,
typeof totalCount === "number"
? totalCount
: value + pageSize,
);
},
})
: await client.fetchAllLayer(layer, validated.siruta, {
total: count,
pageSize,
delayMs: 250,
onProgress: (value, totalCount) => {
updatePhaseProgress(
value,
typeof totalCount === "number"
? totalCount
: value + pageSize,
);
},
});
updatePhaseProgress(
features.length,
count ?? features.length,
);
finishPhase();
/* Fields */
let fields: string[] = [];
try {
fields = await client.getLayerFieldNames(layer);
} catch (error) {
const msg = error instanceof Error ? error.message : "";
if (!msg.toLowerCase().includes("returned no fields")) throw error;
}
if (!fields.length) {
fields = Object.keys(features[0]?.attributes ?? {});
}
/* GPKG */
setPhaseState("GPKG", weights.gpkg, 1);
const geo = esriToGeojson(features);
const gpkg = await withHeartbeat(() =>
buildGpkg({
srsId: 3844,
srsWkt: getEpsg3844Wkt(),
layers: [
{
name: layer.name,
fields,
features: geo.features,
},
],
}),
);
updatePhaseProgress(1, 1);
finishPhase();
/* Finalize */
setPhaseState("Finalizare", weights.finalize, 1);
updatePhaseProgress(1, 1);
finishPhase();
status = "done";
phase = "Finalizat";
message =
typeof count === "number"
? `Finalizat 100% · ${features.length}/${count} elemente`
: `Finalizat 100% · ${features.length} elemente`;
pushProgress();
scheduleClear(jobId);
const filename = `eterra_uat_${validated.siruta}_${layer.name}.gpkg`;
return new Response(new Uint8Array(gpkg), {
headers: {
"Content-Type": "application/geopackage+sqlite3",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
} catch (error) {
const errMessage =
error instanceof Error ? error.message : "Unexpected server error";
status = "error";
message = errMessage;
note = undefined;
pushProgress();
scheduleClear(jobId);
const lower = errMessage.toLowerCase();
const statusCode =
lower.includes("login failed") || lower.includes("session")
? 401
: 400;
return new Response(JSON.stringify({ error: errMessage }), {
status: statusCode,
headers: { "Content-Type": "application/json" },
});
}
}