panels-origin/web-panel/pages/presupuesto.vue
Cursor Agent dc510236c5
feat: programa de obra por proyecto (import, wizard, Gantt, vs costos)
Implementa el módulo completo de programa de obra vinculado al presupuesto:

- Schema PostgreSQL: work_programs, periods, concept_slots, partida_amounts, progress
- RPC: CRUD, import replace, generate wizard, recalculate partidas, vs-cost
- API: parser Excel Neodata (concepto Barra= + partida erogaciones), endpoints REST
- UI: página /programa-obra con cuadrículas, Gantt, comparativo programado/ejecutado
- Integración: menú, ficha proyecto, banner en presupuesto, permiso manage_budget

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-05 05:17:18 +00:00

470 lines
16 KiB
Vue

<template>
<div class="presupuesto-page">
<DeskTableBar v-model="tableQ" placeholder="Buscar clave, descripción o unidad">
<Chip :label="projectName" icon="pi pi-building" />
<Chip :label="`${listed.length} partidas`" icon="pi pi-list" />
<Chip :label="money(subtotal)" icon="pi pi-wallet" />
<Chip :label="`IVA ${money(iva)}`" />
<Chip :label="`Total ${money(total)}`" icon="pi pi-calculator" />
</DeskTableBar>
<Message v-if="!items.length" severity="info" :closable="false">
Esta obra no tiene partidas cargadas. El PDF del expediente no se desglosa solo: hay que importar el Excel
(plantilla del panel o presupuesto Opus/Neodata).
<div class="flex gap-2 mt-3">
<Button label="Importar Excel" icon="pi pi-upload" :disabled="!currentProject" @click="openImport" />
<Button label="Descargar plantilla" icon="pi pi-download" outlined :disabled="!currentProject" :loading="templateLoading" @click="downloadTemplate" />
</div>
</Message>
<Message
v-else-if="currentProject?.start_date && currentProject?.end_date && items.length"
severity="success"
:closable="true"
>
Presupuesto listo. Puede crear el programa de obra para esta obra.
<Button
label="Ir a Programa de obra"
icon="pi pi-calendar"
class="mt-2"
size="small"
@click="navigateTo({ path: '/programa-obra', query: { project: currentProject?.id } })"
/>
</Message>
<DataTable
v-model:selection="selected"
:value="listed"
data-key="id"
paginator
:rows="25"
:rows-per-page-options="[25, 50, 100]"
empty-message="No hay partidas. Importe un Excel o capture una partida."
selection-mode="single"
meta-key-selection="false"
removable-sort
scrollable
scroll-height="flex"
table-style="min-width: 64rem"
@row-click="onRow"
@row-dblclick="(e: { data: BudgetItem }) => openEdit(e.data)"
>
<Column field="code" header="Clave" sortable style="width: 8rem">
<template #body="{ data }"><span class="identifier">{{ data.code || "—" }}</span></template>
</Column>
<Column field="description" header="Descripción" sortable>
<template #body="{ data }">{{ shortDesc(data.description) }}</template>
</Column>
<Column field="unit" header="Unidad" sortable style="width: 6rem" />
<Column field="quantity" header="Cantidad" sortable style="width: 8rem">
<template #body="{ data }">{{ qty(data.quantity) }}</template>
</Column>
<Column field="unit_price" header="P.U." sortable style="width: 9rem">
<template #body="{ data }">{{ money(data.unit_price) }}</template>
</Column>
<Column field="amount" header="Importe" sortable style="width: 10rem">
<template #body="{ data }"><strong>{{ money(data.amount) }}</strong></template>
</Column>
</DataTable>
<Dialog v-model:visible="showForm" :header="form.id ? 'Editar partida' : 'Nueva partida'" modal :style="{ width: '640px' }">
<div class="form-grid form-grid-compact">
<FormField label="Capítulo">
<Select v-model="form.chapter_id" :options="chapterOptions" option-label="name" option-value="id" placeholder="Sin capítulo" show-clear class="w-full" />
</FormField>
<FormField label="Clave">
<InputText v-model="form.code" class="w-full identifier-input" />
</FormField>
<FormField label="Descripción" class="span-2" required>
<Textarea v-model="form.description" class="w-full" rows="4" auto-resize />
</FormField>
<FormField label="Unidad">
<InputText v-model="form.unit" class="w-full" />
</FormField>
<FormField label="Cantidad">
<InputNumber v-model="form.quantity" :min="0" :min-fraction-digits="0" :max-fraction-digits="4" class="w-full" />
</FormField>
<FormField label="Precio unitario">
<InputNumber v-model="form.unit_price" mode="currency" currency="MXN" locale="es-MX" :min="0" class="w-full" />
</FormField>
<FormField label="Importe">
<InputText :model-value="money(form.quantity * form.unit_price)" readonly class="w-full" />
</FormField>
</div>
<Message v-if="formError" severity="error" class="mt-3" :closable="false">{{ formError }}</Message>
<template #footer>
<Button label="Cancelar" text @click="showForm = false" />
<Button label="Guardar" icon="pi pi-check" @click="saveItem" />
</template>
</Dialog>
<Dialog v-model:visible="showChapter" header="Nuevo capítulo" modal :style="{ width: '28rem' }">
<FormField label="Nombre">
<InputText v-model="chapterName" class="w-full" placeholder="Acabados, Bardas, Cimentación" />
</FormField>
<template #footer>
<Button label="Cancelar" text @click="showChapter = false" />
<Button label="Crear" icon="pi pi-check" @click="saveChapter" />
</template>
</Dialog>
<BudgetImportDialog
v-model:visible="showImport"
:project-id="currentProject?.id || 0"
:project-name="projectName"
@imported="onImported"
/>
</div>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import BudgetImportDialog from "~/components/BudgetImportDialog.vue";
type Project = { id: number; code: string; name: string; status: string; budget_count?: number; start_date?: string | null; end_date?: string | null };
type Chapter = {
id: number;
name: string;
code?: string;
parent_id?: number | null;
path?: string;
item_count?: number;
amount?: number;
};
type BudgetTree = {
id: number;
parent_id: number | null;
code: string;
name: string;
wbs: string;
amount: number;
item_count: number;
children: BudgetTree[];
};
type BudgetItem = {
id: number;
chapter_id?: number | null;
chapter_name?: string | null;
parent_path?: string;
ancestor_ids?: number[];
code: string;
description: string;
unit: string;
quantity: number;
unit_price: number;
amount: number;
};
type Totals = { subtotal: number; iva: number; total: number; item_count: number };
const desk = useDesktop();
const { api, download } = useApi();
const toast = useToast();
const confirm = useConfirm();
const route = useRoute();
const projects = ref<Project[]>([]);
const chapters = ref<Chapter[]>([]);
const treeNodes = ref<BudgetTree[]>([]);
const items = ref<BudgetItem[]>([]);
const totals = ref<Totals>({ subtotal: 0, iva: 0, total: 0, item_count: 0 });
const selected = ref<BudgetItem | null>(null);
const showForm = ref(false);
const showChapter = ref(false);
const showImport = ref(false);
const chapterName = ref("");
const formError = ref("");
const templateLoading = ref(false);
const form = reactive({
id: 0,
chapter_id: null as number | null,
code: "",
description: "",
unit: "",
quantity: 0,
unit_price: 0,
});
const loadedProjectId = ref(0);
const currentProject = computed(() => projects.value.find((project) => project.id === loadedProjectId.value) || null);
const projectName = computed(() => currentProject.value?.name || "Sin proyecto");
const chapterOptions = computed(() =>
chapters.value.map((chapter) => ({ id: chapter.id, name: chapter.path || chapter.name })),
);
const currentChapterId = computed(() => {
const key = desk.treeKey.value || "";
return key.startsWith("ch:") ? Number(key.slice(3)) : 0;
});
const { tableQ, matches } = useTableSearch();
const visible = computed(() => {
const key = desk.treeKey.value || "";
if (key.startsWith("ch:")) {
const id = Number(key.slice(3));
return items.value.filter((item) =>
Number(item.chapter_id) === id || (item.ancestor_ids || []).includes(id),
);
}
if (key.endsWith(":none")) return items.value.filter((item) => !item.chapter_id);
return items.value;
});
const listed = computed(() =>
visible.value.filter((item) => matches(item.code, item.description, item.unit, item.chapter_name, item.parent_path)),
);
const subtotal = computed(() => listed.value.reduce((sum, item) => sum + Number(item.amount || 0), 0));
const iva = computed(() => Math.round(subtotal.value * 0.16 * 100) / 100);
const total = computed(() => Math.round((subtotal.value + iva.value) * 100) / 100);
function money(value: number) {
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(value || 0));
}
function qty(value: number) {
return new Intl.NumberFormat("es-MX", { maximumFractionDigits: 4 }).format(Number(value || 0));
}
function shortDesc(value: string) {
const text = String(value || "").replace(/\s+/g, " ").trim();
return text.length > 110 ? `${text.slice(0, 107)}…` : text;
}
function onRow(event: { data: BudgetItem }) {
selected.value = event.data;
desk.openBudgetItem(event.data);
}
function emptyForm() {
form.id = 0;
form.chapter_id = currentChapterId.value || chapters.value[0]?.id || null;
form.code = "";
form.description = "";
form.unit = "";
form.quantity = 0;
form.unit_price = 0;
formError.value = "";
}
function openNew() {
emptyForm();
showForm.value = true;
}
function openEdit(item: BudgetItem) {
selected.value = item;
Object.assign(form, {
id: item.id,
chapter_id: item.chapter_id ?? null,
code: item.code,
description: item.description,
unit: item.unit,
quantity: Number(item.quantity),
unit_price: Number(item.unit_price),
});
formError.value = "";
showForm.value = true;
}
async function saveItem() {
if (!form.description.trim()) {
formError.value = "Descripción de partida obligatoria";
return;
}
const id = currentProject.value?.id;
if (!id) return;
const payload = {
chapter_id: form.chapter_id,
code: form.code,
description: form.description,
unit: form.unit,
quantity: form.quantity,
unit_price: form.unit_price,
};
try {
if (form.id) await api(`/v1/projects/${id}/budget/items/${form.id}`, { method: "PATCH", json: payload });
else await api(`/v1/projects/${id}/budget/items`, { method: "POST", json: payload });
showForm.value = false;
toast.add({ severity: "success", summary: form.id ? "Partida actualizada" : "Partida creada", life: 2000 });
await loadBudget(id);
} catch (error) {
formError.value = error instanceof Error ? error.message : "No se pudo guardar";
}
}
function removeItem() {
const item = selected.value;
const id = currentProject.value?.id;
if (!item || !id) return;
confirm.require({
header: "Eliminar partida",
message: `¿Eliminar ${item.code || "esta partida"}?`,
icon: "pi pi-exclamation-triangle",
acceptLabel: "Eliminar",
rejectLabel: "Cancelar",
acceptProps: { severity: "danger" },
accept: async () => {
await api(`/v1/projects/${id}/budget/items/${item.id}`, { method: "DELETE" });
selected.value = null;
desk.clearDetail();
toast.add({ severity: "success", summary: "Partida eliminada", life: 1800 });
await loadBudget(id);
},
});
}
async function saveChapter() {
const id = currentProject.value?.id;
if (!id || !chapterName.value.trim()) return;
await api(`/v1/projects/${id}/budget/chapters`, {
method: "POST",
json: { name: chapterName.value, parent_id: currentChapterId.value || null },
});
showChapter.value = false;
chapterName.value = "";
toast.add({ severity: "success", summary: "Capítulo creado", life: 1800 });
await loadBudget(id);
}
function openImport() {
showImport.value = true;
}
async function downloadTemplate() {
const id = currentProject.value?.id;
if (!id) return;
templateLoading.value = true;
try {
await download(`/v1/projects/${id}/budget/template`, "plantilla-presupuesto.xlsx");
} finally {
templateLoading.value = false;
}
}
async function exportBudget() {
const project = currentProject.value;
if (!project) return;
await download(`/v1/projects/${project.id}/budget/export`, `presupuesto-${project.code}.xlsx`);
}
async function onImported() {
const id = currentProject.value?.id;
if (!id) return;
await loadProjects();
await loadBudget(id);
}
async function loadProjects() {
const response = await api<{ projects: Project[] }>("/v1/projects");
projects.value = response.projects;
}
async function loadBudget(id: number) {
try {
const budget = await api<{ tree: BudgetTree[]; chapters: Chapter[]; items: BudgetItem[]; totals: Totals }>(
`/v1/projects/${id}/budget`,
);
treeNodes.value = budget.tree || [];
chapters.value = budget.chapters || [];
items.value = budget.items || [];
totals.value = budget.totals || { subtotal: 0, iva: 0, total: 0, item_count: 0 };
} catch (error) {
treeNodes.value = [];
chapters.value = [];
items.value = [];
totals.value = { subtotal: 0, iva: 0, total: 0, item_count: 0 };
toast.add({
severity: "error",
summary: error instanceof Error ? error.message : "No se pudo cargar el presupuesto",
life: 3500,
});
}
if (selected.value) {
selected.value = items.value.find((item) => item.id === selected.value?.id) || null;
if (selected.value) desk.openBudgetItem(selected.value);
}
syncChrome();
}
function mapBudgetTree(nodes: BudgetTree[]) {
return nodes.map((node) => ({
key: `ch:${node.id}`,
label: [node.code, node.name].filter(Boolean).join(" "),
icon: node.children?.length ? "pi pi-folder" : "pi pi-bookmark",
data: { count: node.item_count || 0 },
children: mapBudgetTree(node.children || []),
}));
}
function syncChrome() {
const project = currentProject.value;
desk.configure({
tab: "Presupuesto",
treeTitle: "Presupuesto",
showTree: true,
showDetail: true,
treeKey: desk.treeKey.value || (project ? `p:${project.id}` : ""),
countLabel: project ? `${visible.value.length} partidas · ${money(subtotal.value)}` : "",
tree: projects.value.map((item) => ({
key: `p:${item.id}`,
label: item.name,
icon: "pi pi-building",
data: { count: item.id === project?.id ? items.value.length : Number(item.budget_count || 0) },
children: item.id === project?.id
? [
...mapBudgetTree(treeNodes.value),
...(items.value.some((row) => !row.chapter_id)
? [{ key: `p:${item.id}:none`, label: "Sin capítulo", icon: "pi pi-inbox", data: { count: items.value.filter((row) => !row.chapter_id).length } }]
: []),
]
: [],
})),
ribbon: [
{
key: "acciones",
label: "Acciones",
items: [
{ key: "alta", label: "Partida", icon: "pi pi-plus", disabled: !project, command: openNew },
{ key: "edit", label: "Editar", icon: "pi pi-pencil", disabled: !selected.value, command: () => selected.value && openEdit(selected.value) },
{ key: "baja", label: "Eliminar", icon: "pi pi-trash", disabled: !selected.value, command: removeItem },
{ key: "chapter", label: "Capítulo", icon: "pi pi-folder-plus", disabled: !project, command: () => { chapterName.value = ""; showChapter.value = true; } },
],
},
{
key: "datos",
label: "Datos",
items: [
{ key: "import", label: "Importar", icon: "pi pi-upload", disabled: !project, command: openImport },
{ key: "export", label: "Exportar", icon: "pi pi-download", disabled: !project, command: exportBudget },
],
},
{
key: "vista",
label: "Vista",
items: [{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", disabled: !project, command: () => project && loadBudget(project.id) }],
},
],
});
}
watch(
() => desk.treeKey.value,
async (key) => {
if (key?.startsWith("p:")) {
const id = Number(key.split(":")[1]);
if (id && id !== loadedProjectId.value) {
loadedProjectId.value = id;
await loadBudget(id);
return;
}
}
syncChrome();
},
);
onMounted(async () => {
await loadProjects();
const fromQuery = Number(route.query.project || 0);
const initial = fromQuery || projects.value[0]?.id;
if (initial) {
loadedProjectId.value = initial;
desk.treeKey.value = `p:${initial}`;
await loadBudget(initial);
} else {
syncChrome();
}
});
watch([items, selected, visible, chapters, treeNodes], syncChrome);
</script>