Pre-computed merge for change #12

This commit is contained in:
Cursor Origin 1970-01-01 00:00:00 +00:00
commit 665fac094c
7 changed files with 223 additions and 50 deletions

View file

@ -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);

View file

@ -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);
});

View file

@ -72,6 +72,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,
@ -119,6 +121,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,
@ -732,40 +745,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);
}); });

View file

@ -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;
} }

View file

@ -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;
} }
return fallback; 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 || "Ocurrió un error";
} }

View file

@ -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 {
} catch (error) { const message = preview.errors?.[0]?.messages?.[0]
const detail = error as { data?: { error?: string } }; || "El Excel no tiene el formato de presupuesto esperado. Use la plantilla del panel u Opus/Neodata.";
toast.add({ toast.add({
severity: "warn", severity: "warn",
summary: "Excel sin partidas",
detail: message,
life: 4500,
});
}
} catch (error) {
const detail = error as { data?: unknown };
toast.add({
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,
}); });
} }
} }

View 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).";
}