mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 21:43:17 +00:00
296 lines
9.7 KiB
Vue
296 lines
9.7 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 el Excel de Opus/Neodata, uno 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=".xlsx,.xls,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" 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>
|
|
<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";
|
|
|
|
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);
|
|
}
|
|
|
|
async function readFile(file: File) {
|
|
if (!file) return;
|
|
if (!props.projectId) {
|
|
error.value = "Seleccione una obra en el árbol antes de importar.";
|
|
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) {
|
|
error.value = preview.value.errors[0]?.messages?.[0] || "No se pudo leer el Excel";
|
|
}
|
|
} catch (err) {
|
|
pending.value = null;
|
|
const detail = err as { data?: { errors?: { messages: string[] }[] } };
|
|
error.value = detail.data?.errors?.[0]?.messages?.[0] || (err instanceof Error ? err.message : "No se pudo leer el Excel");
|
|
toast.add({ severity: "error", summary: error.value, life: 3500 });
|
|
} 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) {
|
|
error.value = err instanceof Error ? err.message : "No se pudo guardar";
|
|
toast.add({ severity: "error", summary: error.value, life: 3500 });
|
|
} 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>
|