panels-origin/web-panel/components/BudgetImportDialog.vue
Cursor Agent fa3656d142
fix: mensajes claros al subir presupuesto que no es Excel
El toast de error salía vacío porque el preview 400 no traía message.
Ahora solo se aceptan .xlsx/.xls, y si el Excel no tiene el formato
de presupuesto se explica qué columnas o plantilla usar.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-08 19:34:57 +00:00

316 lines
10 KiB
Vue

<template>
<Dialog
v-model:visible="visibleModel"
header="Leer presupuesto desde Excel"
modal
maximizable
:style="{ width: 'min(96vw, 92rem)' }"
:breakpoints="{ '1200px': '96vw' }"
:content-style="{ overflow: 'auto', maxHeight: 'calc(100vh - 9rem)' }"
>
<Message severity="info" :closable="false">
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>.
</Message>
<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" />
<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" />
<span v-if="pending" class="muted">{{ pending.name }}</span>
</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" />
<template v-if="preview">
<div class="import-summary mt-3">
<div><strong>{{ preview.format_label }}</strong><span>Formato</span></div>
<div><strong>{{ preview.totals.item_count }}</strong><span>Partidas</span></div>
<div><strong>{{ preview.tree.length }}</strong><span>Lotes / capítulos</span></div>
<div><strong>{{ money(preview.totals.subtotal) }}</strong><span>Subtotal</span></div>
<div><strong>{{ money(preview.totals.iva) }}</strong><span>IVA 16%</span></div>
<div><strong>{{ money(preview.totals.total) }}</strong><span>Total</span></div>
</div>
<p v-if="preview.skipped" class="muted mt-2">Se ignoraron {{ preview.skipped }} filas (totales, IVA o texto de cierre).</p>
<div class="preview-split mt-3">
<div>
<div class="section-title mb-2">Árbol encontrado</div>
<Tree :value="tree" class="desk-tree preview-tree">
<template #default="{ node }">
<span class="tree-row">
<span>{{ node.label }}</span>
<Badge v-if="node.data?.count != null" :value="node.data.count" />
</span>
</template>
</Tree>
</div>
<div>
<DeskTableBar v-model="tableQ" placeholder="Buscar clave o descripción">
<Chip :label="`${listedItems.length} partidas`" icon="pi pi-list" />
</DeskTableBar>
<DataTable :value="listedItems" size="small" class="mt-2" scrollable scroll-height="52vh">
<Column field="code" header="Clave" style="width: 8rem">
<template #body="{ data }"><span class="identifier">{{ data.code || "—" }}</span></template>
</Column>
<Column field="chapter" header="Capítulo" style="width: 10rem" />
<Column field="description" header="Descripción">
<template #body="{ data }">{{ shortDesc(data.description) }}</template>
</Column>
<Column field="unit" header="Un." style="width: 4rem" />
<Column field="amount" header="Importe" style="width: 8rem">
<template #body="{ data }">{{ money(data.amount) }}</template>
</Column>
</DataTable>
</div>
</div>
</template>
<Message v-if="error" severity="error" class="mt-3" :closable="false">{{ error }}</Message>
<template #footer>
<Button label="Cerrar" severity="secondary" text @click="visibleModel = false" />
<Button
label="Guardar en esta obra"
icon="pi pi-check"
:disabled="!canSave"
:loading="saving"
@click="confirmSave"
/>
</template>
</Dialog>
</template>
<script setup lang="ts">
import type { TreeNode } from "primevue/treenode";
import { displayErrorMessage } from "~/composables/api-response";
import { EXCEL_ACCEPT, excelOnlyMessage, isExcelSpreadsheet } from "~/utils/excelFile";
type PreviewNode = {
wbs: string;
code: string;
name: string;
amount: number;
item_count: number;
children: PreviewNode[];
};
type Preview = {
format_label: string;
sheet: string;
tree: PreviewNode[];
items: {
code: string;
description: string;
unit: string;
amount: number;
chapter: string;
}[];
totals: { subtotal: number; iva: number; total: number; item_count: number };
skipped: number;
errors: { messages: string[] }[];
};
const props = defineProps<{ visible: boolean; projectId: number; projectName: string }>();
const emit = defineEmits<{ "update:visible": [value: boolean]; imported: [] }>();
const visibleModel = computed({
get: () => props.visible,
set: (value) => emit("update:visible", value),
});
const { api, download } = useApi();
const toast = useToast();
const templateLoading = ref(false);
const reading = ref(false);
const saving = ref(false);
const preview = ref<Preview | null>(null);
const pending = ref<File | null>(null);
const error = ref("");
const fileInput = ref<HTMLInputElement | null>(null);
const { tableQ, matches } = useTableSearch();
const busy = computed(() => reading.value || saving.value);
const canSave = computed(() => Boolean(pending.value && preview.value?.totals.item_count && !busy.value));
const tree = computed<TreeNode[]>(() => mapTree(preview.value?.tree || []));
const listedItems = computed(() =>
(preview.value?.items || []).filter((item) => matches(item.code, item.description, item.unit, item.chapter)),
);
function mapTree(nodes: PreviewNode[]): TreeNode[] {
return nodes.map((node) => ({
key: node.wbs,
label: [node.code, node.name].filter(Boolean).join(" "),
icon: node.children?.length ? "pi pi-folder" : "pi pi-bookmark",
data: { count: node.item_count },
children: mapTree(node.children || []),
}));
}
function money(value: number) {
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(value || 0));
}
function shortDesc(value: string) {
const text = String(value || "").replace(/\s+/g, " ").trim();
return text.length > 160 ? `${text.slice(0, 157)}…` : text;
}
async function downloadTemplate() {
if (!props.projectId) return;
templateLoading.value = true;
try {
await download(`/v1/projects/${props.projectId}/budget/template`, "plantilla-presupuesto.xlsx");
} catch (err) {
toast.add({ severity: "error", summary: err instanceof Error ? err.message : "No se pudo descargar", life: 3000 });
} finally {
templateLoading.value = false;
}
}
function pickFile() {
fileInput.value?.click();
}
function onNativeFile(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
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) {
if (!file) return;
if (!props.projectId) {
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;
}
reading.value = true;
error.value = "";
preview.value = null;
pending.value = file;
try {
const body = new FormData();
body.append("file", file);
preview.value = await api<Preview>(`/v1/projects/${props.projectId}/budget/preview`, { method: "POST", body });
if (preview.value.errors?.length && !preview.value.totals.item_count) {
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) {
pending.value = null;
const detail = err as { data?: unknown };
showReadError(
displayErrorMessage(detail.data ?? err, err instanceof Error ? err.message : "No se pudo leer el Excel"),
"Formato de Excel no válido",
);
} finally {
reading.value = false;
}
}
async function confirmSave() {
if (!pending.value || !props.projectId) return;
saving.value = true;
error.value = "";
try {
const body = new FormData();
body.append("file", pending.value);
const report = await api<{ inserted: number; chapters: number; contract_amount?: number }>(
`/v1/projects/${props.projectId}/budget/import`,
{ method: "POST", body },
);
const amount = report.contract_amount != null
? ` · contrato ${Number(report.contract_amount).toLocaleString("es-MX", { style: "currency", currency: "MXN" })}`
: "";
toast.add({ severity: "success", summary: `${report.inserted} partidas guardadas${amount}`, life: 3200 });
emit("imported");
visibleModel.value = false;
} catch (err) {
const detail = err as { data?: unknown };
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 {
saving.value = false;
}
}
watch(
() => props.visible,
(open) => {
if (!open) return;
preview.value = null;
pending.value = null;
error.value = "";
},
);
</script>
<style scoped>
.hidden-file {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.preview-split {
display: grid;
grid-template-columns: minmax(18rem, 26rem) 1fr;
gap: 16px;
min-height: 0;
}
.preview-tree {
max-height: 52vh;
overflow: auto;
border: 1px solid #e1e6ed;
border-radius: 6px;
}
.preview-tree :deep(.tree-row) {
align-items: flex-start;
white-space: normal;
}
.preview-tree :deep(.tree-row span:first-child) {
line-height: 1.35;
}
.import-summary {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
}
.import-summary > div {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
border: 1px solid #e5e9ef;
border-radius: 6px;
background: #fbfcfd;
}
.import-summary strong {
font-size: 13px;
color: #172033;
}
.import-summary span {
font-size: 10px;
color: #748095;
}
@media (max-width: 900px) {
.preview-split,
.import-summary {
grid-template-columns: 1fr;
}
}
</style>