mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 11:23:18 +00:00
merge: #12 toast presupuesto no Excel
Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
commit
202eca337b
7 changed files with 223 additions and 50 deletions
|
|
@ -801,10 +801,49 @@ const FORMAT_LABEL: Record<ParsedBudget["format"], string> = {
|
||||||
none: "No reconocido",
|
none: "No reconocido",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const EXCEL_ONLY_MESSAGE = "Solo se aceptan archivos Excel (.xlsx o .xls).";
|
||||||
|
export const BUDGET_FORMAT_MESSAGE =
|
||||||
|
"El Excel no tiene el formato de presupuesto esperado. Use la plantilla del panel o un archivo Opus/Neodata con columnas CLAVE, DESCRIPCION, UNIDAD, CANTIDAD y PRECIO.";
|
||||||
|
|
||||||
|
export function excelFileTypeError(filename: string): string | null {
|
||||||
|
const name = filename.trim();
|
||||||
|
if (/\.(xlsx|xls)$/i.test(name)) return null;
|
||||||
|
const shown = name || "sin nombre";
|
||||||
|
return `${EXCEL_ONLY_MESSAGE} «${shown}» no es un Excel.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function budgetReadErrorMessage(preview: { errors: { messages: string[] }[] }): string {
|
||||||
|
return preview.errors[0]?.messages?.[0] || BUDGET_FORMAT_MESSAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startsWith(bytes: Uint8Array, sig: number[]): boolean {
|
||||||
|
if (bytes.length < sig.length) return false;
|
||||||
|
return sig.every((value, i) => bytes[i] === value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unrecognizedBinaryMessage(bytes: Uint8Array): string | null {
|
||||||
|
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46])) {
|
||||||
|
return "El archivo es un PDF, no un Excel. Suba un .xlsx o .xls con el formato de presupuesto.";
|
||||||
|
}
|
||||||
|
if (startsWith(bytes, [0xff, 0xd8, 0xff]) || startsWith(bytes, [0x89, 0x50, 0x4e, 0x47])) {
|
||||||
|
return "El archivo es una imagen, no un Excel. Suba un .xlsx o .xls.";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function readBudgetSheet(bytes: Uint8Array): { sheet: string; rows: unknown[][] } | { error: string } {
|
function readBudgetSheet(bytes: Uint8Array): { sheet: string; rows: unknown[][] } | { error: string } {
|
||||||
const wb = XLSX.read(bytes, { type: "array" });
|
const binaryError = unrecognizedBinaryMessage(bytes);
|
||||||
|
if (binaryError) return { error: binaryError };
|
||||||
|
let wb: XLSX.WorkBook;
|
||||||
|
try {
|
||||||
|
wb = XLSX.read(bytes, { type: "array" });
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
error: "No se pudo abrir el archivo. Suba un Excel .xlsx o .xls con el formato de presupuesto (plantilla del panel u Opus/Neodata).",
|
||||||
|
};
|
||||||
|
}
|
||||||
const sheet = wb.SheetNames.find((name) => /presupuesto/i.test(name)) || wb.SheetNames[0];
|
const sheet = wb.SheetNames.find((name) => /presupuesto/i.test(name)) || wb.SheetNames[0];
|
||||||
if (!sheet) return { error: "El archivo no tiene hojas" };
|
if (!sheet) return { error: "El archivo Excel no tiene hojas. Revise que no esté vacío o dañado." };
|
||||||
return { sheet, rows: XLSX.utils.sheet_to_json(wb.Sheets[sheet], { header: 1, defval: "" }) as unknown[][] };
|
return { sheet, rows: XLSX.utils.sheet_to_json(wb.Sheets[sheet], { header: 1, defval: "" }) as unknown[][] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -863,7 +902,7 @@ export function previewBudgetExcel(bytes: Uint8Array): BudgetPreview {
|
||||||
const iva = Math.round(subtotal * BUDGET_IVA * 100) / 100;
|
const iva = Math.round(subtotal * BUDGET_IVA * 100) / 100;
|
||||||
const errors = parsed.items.length
|
const errors = parsed.items.length
|
||||||
? []
|
? []
|
||||||
: [{ row: 0, messages: ["No se encontraron partidas. Revise que el Excel tenga Código/Concepto o la plantilla WBS."] }];
|
: [{ row: 0, messages: [BUDGET_FORMAT_MESSAGE] }];
|
||||||
return {
|
return {
|
||||||
format: parsed.format,
|
format: parsed.format,
|
||||||
format_label: FORMAT_LABEL[parsed.format],
|
format_label: FORMAT_LABEL[parsed.format],
|
||||||
|
|
@ -902,7 +941,7 @@ export async function importBudgetExcel(database: Db, projectId: number, bytes:
|
||||||
chapters: 0,
|
chapters: 0,
|
||||||
inserted: 0,
|
inserted: 0,
|
||||||
skipped: parsed.skipped,
|
skipped: parsed.skipped,
|
||||||
errors: [{ row: 0, messages: ["No se encontraron partidas. Use la plantilla o un Excel con CLAVE, DESCRIPCION, UNIDAD, CANTIDAD y PRECIO."] }],
|
errors: [{ row: 0, messages: [BUDGET_FORMAT_MESSAGE] }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
await replaceBudgetFromParsed(database, projectId, parsed);
|
await replaceBudgetFromParsed(database, projectId, parsed);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import { assertEquals, assertStringIncludes } from "jsr:@std/assert@1";
|
import { assertEquals, assertStringIncludes } from "jsr:@std/assert@1";
|
||||||
import * as XLSX from "xlsx";
|
import * as XLSX from "xlsx";
|
||||||
import { buildBudgetTemplate, parseBudgetRows, parseMoney, previewBudgetExcel } from "./budget.ts";
|
import {
|
||||||
|
BUDGET_FORMAT_MESSAGE,
|
||||||
|
buildBudgetTemplate,
|
||||||
|
excelFileTypeError,
|
||||||
|
parseBudgetRows,
|
||||||
|
parseMoney,
|
||||||
|
previewBudgetExcel,
|
||||||
|
} from "./budget.ts";
|
||||||
|
|
||||||
Deno.test("parsea partidas estilo CLAVE DESCRIPCION UNIDAD", () => {
|
Deno.test("parsea partidas estilo CLAVE DESCRIPCION UNIDAD", () => {
|
||||||
const parsed = parseBudgetRows([
|
const parsed = parseBudgetRows([
|
||||||
|
|
@ -96,3 +103,25 @@ Deno.test("preview de plantilla no guarda y arma arbol", () => {
|
||||||
assertEquals(preview.tree[0]?.children.length, 2);
|
assertEquals(preview.tree[0]?.children.length, 2);
|
||||||
assertEquals(preview.totals.item_count, 4);
|
assertEquals(preview.totals.item_count, 4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("rechaza archivos que no son Excel por nombre", () => {
|
||||||
|
assertEquals(excelFileTypeError("obra.pdf")?.includes(".xlsx"), true);
|
||||||
|
assertEquals(excelFileTypeError("notas.docx")?.includes("«notas.docx»"), true);
|
||||||
|
assertEquals(excelFileTypeError("presupuesto.xlsx"), null);
|
||||||
|
assertEquals(excelFileTypeError("PRESUPUESTO.XLS"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("preview de PDF explica que no es Excel", () => {
|
||||||
|
const preview = previewBudgetExcel(new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34]));
|
||||||
|
assertEquals(preview.totals.item_count, 0);
|
||||||
|
assertStringIncludes(preview.errors[0]?.messages[0] || "", "PDF");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("preview de Excel sin columnas de presupuesto explica el formato", () => {
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([["Nombre", "Notas"], ["Juan", "sin partidas"]]), "Hoja1");
|
||||||
|
const bytes = XLSX.write(wb, { type: "array", bookType: "xlsx" });
|
||||||
|
const preview = previewBudgetExcel(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
|
||||||
|
assertEquals(preview.totals.item_count, 0);
|
||||||
|
assertEquals(preview.errors[0]?.messages[0], BUDGET_FORMAT_MESSAGE);
|
||||||
|
});
|
||||||
|
|
|
||||||
68
api/main.ts
68
api/main.ts
|
|
@ -73,6 +73,8 @@ import { registerIamRoutes } from "./iam_http.ts";
|
||||||
import { createSubcompany, listCompanies, resolveCompany, updateCompany, companyById } from "./companies.ts";
|
import { createSubcompany, listCompanies, resolveCompany, updateCompany, companyById } from "./companies.ts";
|
||||||
import {
|
import {
|
||||||
buildBudgetTemplate,
|
buildBudgetTemplate,
|
||||||
|
budgetReadErrorMessage,
|
||||||
|
excelFileTypeError,
|
||||||
exportBudgetWorkbook,
|
exportBudgetWorkbook,
|
||||||
previewBudgetExcel,
|
previewBudgetExcel,
|
||||||
importBudgetExcel,
|
importBudgetExcel,
|
||||||
|
|
@ -125,6 +127,17 @@ async function rpcDocumentForDownload(
|
||||||
return { doc, envelope };
|
return { doc, envelope };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readExcelUpload(
|
||||||
|
c: { req: { formData: () => Promise<FormData> } },
|
||||||
|
): Promise<{ file: File; bytes: Uint8Array } | { error: string }> {
|
||||||
|
const form = await c.req.formData();
|
||||||
|
const file = form.get("file");
|
||||||
|
if (!(file instanceof File)) return { error: "Seleccione un archivo Excel (.xlsx o .xls)." };
|
||||||
|
const typeError = excelFileTypeError(file.name);
|
||||||
|
if (typeError) return { error: typeError };
|
||||||
|
return { file, bytes: new Uint8Array(await file.arrayBuffer()) };
|
||||||
|
}
|
||||||
|
|
||||||
async function scopedCompany(
|
async function scopedCompany(
|
||||||
db: Db,
|
db: Db,
|
||||||
id: number,
|
id: number,
|
||||||
|
|
@ -738,40 +751,59 @@ app.get("/v1/projects/:id/budget/export", ...requireCoreAuth, requirePermission(
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/budget/preview", ...requireCoreAuth, requirePermission("budget.create"), async (c) => {
|
app.post("/v1/budget/preview", ...requireCoreAuth, requirePermission("budget.create"), async (c) => {
|
||||||
const form = await c.req.formData();
|
const upload = await readExcelUpload(c);
|
||||||
const file = form.get("file");
|
if ("error" in upload) {
|
||||||
if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400);
|
return respondApiError(c, "VALIDATION", upload.error, { route: routeLabel(c) });
|
||||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
}
|
||||||
const preview = previewBudgetExcel(bytes);
|
const preview = previewBudgetExcel(upload.bytes);
|
||||||
if (preview.errors.length && !preview.items.length) return c.json(preview, 400);
|
if (preview.errors.length && !preview.items.length) {
|
||||||
|
return respondApiError(c, "VALIDATION", budgetReadErrorMessage(preview), {
|
||||||
|
route: routeLabel(c),
|
||||||
|
filename: upload.file.name,
|
||||||
|
format: preview.format,
|
||||||
|
}, preview.errors.flatMap((row) => row.messages));
|
||||||
|
}
|
||||||
return c.json(preview);
|
return c.json(preview);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/projects/:id/budget/preview", ...requireCoreAuth, requirePermission("budget.create"), async (c) => {
|
app.post("/v1/projects/:id/budget/preview", ...requireCoreAuth, requirePermission("budget.create"), async (c) => {
|
||||||
const id = Number(c.req.param("id"));
|
const id = Number(c.req.param("id"));
|
||||||
if (!await denyUnlessProjectScope(c, id)) return;
|
if (!await denyUnlessProjectScope(c, id)) return;
|
||||||
const form = await c.req.formData();
|
const upload = await readExcelUpload(c);
|
||||||
const file = form.get("file");
|
if ("error" in upload) {
|
||||||
if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400);
|
return respondApiError(c, "VALIDATION", upload.error, { route: routeLabel(c), project_id: id });
|
||||||
|
}
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404);
|
if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404);
|
||||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
const preview = previewBudgetExcel(upload.bytes);
|
||||||
const preview = previewBudgetExcel(bytes);
|
if (preview.errors.length && !preview.items.length) {
|
||||||
if (preview.errors.length && !preview.items.length) return c.json(preview, 400);
|
return respondApiError(c, "VALIDATION", budgetReadErrorMessage(preview), {
|
||||||
|
route: routeLabel(c),
|
||||||
|
project_id: id,
|
||||||
|
filename: upload.file.name,
|
||||||
|
format: preview.format,
|
||||||
|
}, preview.errors.flatMap((row) => row.messages));
|
||||||
|
}
|
||||||
return c.json(preview);
|
return c.json(preview);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, requirePermission("budget.update"), async (c) => {
|
app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, requirePermission("budget.update"), async (c) => {
|
||||||
const id = Number(c.req.param("id"));
|
const id = Number(c.req.param("id"));
|
||||||
if (!await denyUnlessProjectScope(c, id)) return;
|
if (!await denyUnlessProjectScope(c, id)) return;
|
||||||
const form = await c.req.formData();
|
const upload = await readExcelUpload(c);
|
||||||
const file = form.get("file");
|
if ("error" in upload) {
|
||||||
if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400);
|
return respondApiError(c, "VALIDATION", upload.error, { route: routeLabel(c), project_id: id });
|
||||||
|
}
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404);
|
if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404);
|
||||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
const report = await importBudgetExcel(db, id, upload.bytes);
|
||||||
const report = await importBudgetExcel(db, id, bytes);
|
if (report.errors.length && !report.inserted) {
|
||||||
if (report.errors.length && !report.inserted) return c.json(report, 400);
|
return respondApiError(c, "VALIDATION", budgetReadErrorMessage(report), {
|
||||||
|
route: routeLabel(c),
|
||||||
|
project_id: id,
|
||||||
|
filename: upload.file.name,
|
||||||
|
}, report.errors.flatMap((row) => row.messages));
|
||||||
|
}
|
||||||
return c.json(report);
|
return c.json(report);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,17 @@
|
||||||
:content-style="{ overflow: 'auto', maxHeight: 'calc(100vh - 9rem)' }"
|
:content-style="{ overflow: 'auto', maxHeight: 'calc(100vh - 9rem)' }"
|
||||||
>
|
>
|
||||||
<Message severity="info" :closable="false">
|
<Message severity="info" :closable="false">
|
||||||
Suba el Excel de Opus/Neodata, uno plano o la plantilla del panel. Primero se muestra lo encontrado;
|
Suba un Excel .xlsx o .xls (Opus/Neodata, plano o la plantilla del panel). Primero se muestra lo encontrado;
|
||||||
nada se guarda hasta que confirme. Reemplazará el presupuesto de <strong>{{ projectName }}</strong>.
|
nada se guarda hasta que confirme. Reemplazará el presupuesto de <strong>{{ projectName }}</strong>.
|
||||||
</Message>
|
</Message>
|
||||||
|
|
||||||
<div class="flex flex-wrap align-items-center gap-2 mt-3">
|
<div class="flex flex-wrap align-items-center gap-2 mt-3">
|
||||||
<Button label="Descargar plantilla" icon="pi pi-download" outlined :loading="templateLoading" @click="downloadTemplate" />
|
<Button label="Descargar plantilla" icon="pi pi-download" outlined :loading="templateLoading" @click="downloadTemplate" />
|
||||||
<input ref="fileInput" type="file" accept=".xlsx,.xls,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" class="hidden-file" @change="onNativeFile" />
|
<input ref="fileInput" type="file" :accept="EXCEL_ACCEPT" class="hidden-file" @change="onNativeFile" />
|
||||||
<Button label="Seleccionar Excel" icon="pi pi-plus" :disabled="busy" @click="pickFile" />
|
<Button label="Seleccionar Excel" icon="pi pi-plus" :disabled="busy" @click="pickFile" />
|
||||||
<span v-if="pending" class="muted">{{ pending.name }}</span>
|
<span v-if="pending" class="muted">{{ pending.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="muted mt-2">Solo archivos Excel (.xlsx o .xls). Otros tipos se rechazan antes de subirlos.</div>
|
||||||
<ProgressBar v-if="busy" mode="indeterminate" class="mt-3" style="height: 4px" />
|
<ProgressBar v-if="busy" mode="indeterminate" class="mt-3" style="height: 4px" />
|
||||||
|
|
||||||
<template v-if="preview">
|
<template v-if="preview">
|
||||||
|
|
@ -82,6 +83,8 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TreeNode } from "primevue/treenode";
|
import type { TreeNode } from "primevue/treenode";
|
||||||
|
import { displayErrorMessage } from "~/composables/api-response";
|
||||||
|
import { EXCEL_ACCEPT, excelOnlyMessage, isExcelSpreadsheet } from "~/utils/excelFile";
|
||||||
|
|
||||||
type PreviewNode = {
|
type PreviewNode = {
|
||||||
wbs: string;
|
wbs: string;
|
||||||
|
|
@ -172,10 +175,21 @@ function onNativeFile(event: Event) {
|
||||||
if (file) readFile(file);
|
if (file) readFile(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showReadError(message: string, summary = "No se pudo importar el presupuesto") {
|
||||||
|
error.value = message;
|
||||||
|
toast.add({ severity: "error", summary, detail: message, life: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
async function readFile(file: File) {
|
async function readFile(file: File) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (!props.projectId) {
|
if (!props.projectId) {
|
||||||
error.value = "Seleccione una obra en el árbol antes de importar.";
|
showReadError("Seleccione una obra en el árbol antes de importar.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isExcelSpreadsheet(file)) {
|
||||||
|
pending.value = null;
|
||||||
|
preview.value = null;
|
||||||
|
showReadError(excelOnlyMessage(file.name), "Archivo no permitido");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reading.value = true;
|
reading.value = true;
|
||||||
|
|
@ -187,13 +201,18 @@ async function readFile(file: File) {
|
||||||
body.append("file", file);
|
body.append("file", file);
|
||||||
preview.value = await api<Preview>(`/v1/projects/${props.projectId}/budget/preview`, { method: "POST", body });
|
preview.value = await api<Preview>(`/v1/projects/${props.projectId}/budget/preview`, { method: "POST", body });
|
||||||
if (preview.value.errors?.length && !preview.value.totals.item_count) {
|
if (preview.value.errors?.length && !preview.value.totals.item_count) {
|
||||||
error.value = preview.value.errors[0]?.messages?.[0] || "No se pudo leer el Excel";
|
const message = preview.value.errors[0]?.messages?.[0] || "El Excel no tiene el formato de presupuesto esperado.";
|
||||||
|
pending.value = null;
|
||||||
|
preview.value = null;
|
||||||
|
showReadError(message, "Formato de Excel no válido");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
pending.value = null;
|
pending.value = null;
|
||||||
const detail = err as { data?: { errors?: { messages: string[] }[] } };
|
const detail = err as { data?: unknown };
|
||||||
error.value = detail.data?.errors?.[0]?.messages?.[0] || (err instanceof Error ? err.message : "No se pudo leer el Excel");
|
showReadError(
|
||||||
toast.add({ severity: "error", summary: error.value, life: 3500 });
|
displayErrorMessage(detail.data ?? err, err instanceof Error ? err.message : "No se pudo leer el Excel"),
|
||||||
|
"Formato de Excel no válido",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
reading.value = false;
|
reading.value = false;
|
||||||
}
|
}
|
||||||
|
|
@ -217,8 +236,9 @@ async function confirmSave() {
|
||||||
emit("imported");
|
emit("imported");
|
||||||
visibleModel.value = false;
|
visibleModel.value = false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : "No se pudo guardar";
|
const detail = err as { data?: unknown };
|
||||||
toast.add({ severity: "error", summary: error.value, life: 3500 });
|
error.value = displayErrorMessage(detail.data ?? err, err instanceof Error ? err.message : "No se pudo guardar el presupuesto");
|
||||||
|
toast.add({ severity: "error", summary: "No se pudo guardar el presupuesto", detail: error.value, life: 5000 });
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,14 +66,43 @@ export function unwrapApiPayload<T>(body: unknown): T {
|
||||||
} as T;
|
} as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function firstListedError(value: object): string | null {
|
||||||
|
if (!("errors" in value) || value.errors == null) return null;
|
||||||
|
const errors = (value as { errors: unknown }).errors;
|
||||||
|
if (typeof errors === "string" && errors.trim()) return errors;
|
||||||
|
if (!Array.isArray(errors)) return null;
|
||||||
|
for (const item of errors) {
|
||||||
|
if (typeof item === "string" && item.trim()) return item;
|
||||||
|
if (item && typeof item === "object" && "messages" in item) {
|
||||||
|
const msgs = (item as { messages: unknown }).messages;
|
||||||
|
if (Array.isArray(msgs)) {
|
||||||
|
const first = msgs.find((msg) => typeof msg === "string" && msg.trim());
|
||||||
|
if (typeof first === "string") return first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Muestra el mensaje detallado de la API o front sin reescribirlo. */
|
/** Muestra el mensaje detallado de la API o front sin reescribirlo. */
|
||||||
export function displayErrorMessage(value: unknown, fallback = "Ocurrió un error"): string {
|
export function displayErrorMessage(value: unknown, fallback = "Ocurrió un error"): string {
|
||||||
if (isApiEnvelope(value)) return value.message;
|
if (isApiEnvelope(value)) {
|
||||||
if (value && typeof value === "object" && "message" in value) {
|
const listed = firstListedError(value);
|
||||||
return String((value as { message: unknown }).message);
|
const message = value.message?.trim();
|
||||||
|
if (message) return message;
|
||||||
|
if (listed) return listed;
|
||||||
}
|
}
|
||||||
if (value && typeof value === "object" && "error" in value) {
|
if (value && typeof value === "object") {
|
||||||
return String((value as { error: unknown }).error);
|
if ("message" in value) {
|
||||||
|
const message = String((value as { message: unknown }).message || "").trim();
|
||||||
|
if (message) return message;
|
||||||
|
}
|
||||||
|
if ("error" in value) {
|
||||||
|
const error = String((value as { error: unknown }).error || "").trim();
|
||||||
|
if (error) return error;
|
||||||
|
}
|
||||||
|
const listed = firstListedError(value);
|
||||||
|
if (listed) return listed;
|
||||||
}
|
}
|
||||||
return fallback;
|
return fallback || "Ocurrió un error";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@
|
||||||
/>
|
/>
|
||||||
<div v-if="readingBudget" class="muted mt-1">Leyendo Excel…</div>
|
<div v-if="readingBudget" class="muted mt-1">Leyendo Excel…</div>
|
||||||
<div v-else-if="presupuestoFile" class="muted mt-1">{{ presupuestoFile.name }}</div>
|
<div v-else-if="presupuestoFile" class="muted mt-1">{{ presupuestoFile.name }}</div>
|
||||||
<div v-else class="muted mt-1">PDF o Excel. Si es Excel, el monto de contrato se calcula solo.</div>
|
<div v-else class="muted mt-1">PDF para el expediente, o Excel (.xlsx / .xls) para cargar partidas y el monto.</div>
|
||||||
</div>
|
</div>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -142,6 +142,8 @@
|
||||||
import FormField from "~/components/padron/FormField.vue";
|
import FormField from "~/components/padron/FormField.vue";
|
||||||
import type { CompanyOption } from "~/types/padron";
|
import type { CompanyOption } from "~/types/padron";
|
||||||
import { companyLabel } from "~/types/padron";
|
import { companyLabel } from "~/types/padron";
|
||||||
|
import { displayErrorMessage } from "~/composables/api-response";
|
||||||
|
import { isExcelSpreadsheet } from "~/utils/excelFile";
|
||||||
|
|
||||||
type Project = Record<string, unknown> & {
|
type Project = Record<string, unknown> & {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -289,9 +291,7 @@ function onPickContrato(event: { files: File[] }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function isExcelFile(file: File) {
|
function isExcelFile(file: File) {
|
||||||
const name = file.name.toLowerCase();
|
return isExcelSpreadsheet(file);
|
||||||
return name.endsWith(".xlsx") || name.endsWith(".xls") ||
|
|
||||||
file.type.includes("spreadsheet") || file.type.includes("excel");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function moneyMx(value: number) {
|
function moneyMx(value: number) {
|
||||||
|
|
@ -302,7 +302,8 @@ async function onPickPresupuesto(event: { files: File[] }) {
|
||||||
const file = event.files?.[0] || null;
|
const file = event.files?.[0] || null;
|
||||||
presupuestoFile.value = file;
|
presupuestoFile.value = file;
|
||||||
budgetHint.value = "";
|
budgetHint.value = "";
|
||||||
if (!file || !isExcelFile(file)) return;
|
if (!file) return;
|
||||||
|
if (!isExcelFile(file)) return;
|
||||||
readingBudget.value = true;
|
readingBudget.value = true;
|
||||||
try {
|
try {
|
||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
|
|
@ -310,6 +311,7 @@ async function onPickPresupuesto(event: { files: File[] }) {
|
||||||
const preview = await api<{
|
const preview = await api<{
|
||||||
totals: { subtotal: number; iva: number; total: number; item_count: number };
|
totals: { subtotal: number; iva: number; total: number; item_count: number };
|
||||||
format_label?: string;
|
format_label?: string;
|
||||||
|
errors?: { messages: string[] }[];
|
||||||
}>("/v1/budget/preview", { method: "POST", body });
|
}>("/v1/budget/preview", { method: "POST", body });
|
||||||
const subtotal = Number(preview.totals?.subtotal || 0);
|
const subtotal = Number(preview.totals?.subtotal || 0);
|
||||||
if (subtotal > 0) {
|
if (subtotal > 0) {
|
||||||
|
|
@ -321,14 +323,23 @@ async function onPickPresupuesto(event: { files: File[] }) {
|
||||||
detail: `Subtotal del presupuesto: ${moneyMx(subtotal)}`,
|
detail: `Subtotal del presupuesto: ${moneyMx(subtotal)}`,
|
||||||
life: 2800,
|
life: 2800,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
const message = preview.errors?.[0]?.messages?.[0]
|
||||||
|
|| "El Excel no tiene el formato de presupuesto esperado. Use la plantilla del panel u Opus/Neodata.";
|
||||||
|
toast.add({
|
||||||
|
severity: "warn",
|
||||||
|
summary: "Excel sin partidas",
|
||||||
|
detail: message,
|
||||||
|
life: 4500,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const detail = error as { data?: { error?: string } };
|
const detail = error as { data?: unknown };
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: "warn",
|
severity: "error",
|
||||||
summary: "No se pudo leer el Excel",
|
summary: "No se pudo leer el Excel",
|
||||||
detail: detail.data?.error || (error instanceof Error ? error.message : "Revise el formato"),
|
detail: displayErrorMessage(detail.data ?? error, error instanceof Error ? error.message : "Revise que sea .xlsx o .xls con el formato de presupuesto"),
|
||||||
life: 3500,
|
life: 4500,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
readingBudget.value = false;
|
readingBudget.value = false;
|
||||||
|
|
@ -367,12 +378,12 @@ async function uploadDocs(projectId: number) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const detail = error as { data?: { error?: string } };
|
const detail = error as { data?: unknown };
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: "warn",
|
severity: "warn",
|
||||||
summary: "Documento guardado, pero no se importaron partidas",
|
summary: "Documento guardado, pero no se importaron partidas",
|
||||||
detail: detail.data?.error || (error instanceof Error ? error.message : "Revise el Excel"),
|
detail: displayErrorMessage(detail.data ?? error, error instanceof Error ? error.message : "Revise el formato del Excel"),
|
||||||
life: 4000,
|
life: 4500,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
web-panel/utils/excelFile.ts
Normal file
13
web-panel/utils/excelFile.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
export const EXCEL_ACCEPT = ".xlsx,.xls,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||||
|
|
||||||
|
export function isExcelSpreadsheet(file: File): boolean {
|
||||||
|
const name = file.name.toLowerCase();
|
||||||
|
return name.endsWith(".xlsx") || name.endsWith(".xls");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function excelOnlyMessage(fileName?: string): string {
|
||||||
|
const name = fileName?.trim();
|
||||||
|
return name
|
||||||
|
? `Solo se aceptan archivos Excel (.xlsx o .xls). «${name}» no es un Excel.`
|
||||||
|
: "Solo se aceptan archivos Excel (.xlsx o .xls).";
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue