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
@@ -405,6 +405,64 @@ export class EterraClient {
return this.getLayerFields(layer);
}
/* ---- Magic-mode methods (eTerra application APIs) ------------- */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async fetchImmAppsByImmovable(immovableId: string | number, workspaceId: string | number): Promise<any[]> {
const url = `${BASE_URL}/api/immApps/byImm/list/${immovableId}/${workspaceId}`;
return this.getRawJson(url);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async fetchParcelFolosinte(workspaceId: string | number, immovableId: string | number, applicationId: string | number, page = 1): Promise<any[]> {
const url = `${BASE_URL}/api/immApps/parcels/list/${workspaceId}/${immovableId}/${applicationId}/${page}`;
return this.getRawJson(url);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async fetchImmovableListByAdminUnit(workspaceId: string | number, adminUnitId: string | number, page = 0, size = 200, includeInscrisCF = true): Promise<any> {
const url = `${BASE_URL}/api/immovable/list`;
const filters: Array<{ value: string | number; type: "NUMBER" | "STRING"; key: string; op: string }> = [
{ value: Number(workspaceId), type: "NUMBER", key: "workspace.nomenPk", op: "=" },
{ value: Number(adminUnitId), type: "NUMBER", key: "adminUnit.nomenPk", op: "=" },
{ value: "C", type: "STRING", key: "immovableType", op: "<>C" },
];
if (includeInscrisCF) {
filters.push({ value: -1, type: "NUMBER", key: "inscrisCF", op: "=" });
}
const payload = { filters, nrElements: size, page, sorters: [] };
return this.requestRaw(() =>
this.client.post(url, payload, {
headers: { "Content-Type": "application/json;charset=UTF-8" },
timeout: this.timeoutMs,
}),
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async fetchDocumentationData(workspaceId: string | number, immovableIds: Array<string | number>): Promise<any> {
const url = `${BASE_URL}/api/documentation/data/`;
const payload = {
workflowCode: "EXPLORE_DATABASE",
activityCode: "EXPLORE",
applicationId: 0,
workspaceId: Number(workspaceId),
immovables: immovableIds.map((id) => Number(id)).filter(Number.isFinite),
};
return this.requestRaw(() =>
this.client.post(url, payload, {
headers: { "Content-Type": "application/json;charset=UTF-8" },
timeout: this.timeoutMs,
}),
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async fetchImmovableParcelDetails(workspaceId: string | number, immovableId: string | number, page = 1, size = 1): Promise<any[]> {
const url = `${BASE_URL}/api/immovable/details/parcels/list/${workspaceId}/${immovableId}/${page}/${size}`;
return this.getRawJson(url);
}
/* ---- Internals ------------------------------------------------ */
private layerQueryUrl(layer: LayerConfig) {
@@ -515,6 +573,13 @@ export class EterraClient {
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private async getRawJson<T = any>(url: string): Promise<T> {
return this.requestRaw(() =>
this.client.get(url, { timeout: this.timeoutMs }),
);
}
private async postJson(
url: string,
body: URLSearchParams,
@@ -558,6 +623,34 @@ export class EterraClient {
return data;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private async requestRaw<T = any>(
request: () => Promise<{ data: T | string; status: number }>,
): Promise<T> {
let response;
try {
response = await this.requestWithRetry(request);
} catch (error) {
const err = error as AxiosError;
if (err?.response?.status === 401 && !this.reloginAttempted) {
this.reloginAttempted = true;
await this.login(this.username, this.password);
response = await this.requestWithRetry(request);
} else if (err?.response?.status === 401) {
throw new Error("Session expired (401)");
} else throw error;
}
const data = response.data as T | string;
if (typeof data === "string") {
try {
return JSON.parse(data) as T;
} catch {
throw new Error("Session expired or invalid response from eTerra");
}
}
return data;
}
private async queryLayer(
layer: LayerConfig,
params: URLSearchParams,