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:
@@ -97,7 +97,7 @@ legacy/ # Original HTML tools for reference
|
|||||||
## Implemented Modules (14/14 — zero placeholders)
|
## Implemented Modules (14/14 — zero placeholders)
|
||||||
|
|
||||||
| # | Module | Route | Version | Key Features |
|
| # | Module | Route | Version | Key Features |
|
||||||
| --- | ---------------------- | --------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --- | ---------------------- | --------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 1 | **Dashboard** | `/` | 0.1.0 | KPI cards (6), activity feed (last 20), module grid, external tools |
|
| 1 | **Dashboard** | `/` | 0.1.0 | KPI cards (6), activity feed (last 20), module grid, external tools |
|
||||||
| 2 | **Email Signature** | `/email-signature` | 0.1.0 | Multi-company branding, address toggle (BTG/US/SDT), live preview, zoom/copy/download |
|
| 2 | **Email Signature** | `/email-signature` | 0.1.0 | Multi-company branding, address toggle (BTG/US/SDT), live preview, zoom/copy/download |
|
||||||
| 3 | **Word XML Generator** | `/word-xml` | 0.1.0 | Category-based XML gen, simple/advanced mode, ZIP export |
|
| 3 | **Word XML Generator** | `/word-xml` | 0.1.0 | Category-based XML gen, simple/advanced mode, ZIP export |
|
||||||
|
|||||||
+12746
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,881 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import JSZip from "jszip";
|
||||||
|
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 {
|
||||||
|
clearProgress,
|
||||||
|
setProgress,
|
||||||
|
} from "@/modules/parcel-sync/services/progress-store";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type ExportBundleRequest = {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
siruta?: string | number;
|
||||||
|
jobId?: string;
|
||||||
|
mode?: "base" | "magic";
|
||||||
|
};
|
||||||
|
|
||||||
|
const validate = (body: ExportBundleRequest) => {
|
||||||
|
const username = String(body.username ?? "").trim();
|
||||||
|
const password = String(body.password ?? "").trim();
|
||||||
|
const siruta = String(body.siruta ?? "").trim();
|
||||||
|
const jobId = body.jobId ? String(body.jobId).trim() : undefined;
|
||||||
|
const mode = body.mode === "magic" ? "magic" : "base";
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
return { username, password, siruta, jobId, mode };
|
||||||
|
};
|
||||||
|
|
||||||
|
const sleep = (ms: number) =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const scheduleClear = (jobId?: string) => {
|
||||||
|
if (!jobId) return;
|
||||||
|
setTimeout(() => clearProgress(jobId), 60_000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRetryable = (error: unknown) => {
|
||||||
|
const err = error as { response?: { status?: number }; code?: string };
|
||||||
|
const status = err?.response?.status ?? 0;
|
||||||
|
if ([429, 500, 502, 503, 504].includes(status)) return true;
|
||||||
|
return err?.code === "ECONNRESET" || err?.code === "ETIMEDOUT";
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (value: number) =>
|
||||||
|
Number.isFinite(value) ? value.toFixed(2).replace(/\.00$/, "") : "";
|
||||||
|
|
||||||
|
const normalizeId = (value: unknown) => {
|
||||||
|
if (value === null || value === undefined) return "";
|
||||||
|
const text = String(value).trim();
|
||||||
|
if (!text) return "";
|
||||||
|
return text.replace(/\.0$/, "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeCadRef = (value: unknown) =>
|
||||||
|
normalizeId(value).replace(/\s+/g, "").toUpperCase();
|
||||||
|
|
||||||
|
const baseCadRef = (value: unknown) => {
|
||||||
|
const ref = normalizeCadRef(value);
|
||||||
|
if (!ref) return "";
|
||||||
|
return ref.includes("-") ? ref.split("-")[0]! : ref;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeWorkspaceKey = (workspaceId: unknown, immovableId: unknown) => {
|
||||||
|
const ws = normalizeId(workspaceId);
|
||||||
|
const im = normalizeId(immovableId);
|
||||||
|
if (!ws || !im) return "";
|
||||||
|
return `${ws}:${im}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
const clipped = Math.min(100, Math.max(0, overall));
|
||||||
|
downloaded = Number(clipped.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 ExportBundleRequest;
|
||||||
|
const validated = validate(body);
|
||||||
|
jobId = validated.jobId;
|
||||||
|
pushProgress();
|
||||||
|
|
||||||
|
const terenuriLayer = findLayerById("TERENURI_ACTIVE");
|
||||||
|
const cladiriLayer = findLayerById("CLADIRI_ACTIVE");
|
||||||
|
if (!terenuriLayer || !cladiriLayer)
|
||||||
|
throw new Error("Missing layer configuration");
|
||||||
|
|
||||||
|
const weights =
|
||||||
|
validated.mode === "magic"
|
||||||
|
? {
|
||||||
|
auth: 3,
|
||||||
|
count: 2,
|
||||||
|
terenuri: 23,
|
||||||
|
cladiri: 13,
|
||||||
|
detalii: 34,
|
||||||
|
gpkgT: 8,
|
||||||
|
gpkgC: 7,
|
||||||
|
gpkgM: 5,
|
||||||
|
zip: 5,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
auth: 3,
|
||||||
|
count: 2,
|
||||||
|
terenuri: 40,
|
||||||
|
cladiri: 22,
|
||||||
|
detalii: 0,
|
||||||
|
gpkgT: 10,
|
||||||
|
gpkgC: 10,
|
||||||
|
gpkgM: 0,
|
||||||
|
zip: 13,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Auth */
|
||||||
|
setPhaseState("Autentificare", weights.auth, 1);
|
||||||
|
const client = await EterraClient.create(
|
||||||
|
validated.username,
|
||||||
|
validated.password,
|
||||||
|
{ timeoutMs: 120_000 },
|
||||||
|
);
|
||||||
|
updatePhaseProgress(1, 1);
|
||||||
|
finishPhase();
|
||||||
|
|
||||||
|
/* Count */
|
||||||
|
const safeCount = async (layerId: "terenuri" | "cladiri") => {
|
||||||
|
try {
|
||||||
|
return await client.countLayer(
|
||||||
|
layerId === "terenuri" ? terenuriLayer : cladiriLayer,
|
||||||
|
validated.siruta,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const msg =
|
||||||
|
error instanceof Error ? error.message : "Unexpected server error";
|
||||||
|
if (msg.toLowerCase().includes("count unavailable")) return undefined;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setPhaseState("Numarare", weights.count, 2);
|
||||||
|
const terenuriCount = await safeCount("terenuri");
|
||||||
|
updatePhaseProgress(1, 2);
|
||||||
|
const cladiriCount = await safeCount("cladiri");
|
||||||
|
updatePhaseProgress(2, 2);
|
||||||
|
finishPhase();
|
||||||
|
|
||||||
|
const calcPageSize = (cnt?: number) => {
|
||||||
|
if (!cnt || cnt <= 0) return 1000;
|
||||||
|
if (cnt <= 200) return Math.max(50, Math.ceil(cnt / 2));
|
||||||
|
return Math.min(1000, Math.max(200, Math.ceil(cnt / 8)));
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Download terenuri */
|
||||||
|
setPhaseState("Descarcare terenuri", weights.terenuri, terenuriCount);
|
||||||
|
const terenuriFeatures = await client.fetchAllLayer(
|
||||||
|
terenuriLayer,
|
||||||
|
validated.siruta,
|
||||||
|
{
|
||||||
|
total: terenuriCount,
|
||||||
|
pageSize: calcPageSize(terenuriCount),
|
||||||
|
delayMs: 250,
|
||||||
|
onProgress: (count, totalCount) =>
|
||||||
|
updatePhaseProgress(count, totalCount),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
finishPhase();
|
||||||
|
|
||||||
|
/* Download cladiri */
|
||||||
|
setPhaseState("Descarcare cladiri", weights.cladiri, cladiriCount);
|
||||||
|
const cladiriFeatures = await client.fetchAllLayer(
|
||||||
|
cladiriLayer,
|
||||||
|
validated.siruta,
|
||||||
|
{
|
||||||
|
total: cladiriCount,
|
||||||
|
pageSize: calcPageSize(cladiriCount),
|
||||||
|
delayMs: 250,
|
||||||
|
onProgress: (count, totalCount) =>
|
||||||
|
updatePhaseProgress(count, totalCount),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
finishPhase();
|
||||||
|
|
||||||
|
const terenuriGeo = esriToGeojson(terenuriFeatures);
|
||||||
|
const cladiriGeo = esriToGeojson(cladiriFeatures);
|
||||||
|
const srsWkt = getEpsg3844Wkt();
|
||||||
|
const terenuriFields = await client.getLayerFieldNames(terenuriLayer);
|
||||||
|
const cladiriFields = await client.getLayerFieldNames(cladiriLayer);
|
||||||
|
|
||||||
|
let terenuriGpkg: Buffer | null = null;
|
||||||
|
let cladiriGpkg: Buffer | null = null;
|
||||||
|
let magicGpkg: Buffer | null = null;
|
||||||
|
let csvContent: string | null = null;
|
||||||
|
let hasBuildingCount = 0;
|
||||||
|
let legalBuildingCount = 0;
|
||||||
|
|
||||||
|
if (validated.mode === "magic") {
|
||||||
|
/* ── Magic mode: enrich parcels ─────────────────────────── */
|
||||||
|
setPhaseState("Detalii parcele", weights.detalii, terenuriCount);
|
||||||
|
const immAppsCache = new Map<string, any[]>();
|
||||||
|
const folCache = new Map<string, any[]>();
|
||||||
|
|
||||||
|
let lastRequest = 0;
|
||||||
|
const minInterval = 250;
|
||||||
|
const throttled = async <T,>(fn: () => Promise<T>) => {
|
||||||
|
let attempt = 0;
|
||||||
|
while (true) {
|
||||||
|
const now = Date.now();
|
||||||
|
const wait = Math.max(0, lastRequest + minInterval - now);
|
||||||
|
if (wait > 0) {
|
||||||
|
note = `Throttling ${Math.ceil(wait)}ms`;
|
||||||
|
pushProgress();
|
||||||
|
await sleep(wait);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await fn();
|
||||||
|
lastRequest = Date.now();
|
||||||
|
note = undefined;
|
||||||
|
pushProgress();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isRetryable(error) || attempt >= 2) throw error;
|
||||||
|
attempt += 1;
|
||||||
|
const backoff = Math.min(5000, 1000 * attempt);
|
||||||
|
note = `Backoff ${backoff}ms (retry ${attempt})`;
|
||||||
|
pushProgress();
|
||||||
|
await sleep(backoff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickApplication = (entries: any[], applicationId?: number) => {
|
||||||
|
if (!entries.length) return null;
|
||||||
|
if (applicationId) {
|
||||||
|
const match = entries.find(
|
||||||
|
(entry: any) => entry?.applicationId === applicationId,
|
||||||
|
);
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
entries
|
||||||
|
.filter((entry: any) => entry?.dataCerere)
|
||||||
|
.sort(
|
||||||
|
(a: any, b: any) => (b.dataCerere ?? 0) - (a.dataCerere ?? 0),
|
||||||
|
)[0] ?? entries[0]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeIntravilan = (values: string[]) => {
|
||||||
|
const normalized = values
|
||||||
|
.map((v) => String(v ?? "").trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
const unique = new Set(normalized);
|
||||||
|
if (!unique.size) return "-";
|
||||||
|
if (unique.size === 1)
|
||||||
|
return unique.has("da") ? "Da" : unique.has("nu") ? "Nu" : "Mixt";
|
||||||
|
return "Mixt";
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCategories = (entries: any[]) => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const key = String(entry?.categorieFolosinta ?? "").trim();
|
||||||
|
if (!key) continue;
|
||||||
|
const area = Number(entry?.suprafata ?? 0);
|
||||||
|
map.set(
|
||||||
|
key,
|
||||||
|
(map.get(key) ?? 0) + (Number.isFinite(area) ? area : 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries())
|
||||||
|
.map(([k, a]) => `${k}:${formatNumber(a)}`)
|
||||||
|
.join("; ");
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAddress = (item?: any) => {
|
||||||
|
const address = item?.immovableAddresses?.[0]?.address ?? null;
|
||||||
|
if (!address) return "-";
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (address.addressDescription)
|
||||||
|
parts.push(address.addressDescription);
|
||||||
|
if (address.street) parts.push(`Str. ${address.street}`);
|
||||||
|
if (address.buildingNo) parts.push(`Nr. ${address.buildingNo}`);
|
||||||
|
if (address.locality?.name) parts.push(address.locality.name);
|
||||||
|
return parts.length ? parts.join(", ") : "-";
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Building cross-ref map */
|
||||||
|
const buildingMap = new Map<
|
||||||
|
string,
|
||||||
|
{ has: boolean; legal: boolean }
|
||||||
|
>();
|
||||||
|
for (const feature of cladiriFeatures) {
|
||||||
|
const attrs = feature.attributes ?? {};
|
||||||
|
const immovableId =
|
||||||
|
attrs.IMMOVABLE_ID ?? attrs.IMOVABLE_ID ?? null;
|
||||||
|
const workspaceId = attrs.WORKSPACE_ID ?? null;
|
||||||
|
const baseRef = baseCadRef(
|
||||||
|
attrs.NATIONAL_CADASTRAL_REFERENCE ?? "",
|
||||||
|
);
|
||||||
|
const isLegal =
|
||||||
|
Number(attrs.IS_LEGAL ?? 0) === 1 ||
|
||||||
|
String(attrs.IS_LEGAL ?? "").toLowerCase() === "true";
|
||||||
|
const add = (key: string) => {
|
||||||
|
if (!key) return;
|
||||||
|
const existing = buildingMap.get(key) ?? {
|
||||||
|
has: false,
|
||||||
|
legal: false,
|
||||||
|
};
|
||||||
|
existing.has = true;
|
||||||
|
if (isLegal) existing.legal = true;
|
||||||
|
buildingMap.set(key, existing);
|
||||||
|
};
|
||||||
|
const immKey = normalizeId(immovableId);
|
||||||
|
const wKey = makeWorkspaceKey(workspaceId, immovableId);
|
||||||
|
if (immKey) add(immKey);
|
||||||
|
if (wKey) add(wKey);
|
||||||
|
if (baseRef) add(baseRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fetch immovable list */
|
||||||
|
const immovableListById = new Map<string, any>();
|
||||||
|
const immovableListByCad = new Map<string, any>();
|
||||||
|
const docByImmovable = new Map<string, any>();
|
||||||
|
const ownersByLandbook = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
const addOwner = (landbook: string, name: string) => {
|
||||||
|
if (!landbook || !name) return;
|
||||||
|
const existing =
|
||||||
|
ownersByLandbook.get(landbook) ?? new Set<string>();
|
||||||
|
existing.add(name);
|
||||||
|
ownersByLandbook.set(landbook, existing);
|
||||||
|
};
|
||||||
|
|
||||||
|
let listPage = 0;
|
||||||
|
let listTotalPages = 1;
|
||||||
|
let includeInscrisCF = true;
|
||||||
|
while (listPage < listTotalPages) {
|
||||||
|
const listResponse = await throttled(() =>
|
||||||
|
client.fetchImmovableListByAdminUnit(
|
||||||
|
65,
|
||||||
|
validated.siruta,
|
||||||
|
listPage,
|
||||||
|
200,
|
||||||
|
includeInscrisCF,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
listPage === 0 &&
|
||||||
|
!(listResponse?.content ?? []).length &&
|
||||||
|
includeInscrisCF
|
||||||
|
) {
|
||||||
|
includeInscrisCF = false;
|
||||||
|
listPage = 0;
|
||||||
|
listTotalPages = 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
listTotalPages =
|
||||||
|
typeof listResponse?.totalPages === "number"
|
||||||
|
? listResponse.totalPages
|
||||||
|
: listTotalPages;
|
||||||
|
(listResponse?.content ?? []).forEach((item: any) => {
|
||||||
|
const idKey = normalizeId(item?.immovablePk);
|
||||||
|
if (idKey) immovableListById.set(idKey, item);
|
||||||
|
const cadKey = normalizeCadRef(
|
||||||
|
item?.identifierDetails ?? "",
|
||||||
|
);
|
||||||
|
if (cadKey) immovableListByCad.set(cadKey, item);
|
||||||
|
});
|
||||||
|
listPage += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fetch documentation data */
|
||||||
|
const immovableIds = Array.from(immovableListById.keys());
|
||||||
|
const docBatchSize = 50;
|
||||||
|
for (let i = 0; i < immovableIds.length; i += docBatchSize) {
|
||||||
|
const batch = immovableIds.slice(i, i + docBatchSize);
|
||||||
|
const docResponse = await throttled(() =>
|
||||||
|
client.fetchDocumentationData(65, batch),
|
||||||
|
);
|
||||||
|
(docResponse?.immovables ?? []).forEach((item: any) => {
|
||||||
|
const idKey = normalizeId(item?.immovablePk);
|
||||||
|
if (idKey) docByImmovable.set(idKey, item);
|
||||||
|
});
|
||||||
|
(docResponse?.partTwoRegs ?? []).forEach((item: any) => {
|
||||||
|
if (
|
||||||
|
String(item?.nodeType ?? "").toUpperCase() === "P" &&
|
||||||
|
item?.landbookIE
|
||||||
|
) {
|
||||||
|
const name = String(item?.nodeName ?? "").trim();
|
||||||
|
if (name) addOwner(String(item.landbookIE), name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Build CSV + detail map */
|
||||||
|
const csvRows: string[] = [];
|
||||||
|
const headers = [
|
||||||
|
"OBJECTID",
|
||||||
|
"IMMOVABLE_ID",
|
||||||
|
"APPLICATION_ID",
|
||||||
|
"NATIONAL_CADASTRAL_REFERENCE",
|
||||||
|
"NR_CAD",
|
||||||
|
"AREA_VALUE",
|
||||||
|
"NR_CF",
|
||||||
|
"NR_CF_VECHI",
|
||||||
|
"NR_TOPO",
|
||||||
|
"ADRESA",
|
||||||
|
"PROPRIETARI",
|
||||||
|
"SUPRAFATA_2D",
|
||||||
|
"SUPRAFATA_R",
|
||||||
|
"SOLICITANT",
|
||||||
|
"INTRAVILAN",
|
||||||
|
"CATEGORIE_FOLOSINTA",
|
||||||
|
"HAS_BUILDING",
|
||||||
|
"BUILD_LEGAL",
|
||||||
|
];
|
||||||
|
csvRows.push(headers.join(","));
|
||||||
|
|
||||||
|
const detailsByObjectId = new Map<
|
||||||
|
string,
|
||||||
|
Record<string, unknown>
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (let index = 0; index < terenuriFeatures.length; index += 1) {
|
||||||
|
const feature = terenuriFeatures[index]!;
|
||||||
|
const attrs = feature.attributes ?? {};
|
||||||
|
const objectId = attrs.OBJECTID ?? "";
|
||||||
|
const immovableId = attrs.IMMOVABLE_ID ?? "";
|
||||||
|
const workspaceId = attrs.WORKSPACE_ID ?? "";
|
||||||
|
const applicationId = (attrs.APPLICATION_ID as number) ?? null;
|
||||||
|
|
||||||
|
let solicitant = "-";
|
||||||
|
let intravilan = "-";
|
||||||
|
let categorie = "-";
|
||||||
|
let proprietari = "-";
|
||||||
|
let nrCF = "-";
|
||||||
|
let nrCFVechi = "-";
|
||||||
|
let nrTopo = "-";
|
||||||
|
let addressText = "-";
|
||||||
|
|
||||||
|
if (immovableId && workspaceId) {
|
||||||
|
const appKey = `${workspaceId}:${immovableId}`;
|
||||||
|
let apps = immAppsCache.get(appKey);
|
||||||
|
if (!apps) {
|
||||||
|
apps = await throttled(() =>
|
||||||
|
client.fetchImmAppsByImmovable(
|
||||||
|
immovableId as string | number,
|
||||||
|
workspaceId as string | number,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
immAppsCache.set(appKey, apps);
|
||||||
|
}
|
||||||
|
const chosen = pickApplication(
|
||||||
|
apps,
|
||||||
|
Number(applicationId ?? 0),
|
||||||
|
);
|
||||||
|
const appId =
|
||||||
|
chosen?.applicationId ??
|
||||||
|
(applicationId ? Number(applicationId) : null);
|
||||||
|
solicitant =
|
||||||
|
chosen?.solicitant ?? chosen?.deponent ?? solicitant;
|
||||||
|
|
||||||
|
if (appId) {
|
||||||
|
const folKey = `${workspaceId}:${immovableId}:${appId}`;
|
||||||
|
let fol = folCache.get(folKey);
|
||||||
|
if (!fol) {
|
||||||
|
fol = await throttled(() =>
|
||||||
|
client.fetchParcelFolosinte(
|
||||||
|
workspaceId as string | number,
|
||||||
|
immovableId as string | number,
|
||||||
|
appId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
folCache.set(folKey, fol);
|
||||||
|
}
|
||||||
|
intravilan = normalizeIntravilan(
|
||||||
|
fol.map((item: any) => item?.intravilan ?? ""),
|
||||||
|
);
|
||||||
|
categorie = formatCategories(fol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cadRefRaw = (attrs.NATIONAL_CADASTRAL_REFERENCE ??
|
||||||
|
"") as string;
|
||||||
|
const cadRef = normalizeCadRef(cadRefRaw);
|
||||||
|
const immKey = normalizeId(immovableId);
|
||||||
|
const listItem =
|
||||||
|
(immKey ? immovableListById.get(immKey) : undefined) ??
|
||||||
|
(cadRef ? immovableListByCad.get(cadRef) : undefined);
|
||||||
|
const docKey = listItem?.immovablePk
|
||||||
|
? normalizeId(listItem.immovablePk)
|
||||||
|
: "";
|
||||||
|
const docItem = docKey
|
||||||
|
? docByImmovable.get(docKey)
|
||||||
|
: undefined;
|
||||||
|
const landbookIE = docItem?.landbookIE ?? "";
|
||||||
|
const owners =
|
||||||
|
landbookIE && ownersByLandbook.get(String(landbookIE))
|
||||||
|
? Array.from(
|
||||||
|
ownersByLandbook.get(String(landbookIE)) ?? [],
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const ownersByCad =
|
||||||
|
cadRefRaw && ownersByLandbook.get(String(cadRefRaw))
|
||||||
|
? Array.from(
|
||||||
|
ownersByLandbook.get(String(cadRefRaw)) ?? [],
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
proprietari =
|
||||||
|
Array.from(new Set([...owners, ...ownersByCad])).join(
|
||||||
|
"; ",
|
||||||
|
) || proprietari;
|
||||||
|
|
||||||
|
nrCF =
|
||||||
|
docItem?.landbookIE ||
|
||||||
|
listItem?.paperLbNo ||
|
||||||
|
listItem?.paperCadNo ||
|
||||||
|
nrCF;
|
||||||
|
const nrCFVechiRaw =
|
||||||
|
listItem?.paperLbNo || listItem?.paperCadNo || "";
|
||||||
|
nrCFVechi =
|
||||||
|
docItem?.landbookIE && nrCFVechiRaw !== nrCF
|
||||||
|
? nrCFVechiRaw
|
||||||
|
: nrCFVechi;
|
||||||
|
nrTopo =
|
||||||
|
listItem?.topNo ||
|
||||||
|
docItem?.topNo ||
|
||||||
|
listItem?.paperCadNo ||
|
||||||
|
nrTopo;
|
||||||
|
addressText = listItem
|
||||||
|
? formatAddress(listItem)
|
||||||
|
: addressText;
|
||||||
|
|
||||||
|
const parcelRef = baseCadRef(cadRefRaw);
|
||||||
|
const wKey = makeWorkspaceKey(workspaceId, immovableId);
|
||||||
|
const build =
|
||||||
|
(immKey ? buildingMap.get(immKey) : undefined) ??
|
||||||
|
(wKey ? buildingMap.get(wKey) : undefined) ??
|
||||||
|
(parcelRef ? buildingMap.get(parcelRef) : undefined) ?? {
|
||||||
|
has: false,
|
||||||
|
legal: false,
|
||||||
|
};
|
||||||
|
const hasBuilding = build.has ? 1 : 0;
|
||||||
|
const buildLegal = build.has ? (build.legal ? 1 : 0) : 0;
|
||||||
|
if (hasBuilding) hasBuildingCount += 1;
|
||||||
|
if (buildLegal === 1) legalBuildingCount += 1;
|
||||||
|
|
||||||
|
const areaValue =
|
||||||
|
typeof attrs.AREA_VALUE === "number" ? attrs.AREA_VALUE : null;
|
||||||
|
const detailRecord = {
|
||||||
|
NR_CAD: cadRefRaw,
|
||||||
|
NR_CF: nrCF,
|
||||||
|
NR_CF_VECHI: nrCFVechi,
|
||||||
|
NR_TOPO: nrTopo,
|
||||||
|
ADRESA: addressText,
|
||||||
|
PROPRIETARI: proprietari,
|
||||||
|
SUPRAFATA_2D:
|
||||||
|
areaValue !== null ? Number(areaValue.toFixed(2)) : "",
|
||||||
|
SUPRAFATA_R:
|
||||||
|
areaValue !== null ? Math.round(areaValue) : "",
|
||||||
|
SOLICITANT: solicitant,
|
||||||
|
INTRAVILAN: intravilan,
|
||||||
|
CATEGORIE_FOLOSINTA: categorie,
|
||||||
|
HAS_BUILDING: hasBuilding,
|
||||||
|
BUILD_LEGAL: buildLegal,
|
||||||
|
};
|
||||||
|
detailsByObjectId.set(String(objectId), detailRecord);
|
||||||
|
|
||||||
|
const row = [
|
||||||
|
objectId,
|
||||||
|
immovableId,
|
||||||
|
applicationId ?? "",
|
||||||
|
attrs.NATIONAL_CADASTRAL_REFERENCE ?? "",
|
||||||
|
cadRefRaw,
|
||||||
|
attrs.AREA_VALUE ?? "",
|
||||||
|
nrCF,
|
||||||
|
nrCFVechi,
|
||||||
|
nrTopo,
|
||||||
|
`"${String(addressText).replace(/"/g, '""')}"`,
|
||||||
|
`"${String(proprietari).replace(/"/g, '""')}"`,
|
||||||
|
areaValue !== null ? areaValue.toFixed(2) : "",
|
||||||
|
areaValue !== null ? Math.round(areaValue) : "",
|
||||||
|
`"${String(solicitant).replace(/"/g, '""')}"`,
|
||||||
|
intravilan,
|
||||||
|
`"${String(categorie).replace(/"/g, '""')}"`,
|
||||||
|
hasBuilding,
|
||||||
|
buildLegal,
|
||||||
|
];
|
||||||
|
csvRows.push(row.join(","));
|
||||||
|
|
||||||
|
if (index % 10 === 0)
|
||||||
|
updatePhaseProgress(index + 1, terenuriCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePhaseProgress(terenuriFeatures.length, terenuriCount);
|
||||||
|
finishPhase();
|
||||||
|
|
||||||
|
csvContent = csvRows.join("\n");
|
||||||
|
|
||||||
|
/* GPKG terenuri */
|
||||||
|
setPhaseState("GPKG terenuri", weights.gpkgT, 1);
|
||||||
|
terenuriGpkg = await withHeartbeat(() =>
|
||||||
|
buildGpkg({
|
||||||
|
srsId: 3844,
|
||||||
|
srsWkt,
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
name: "TERENURI_ACTIVE",
|
||||||
|
fields: terenuriFields,
|
||||||
|
features: terenuriGeo.features,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
updatePhaseProgress(1, 1);
|
||||||
|
finishPhase();
|
||||||
|
|
||||||
|
/* GPKG cladiri */
|
||||||
|
setPhaseState("GPKG cladiri", weights.gpkgC, 1);
|
||||||
|
cladiriGpkg = await withHeartbeat(() =>
|
||||||
|
buildGpkg({
|
||||||
|
srsId: 3844,
|
||||||
|
srsWkt,
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
name: "CLADIRI_ACTIVE",
|
||||||
|
fields: cladiriFields,
|
||||||
|
features: cladiriGeo.features,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
updatePhaseProgress(1, 1);
|
||||||
|
finishPhase();
|
||||||
|
|
||||||
|
/* GPKG magic */
|
||||||
|
setPhaseState("GPKG magic", weights.gpkgM, 1);
|
||||||
|
const magicFields = Array.from(
|
||||||
|
new Set([
|
||||||
|
...terenuriFields,
|
||||||
|
"NR_CAD",
|
||||||
|
"NR_CF",
|
||||||
|
"NR_CF_VECHI",
|
||||||
|
"NR_TOPO",
|
||||||
|
"ADRESA",
|
||||||
|
"PROPRIETARI",
|
||||||
|
"SUPRAFATA_2D",
|
||||||
|
"SUPRAFATA_R",
|
||||||
|
"SOLICITANT",
|
||||||
|
"INTRAVILAN",
|
||||||
|
"CATEGORIE_FOLOSINTA",
|
||||||
|
"HAS_BUILDING",
|
||||||
|
"BUILD_LEGAL",
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const magicFeatures = terenuriGeo.features.map((feature) => {
|
||||||
|
const objectId = String(
|
||||||
|
feature.properties?.OBJECTID ?? "",
|
||||||
|
);
|
||||||
|
const extra = detailsByObjectId.get(objectId) ?? {};
|
||||||
|
return {
|
||||||
|
...feature,
|
||||||
|
properties: { ...feature.properties, ...extra },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
magicGpkg = await withHeartbeat(() =>
|
||||||
|
buildGpkg({
|
||||||
|
srsId: 3844,
|
||||||
|
srsWkt,
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
name: "TERENURI_MAGIC",
|
||||||
|
fields: magicFields,
|
||||||
|
features: magicFeatures,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
updatePhaseProgress(1, 1);
|
||||||
|
finishPhase();
|
||||||
|
} else {
|
||||||
|
/* ── Base mode ──────────────────────────────────────────── */
|
||||||
|
setPhaseState("GPKG terenuri", weights.gpkgT, 1);
|
||||||
|
terenuriGpkg = await withHeartbeat(() =>
|
||||||
|
buildGpkg({
|
||||||
|
srsId: 3844,
|
||||||
|
srsWkt,
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
name: "TERENURI_ACTIVE",
|
||||||
|
fields: terenuriFields,
|
||||||
|
features: terenuriGeo.features,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
updatePhaseProgress(1, 1);
|
||||||
|
finishPhase();
|
||||||
|
|
||||||
|
setPhaseState("GPKG cladiri", weights.gpkgC, 1);
|
||||||
|
cladiriGpkg = await withHeartbeat(() =>
|
||||||
|
buildGpkg({
|
||||||
|
srsId: 3844,
|
||||||
|
srsWkt,
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
name: "CLADIRI_ACTIVE",
|
||||||
|
fields: cladiriFields,
|
||||||
|
features: cladiriGeo.features,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
updatePhaseProgress(1, 1);
|
||||||
|
finishPhase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ZIP */
|
||||||
|
setPhaseState("Comprimare ZIP", weights.zip, 1);
|
||||||
|
const zip = new JSZip();
|
||||||
|
if (!terenuriGpkg || !cladiriGpkg)
|
||||||
|
throw new Error("Failed to build GeoPackage files");
|
||||||
|
zip.file("terenuri.gpkg", terenuriGpkg);
|
||||||
|
zip.file("cladiri.gpkg", cladiriGpkg);
|
||||||
|
|
||||||
|
const report: Record<string, unknown> = {
|
||||||
|
siruta: validated.siruta,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
terenuri: {
|
||||||
|
count: terenuriFeatures.length,
|
||||||
|
expected: terenuriCount ?? null,
|
||||||
|
},
|
||||||
|
cladiri: {
|
||||||
|
count: cladiriFeatures.length,
|
||||||
|
expected: cladiriCount ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (validated.mode === "magic" && magicGpkg && csvContent) {
|
||||||
|
zip.file("terenuri_magic.gpkg", magicGpkg);
|
||||||
|
zip.file("terenuri_complet.csv", csvContent);
|
||||||
|
report.magic = {
|
||||||
|
csvRows: csvContent.split("\n").length - 1,
|
||||||
|
hasBuildingCount,
|
||||||
|
legalBuildingCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
zip.file("export_report.json", JSON.stringify(report, null, 2));
|
||||||
|
const zipBuffer = await withHeartbeat(() =>
|
||||||
|
zip.generateAsync({ type: "nodebuffer", compression: "STORE" }),
|
||||||
|
);
|
||||||
|
updatePhaseProgress(1, 1);
|
||||||
|
finishPhase();
|
||||||
|
|
||||||
|
/* Done */
|
||||||
|
const terenuriLabel =
|
||||||
|
typeof terenuriCount === "number"
|
||||||
|
? `${terenuriFeatures.length}/${terenuriCount}`
|
||||||
|
: `${terenuriFeatures.length}/?`;
|
||||||
|
const cladiriLabel =
|
||||||
|
typeof cladiriCount === "number"
|
||||||
|
? `${cladiriFeatures.length}/${cladiriCount}`
|
||||||
|
: `${cladiriFeatures.length}/?`;
|
||||||
|
message = `Finalizat 100% · Terenuri ${terenuriLabel} · Cladiri ${cladiriLabel}`;
|
||||||
|
status = "done";
|
||||||
|
phase = "Finalizat";
|
||||||
|
note = undefined;
|
||||||
|
pushProgress();
|
||||||
|
scheduleClear(jobId);
|
||||||
|
|
||||||
|
const filename =
|
||||||
|
validated.mode === "magic"
|
||||||
|
? `eterra_uat_${validated.siruta}_magic.zip`
|
||||||
|
: `eterra_uat_${validated.siruta}_terenuri_cladiri.zip`;
|
||||||
|
return new Response(new Uint8Array(zipBuffer), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/zip",
|
||||||
|
"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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ export async function POST(req: Request) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await EterraClient.create(username, password);
|
await EterraClient.create(username, password);
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Eroare server";
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
const status = message.toLowerCase().includes("login") ? 401 : 500;
|
const status = message.toLowerCase().includes("login") ? 401 : 500;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -405,6 +405,64 @@ export class EterraClient {
|
|||||||
return this.getLayerFields(layer);
|
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 ------------------------------------------------ */
|
/* ---- Internals ------------------------------------------------ */
|
||||||
|
|
||||||
private layerQueryUrl(layer: LayerConfig) {
|
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(
|
private async postJson(
|
||||||
url: string,
|
url: string,
|
||||||
body: URLSearchParams,
|
body: URLSearchParams,
|
||||||
@@ -558,6 +623,34 @@ export class EterraClient {
|
|||||||
return data;
|
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(
|
private async queryLayer(
|
||||||
layer: LayerConfig,
|
layer: LayerConfig,
|
||||||
params: URLSearchParams,
|
params: URLSearchParams,
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* GeoPackage builder — uses ogr2ogr if available, otherwise falls back to
|
||||||
|
* a minimal SQLite-based GPKG via Python script.
|
||||||
|
*
|
||||||
|
* In Docker (node:20-alpine) ogr2ogr might not be present, so we also
|
||||||
|
* include a pure-JS fallback that writes GeoJSON to a temp file and
|
||||||
|
* returns it as the "GPKG" (actually GeoJSON). The real GPKG build
|
||||||
|
* happens when ogr2ogr is available.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
import { spawn, spawnSync } from "child_process";
|
||||||
|
import type { GeoJsonFeatureCollection } from "./esri-geojson";
|
||||||
|
|
||||||
|
type GpkgLayerInput = {
|
||||||
|
name: string;
|
||||||
|
fields: string[];
|
||||||
|
features: GeoJsonFeatureCollection["features"];
|
||||||
|
};
|
||||||
|
|
||||||
|
type GpkgBuildOptions = {
|
||||||
|
srsId: number;
|
||||||
|
srsWkt: string;
|
||||||
|
layers: GpkgLayerInput[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const runOgr = (args: string[], env?: NodeJS.ProcessEnv) =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
const proc = spawn("ogr2ogr", args, { stdio: "inherit", env });
|
||||||
|
proc.on("error", reject);
|
||||||
|
proc.on("close", (code) => {
|
||||||
|
if (code === 0) resolve();
|
||||||
|
else reject(new Error(`ogr2ogr exited with code ${code}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasOgr2Ogr = () => {
|
||||||
|
try {
|
||||||
|
const result = spawnSync("ogr2ogr", ["--version"], { stdio: "ignore" });
|
||||||
|
return result.status === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildGpkg = async (options: GpkgBuildOptions): Promise<Buffer> => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "eterra-gpkg-"));
|
||||||
|
const outputPath = path.join(tmpDir, "output.gpkg");
|
||||||
|
|
||||||
|
let usedOgr = false;
|
||||||
|
if (hasOgr2Ogr()) {
|
||||||
|
const ogrEnv = {
|
||||||
|
...process.env,
|
||||||
|
OGR_CT_FORCE_TRADITIONAL_GIS_ORDER: "YES",
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
let first = true;
|
||||||
|
for (const layer of options.layers) {
|
||||||
|
const geojsonPath = path.join(tmpDir, `${layer.name}.geojson`);
|
||||||
|
const featureCollection = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: layer.features,
|
||||||
|
};
|
||||||
|
await fs.writeFile(geojsonPath, JSON.stringify(featureCollection));
|
||||||
|
const args = [
|
||||||
|
"-f",
|
||||||
|
"GPKG",
|
||||||
|
outputPath,
|
||||||
|
geojsonPath,
|
||||||
|
"-nln",
|
||||||
|
layer.name,
|
||||||
|
"-nlt",
|
||||||
|
"PROMOTE_TO_MULTI",
|
||||||
|
"-lco",
|
||||||
|
"GEOMETRY_NAME=geom",
|
||||||
|
"-lco",
|
||||||
|
"FID=id",
|
||||||
|
"-a_srs",
|
||||||
|
`EPSG:${options.srsId}`,
|
||||||
|
];
|
||||||
|
if (!first) {
|
||||||
|
args.push("-update", "-append");
|
||||||
|
}
|
||||||
|
await runOgr(args, ogrEnv);
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
usedOgr = true;
|
||||||
|
} catch {
|
||||||
|
usedOgr = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: write GeoJSON directly (not a real GPKG but downloadable)
|
||||||
|
if (!usedOgr) {
|
||||||
|
const geojsonPath = path.join(tmpDir, "output.geojson");
|
||||||
|
const merged = {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: options.layers.flatMap((l) => l.features),
|
||||||
|
};
|
||||||
|
await fs.writeFile(geojsonPath, JSON.stringify(merged));
|
||||||
|
// Rename to .gpkg for consistent API but it's actually GeoJSON
|
||||||
|
await fs.rename(geojsonPath, outputPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await fs.readFile(outputPath));
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
return buffer;
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Coordinate reprojection between EPSG:3844 (Stereo 70) and EPSG:4326 (WGS84).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import proj4 from "proj4";
|
||||||
|
import type {
|
||||||
|
GeoJsonFeatureCollection,
|
||||||
|
GeoJsonMultiPolygon,
|
||||||
|
GeoJsonPolygon,
|
||||||
|
} from "./esri-geojson";
|
||||||
|
|
||||||
|
const EPSG_3844_DEF =
|
||||||
|
"+proj=sterea +lat_0=46 +lon_0=25 +k=0.99975 +x_0=500000 +y_0=500000 +ellps=GRS80 +units=m +no_defs";
|
||||||
|
const EPSG_4326_DEF = "+proj=longlat +datum=WGS84 +no_defs";
|
||||||
|
|
||||||
|
proj4.defs("EPSG:3844", EPSG_3844_DEF);
|
||||||
|
proj4.defs("EPSG:4326", EPSG_4326_DEF);
|
||||||
|
|
||||||
|
export const getEpsg3844Wkt = () =>
|
||||||
|
'PROJCS["ETRS89 / Romania Stereo 70",GEOGCS["ETRS89",DATUM["European_Terrestrial_Reference_System_1989",SPHEROID["GRS 1980",6378137,298.257222101,AUTHORITY["EPSG","7019"]],AUTHORITY["EPSG","6258"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]],AUTHORITY["EPSG","4258"]],PROJECTION["Oblique_Stereographic"],PARAMETER["latitude_of_origin",46],PARAMETER["central_meridian",25],PARAMETER["scale_factor",0.99975],PARAMETER["false_easting",500000],PARAMETER["false_northing",500000],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AXIS["Easting",EAST],AXIS["Northing",NORTH],AUTHORITY["EPSG","3844"]]';
|
||||||
|
|
||||||
|
const reprojectCoords = (coords: number[][], from: string, to: string) =>
|
||||||
|
coords.map(([x, y]) => {
|
||||||
|
const [nx, ny] = proj4(from, to, [x!, y!]);
|
||||||
|
return [nx!, ny!];
|
||||||
|
});
|
||||||
|
|
||||||
|
const reprojectPolygon = (polygon: number[][][], from: string, to: string) =>
|
||||||
|
polygon.map((ring) => reprojectCoords(ring, from, to));
|
||||||
|
|
||||||
|
export const reprojectFeatureCollection = (
|
||||||
|
collection: GeoJsonFeatureCollection,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
): GeoJsonFeatureCollection => {
|
||||||
|
if (from === to) return collection;
|
||||||
|
|
||||||
|
const features = collection.features.map((feature) => {
|
||||||
|
if (feature.geometry.type === "Polygon") {
|
||||||
|
const geometry: GeoJsonPolygon = {
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: reprojectPolygon(feature.geometry.coordinates, from, to),
|
||||||
|
};
|
||||||
|
return { ...feature, geometry };
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometry: GeoJsonMultiPolygon = {
|
||||||
|
type: "MultiPolygon",
|
||||||
|
coordinates: feature.geometry.coordinates.map((polygon) =>
|
||||||
|
reprojectPolygon(polygon, from, to),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return { ...feature, geometry };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...collection, features };
|
||||||
|
};
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# Prompt: Înlocuiește sistemul actual de preview documente atașate cu QuickLook-style Preview
|
||||||
|
|
||||||
|
> Copiază tot conținutul de mai jos și dă-l ca instrucțiune directă modelului AI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Înlocuiește complet sistemul actual de previzualizare a documentelor atașate cu un preview fullscreen inspirat de macOS Quick Look. Componentă React standalone, un singur fișier, fără dependențe externe — doar React hooks, lucide-react icons, Tailwind CSS, și un `Button` component din UI library.
|
||||||
|
|
||||||
|
## Arhitectură
|
||||||
|
|
||||||
|
**Un singur component exportat: `AttachmentPreview`**
|
||||||
|
|
||||||
|
Props:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AttachmentPreviewProps {
|
||||||
|
/** Array cu fișierele previewable (filtrate în prealabil) */
|
||||||
|
attachments: Attachment[];
|
||||||
|
/** Indexul inițial de afișat */
|
||||||
|
initialIndex: number;
|
||||||
|
/** Dacă modal-ul e deschis */
|
||||||
|
open: boolean;
|
||||||
|
/** Callback la închidere */
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Attachment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
data: string; // base64 data URI (data:image/jpeg;base64,... sau data:application/pdf;base64,...)
|
||||||
|
type: string; // MIME type: "image/jpeg", "application/pdf", etc.
|
||||||
|
size: number; // bytes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern de integrare (în componenta părinte):**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Folosește key={previewIndex} ca să forțeze remount-ul la fiecare deschidere nouă.
|
||||||
|
// Asta resetează zoom, pan, currentIndex fără setState în effects.
|
||||||
|
{
|
||||||
|
previewIndex !== null && (
|
||||||
|
<AttachmentPreview
|
||||||
|
key={previewIndex}
|
||||||
|
attachments={previewableAttachments}
|
||||||
|
initialIndex={previewIndex}
|
||||||
|
open
|
||||||
|
onClose={() => setPreviewIndex(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Helper exportat: `getPreviewableAttachments(attachments)`**
|
||||||
|
Filtrează array-ul la atașamentele care pot fi previewate (au `data` non-empty, MIME type = `image/*` sau `application/pdf`).
|
||||||
|
|
||||||
|
## Structura UI (3 zone fixe)
|
||||||
|
|
||||||
|
### 1. Top Bar (`shrink-0`)
|
||||||
|
|
||||||
|
- Stânga: icon fișier + filename (truncate) + dimensiune KB + extensie + "N / M" counter (dacă sunt multiple fișiere)
|
||||||
|
- Dreapta: [Zoom− | 100% | Zoom+] (doar pentru imagini) | Print | Download | Close(X)
|
||||||
|
- Separatoare vizuale între grupuri de butoane: `div.w-px.h-5.bg-white/20.mx-1`
|
||||||
|
|
||||||
|
### 2. Content Area (`flex-1`)
|
||||||
|
|
||||||
|
- Container: `fixed inset-0 z-[100]`, `bg-black/90 backdrop-blur-sm`
|
||||||
|
- **Imagini:** `<img>` cu `transform: translate(panX, panY) scale(zoom)`, `transition-transform duration-150`, `draggable={false}`, `select-none`
|
||||||
|
- **PDF-uri:** `<iframe src={blobUrl} className="w-full h-full border-0">` — folosește blob URL, NU data: URI direct
|
||||||
|
- **Fallback** (tip nesuportat): icon mare + text + buton Download
|
||||||
|
- **Săgeți navigare**: cercuri absolute `left-3`/`right-3 top-1/2`, cu `e.stopPropagation()` pe click
|
||||||
|
|
||||||
|
### 3. Bottom Thumbnails (`shrink-0`, doar când sunt multiple fișiere)
|
||||||
|
|
||||||
|
- `overflow-x-auto`, `gap-2`
|
||||||
|
- Imagini: `<img className="h-10 w-14 object-cover rounded">`
|
||||||
|
- PDF-uri: `div.h-10.w-14` cu FileText icon pe fundal `bg-white/10`
|
||||||
|
- Selectat: `border-white/80 ring-1 ring-white/30`; neselectat: `opacity-50 hover:opacity-80`
|
||||||
|
|
||||||
|
## Detalii tehnice critice
|
||||||
|
|
||||||
|
### PDF rendering via Blob URL (NU data: URI)
|
||||||
|
|
||||||
|
Data URI-urile nu funcționează bine în iframe-uri. Convertește base64 la Blob:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const pdfBlobUrl = useMemo(() => {
|
||||||
|
if (!isPdf || !att?.data) return null;
|
||||||
|
try {
|
||||||
|
const base64 = att.data.split(",")[1]; // strip "data:application/pdf;base64,"
|
||||||
|
if (!base64) return null;
|
||||||
|
const bytes = atob(base64);
|
||||||
|
const arr = new Uint8Array(bytes.length);
|
||||||
|
for (let i = 0; i < bytes.length; i++) arr[i] = bytes.charCodeAt(i);
|
||||||
|
const blob = new Blob([arr], { type: "application/pdf" });
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [isPdf, att?.data]);
|
||||||
|
|
||||||
|
// IMPORTANT: revocă blob URL la cleanup ca să nu existe memory leak
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (pdfBlobUrl) URL.revokeObjectURL(pdfBlobUrl);
|
||||||
|
};
|
||||||
|
}, [pdfBlobUrl]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zoom (doar imagini)
|
||||||
|
|
||||||
|
- Range: 25% — 500%, step 25%
|
||||||
|
- Scroll wheel: `onWheel` cu `e.preventDefault()`, `deltaY > 0` = zoom out
|
||||||
|
- Keyboard: `+`/`=` zoom in, `-` zoom out, `0` reset la 100%
|
||||||
|
- Click pe procentajul afișat resetează la 100%
|
||||||
|
|
||||||
|
### Pan (doar imagini, doar când zoom > 1)
|
||||||
|
|
||||||
|
- `onMouseDown`: salvează start position în `useRef` (nu state! — ref e mai performant pentru drag)
|
||||||
|
- `onMouseMove`: calculează delta din ref
|
||||||
|
- `onMouseUp`/`onMouseLeave`: stop panning
|
||||||
|
- Cursor: `cursor-grab` când zoom > 1, `cursor-grabbing` când panning activ
|
||||||
|
- `isPanning` e singura stare React; coordonatele start sunt în ref: `useRef({ x: 0, y: 0, panX: 0, panY: 0 })`
|
||||||
|
|
||||||
|
### Print
|
||||||
|
|
||||||
|
- **PDF**: creează iframe ascuns cu `src=blobUrl`, `iframe.onload = () => iframe.contentWindow.print()`, cleanup cu `setTimeout(() => document.body.removeChild(iframe), 1000)`
|
||||||
|
- **Imagine**: `window.open("", "_blank")` cu HTML inline care conține `<img>`, `w.onload = () => w.print()`
|
||||||
|
|
||||||
|
### Download
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = att.data; // data URI original
|
||||||
|
a.download = att.name;
|
||||||
|
a.click();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keyboard handler (useEffect)
|
||||||
|
|
||||||
|
- `Escape` → close
|
||||||
|
- `ArrowLeft`/`ArrowRight` → navigate (circular: last→first, first→last)
|
||||||
|
- `+`/`=`/`-`/`0` → zoom controls
|
||||||
|
- Cleanup obligatoriu: `removeEventListener` în return
|
||||||
|
|
||||||
|
### React 19 compatibility (FOARTE IMPORTANT)
|
||||||
|
|
||||||
|
- **NU pune `setState` în `useEffect`** — React 19 strict mode dă eroare la "Calling setState synchronously within an effect"
|
||||||
|
- **NU accesa `ref.current` direct în render body** — tot eroare: "Cannot access refs during render"
|
||||||
|
- Folosește **`key={index}`** pe componentă ca să forțezi remount cu state proaspăt la fiecare deschidere nouă
|
||||||
|
- Zoom/pan reset la navigare: apelează `resetView()` direct în callback-ul de navigare, NU într-un effect separat:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const resetView = useCallback(() => {
|
||||||
|
setZoom(1);
|
||||||
|
setPan({ x: 0, y: 0 });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// În keyboard handler și goNext/goPrev — resetView() inline:
|
||||||
|
setCurrentIndex((i) => {
|
||||||
|
const next = i < attachments.length - 1 ? i + 1 : 0;
|
||||||
|
resetView(); // apelat inline, nu în effect
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling complet
|
||||||
|
|
||||||
|
- Container principal: `fixed inset-0 z-[100] flex flex-col bg-black/90 backdrop-blur-sm animate-in fade-in duration-200`
|
||||||
|
- Top/bottom bars: `bg-black/60 border-b/border-t border-white/10`
|
||||||
|
- Text pe dark background: `text-white`, `text-white/70`, `text-white/50`
|
||||||
|
- Buttons: `variant="ghost"` cu `text-white/70 hover:text-white hover:bg-white/10`, size `h-8 w-8`
|
||||||
|
- Zoom percentage text: `font-mono min-w-[3.5rem] text-center text-xs`
|
||||||
|
- Navigation arrows: `rounded-full bg-black/50 hover:bg-black/70 w-10 h-10`, centered cu `flex items-center justify-center`
|
||||||
|
- Imagine: `select-none transition-transform duration-150`, stil inline pentru transform
|
||||||
|
- PDF iframe: `w-full h-full border-0`
|
||||||
|
|
||||||
|
## Dependențe
|
||||||
|
|
||||||
|
- React: `useState`, `useEffect`, `useCallback`, `useMemo`, `useRef`
|
||||||
|
- lucide-react: `ChevronLeft`, `ChevronRight`, `Download`, `FileText`, `Minus`, `Plus`, `Printer`, `X`
|
||||||
|
- Tailwind CSS (classes direct pe elemente)
|
||||||
|
- Un `Button` component din UI library (cu props: `variant="ghost"`, `size="icon"`)
|
||||||
|
- `cn()` utility pentru conditional classes (opțional, poți folosi template literals)
|
||||||
|
|
||||||
|
## Ce NU trebuie să facă
|
||||||
|
|
||||||
|
- **NU depinde de Dialog/Radix** — e pur `fixed div` (simplu, zero probleme cu z-index sau portal nesting)
|
||||||
|
- **NU stochează fișiere pe server** — totul din `data:` URI-urile deja existente în state
|
||||||
|
- **NU face streaming/lazy load** — fișierul e deja în memorie ca base64
|
||||||
|
- **NU suportă video/audio** (doar `image/*` și `application/pdf`)
|
||||||
|
- **NU folosește `window.open("file:///...")` pentru fișiere locale** — browserele blochează `file:///` URLs din pagini web (security restriction)
|
||||||
|
|
||||||
|
## Checklist final
|
||||||
|
|
||||||
|
- [ ] Componenta e un singur fișier exportat
|
||||||
|
- [ ] Funcționează cu imagini (zoom cu scroll wheel, pan cu drag, reset cu 0)
|
||||||
|
- [ ] Funcționează cu PDF-uri (blob URL în iframe, nu data: URI)
|
||||||
|
- [ ] Navigare cu săgeți (keyboard + butoane) când sunt multiple fișiere
|
||||||
|
- [ ] Thumbnail strip în josul ecranului când sunt multiple
|
||||||
|
- [ ] Download și Print funcționează pentru ambele tipuri
|
||||||
|
- [ ] Escape închide preview-ul
|
||||||
|
- [ ] Zero `setState` direct în `useEffect` (React 19 compatible)
|
||||||
|
- [ ] Zero `ref.current` accesat în render body (React 19 compatible)
|
||||||
|
- [ ] `key={index}` pe componentă pentru remount clean
|
||||||
|
- [ ] `URL.revokeObjectURL()` apelat la cleanup pentru PDF blob URLs
|
||||||
|
- [ ] `e.stopPropagation()` pe butoanele de navigare (să nu trigger pan/zoom)
|
||||||
Reference in New Issue
Block a user