From 6fd673f581ae4bcdc2ca0a87e0696b551a9dffa3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 19:55:37 +0000 Subject: [PATCH] fix: ribbon de Control presupuesto y captura de gastos por concepto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La pantalla de control era solo lectura, pero heredaba los botones de Programa de obra (no-op). Ahora tiene acciones propias, explica que el ejecutado viene de Gastos/almacén, y abre la captura ligada al concepto. Co-authored-by: alberto.martinez --- web-panel/components/BudgetItemPicker.vue | 3 + web-panel/composables/useDesktop.ts | 2 +- web-panel/pages/control-presupuesto.vue | 89 +++++++++++++++++- web-panel/pages/gastos.vue | 104 ++++++++++++++++++---- 4 files changed, 175 insertions(+), 23 deletions(-) diff --git a/web-panel/components/BudgetItemPicker.vue b/web-panel/components/BudgetItemPicker.vue index 2c0f777..c7e5665 100644 --- a/web-panel/components/BudgetItemPicker.vue +++ b/web-panel/components/BudgetItemPicker.vue @@ -50,6 +50,9 @@ async function load() { label: [row.code, row.description].filter(Boolean).join(" — "), })); filtered.value = items.value.slice(0, 30); + if (props.modelValue) { + selected.value = items.value.find((i) => i.id === props.modelValue) ?? null; + } } function onSearch(event: { query: string }) { diff --git a/web-panel/composables/useDesktop.ts b/web-panel/composables/useDesktop.ts index d7db6ce..5b83a8e 100644 --- a/web-panel/composables/useDesktop.ts +++ b/web-panel/composables/useDesktop.ts @@ -87,7 +87,7 @@ export function useDesktop() { }) { tabLabel.value = opts.tab; if (opts.treeTitle != null) treeTitle.value = opts.treeTitle; - if (opts.ribbon) ribbon.value = opts.ribbon; + ribbon.value = opts.ribbon ?? []; if (opts.tree) tree.value = opts.tree; if (opts.treeKey != null) treeKey.value = opts.treeKey; if (opts.showTree != null) showTree.value = opts.showTree; diff --git a/web-panel/pages/control-presupuesto.vue b/web-panel/pages/control-presupuesto.vue index 99f4a67..8054868 100644 --- a/web-panel/pages/control-presupuesto.vue +++ b/web-panel/pages/control-presupuesto.vue @@ -6,6 +6,16 @@ + + Esta pantalla compara presupuesto contra lo ejecutado. Los gastos se capturan en + Gastos, ligados a un concepto. Use «Registrar gasto» o haga clic en un renglón. + +
Presupuesto @@ -38,6 +48,7 @@ scroll-height="flex" table-style="min-width: 72rem" empty-message="Seleccione una obra con presupuesto cargado." + @row-click="onRowClick" > @@ -91,6 +102,7 @@ type Summary = { const desk = useDesktop(); const { api } = useApi(); const route = useRoute(); +const perms = usePermissions(); const tableQ = ref(""); const projects = ref<{ id: number; name: string }[]>([]); @@ -99,6 +111,7 @@ const summary = ref(null); const projectId = computed(() => Number(route.query.project_id || 0) || projects.value[0]?.id || 0); const projectName = computed(() => projects.value.find((p) => p.id === projectId.value)?.name || "Sin obra"); +const canCreateExpense = computed(() => perms.can("expenses.create")); const filteredItems = computed(() => { const q = tableQ.value.toLowerCase(); return items.value.filter((i) => !q || [i.code, i.description].join(" ").toLowerCase().includes(q)); @@ -108,13 +121,33 @@ function money(v: number) { return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0)); } +function goGastos(opts?: { nuevo?: boolean; budgetItemId?: number }) { + if (!projectId.value) return; + const qs = new URLSearchParams({ project_id: String(projectId.value) }); + if (opts?.nuevo) qs.set("nuevo", "1"); + if (opts?.budgetItemId) qs.set("budget_item_id", String(opts.budgetItemId)); + navigateTo(`/gastos?${qs}`); +} + +function onRowClick(e: { data: ControlItem }) { + if (!canCreateExpense.value) { + goGastos(); + return; + } + goGastos({ nuevo: true, budgetItemId: e.data.id }); +} + async function loadProjects() { const r = await api<{ projects?: { id: number; name: string }[] }>("/v1/projects"); projects.value = r.projects ?? []; } async function loadControl() { - if (!projectId.value) return; + if (!projectId.value) { + summary.value = null; + items.value = []; + return; + } const [s, i] = await Promise.all([ api<{ data?: Summary }>(`/v1/projects/${projectId.value}/cost-control/summary`), api<{ data?: { items?: ControlItem[] } }>(`/v1/projects/${projectId.value}/cost-control/items`), @@ -123,20 +156,67 @@ async function loadControl() { items.value = i.data?.items ?? []; } -onMounted(async () => { - await loadProjects(); +function syncChrome() { desk.configure({ tab: "Control presupuesto", treeTitle: "Obras", + showTree: true, + showDetail: false, + treeKey: projectId.value ? `p:${projectId.value}` : "", + countLabel: projectId.value ? `${items.value.length} conceptos` : "", tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-building" })), onTreeSelect: (key) => { if (key.startsWith("p:")) navigateTo(`/control-presupuesto?project_id=${key.slice(2)}`); }, + ribbon: [ + { + key: "control", + label: "Control", + items: [ + { + key: "gasto", + label: "Registrar gasto", + icon: "pi pi-plus", + disabled: !projectId.value || !canCreateExpense.value, + title: canCreateExpense.value + ? "Captura un gasto ligado a un concepto del presupuesto" + : "Sin permiso para capturar gastos", + command: () => goGastos({ nuevo: true }), + }, + { + key: "gastos", + label: "Ver gastos", + icon: "pi pi-list", + disabled: !projectId.value, + command: () => goGastos(), + }, + ], + }, + { + key: "vista", + label: "Vista", + items: [ + { key: "refresh", label: "Actualizar", icon: "pi pi-refresh", disabled: !projectId.value, command: loadControl }, + ], + }, + ], }); +} + +onMounted(async () => { + await loadProjects(); + if (!route.query.project_id && projectId.value) { + await navigateTo(`/control-presupuesto?project_id=${projectId.value}`, { replace: true }); + } + syncChrome(); await loadControl(); + syncChrome(); }); -watch(projectId, loadControl); +watch(projectId, async () => { + await loadControl(); + syncChrome(); +}); diff --git a/web-panel/pages/gastos.vue b/web-panel/pages/gastos.vue index df10f9b..e4cfe08 100644 --- a/web-panel/pages/gastos.vue +++ b/web-panel/pages/gastos.vue @@ -115,6 +115,7 @@ const desk = useDesktop(); const { api } = useApi(); const toast = useToast(); const route = useRoute(); +const perms = usePermissions(); const tableQ = ref(""); const expenses = ref([]); @@ -124,6 +125,7 @@ const showForm = ref(false); const formError = ref(""); const budgetWarning = ref<{ level: string; message: string } | null>(null); const receiptFile = ref(null); +const canCreateExpense = computed(() => perms.can("expenses.create")); const form = reactive({ company_id: null as number | null, @@ -163,6 +165,72 @@ function onFile(e: Event) { receiptFile.value = input.files?.[0] ?? null; } +function resetForm() { + form.company_id = companies.value[0]?.id ?? null; + form.project_id = projectId.value; + form.expense_date = new Date().toISOString().slice(0, 10); + form.budget_item_id = Number(route.query.budget_item_id || 0) || null; + form.description = ""; + form.supplier_name = ""; + form.supplier_rfc = ""; + form.quantity = 0; + form.unit = ""; + form.unit_price = 0; + form.subtotal = 0; + form.total = 0; + form.tax_included = false; + receiptFile.value = null; + formError.value = ""; +} + +function openNew() { + resetForm(); + showForm.value = true; +} + +function syncChrome() { + desk.configure({ + tab: "Gastos", + treeTitle: "Obras", + showTree: true, + showDetail: false, + treeKey: projectId.value ? `p:${projectId.value}` : "", + tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-building" })), + onTreeSelect: (key) => { + if (key.startsWith("p:")) navigateTo(`/gastos?project_id=${key.slice(2)}`); + }, + ribbon: [ + { + key: "gastos", + label: "Captura", + items: [ + { + key: "new", + label: "Nuevo gasto", + icon: "pi pi-plus", + disabled: !canCreateExpense.value, + command: openNew, + }, + ], + }, + { + key: "control", + label: "Control", + items: [ + { + key: "control", + label: "Control presupuesto", + icon: "pi pi-chart-bar", + disabled: !projectId.value, + command: () => navigateTo(`/control-presupuesto?project_id=${projectId.value}`), + }, + { key: "refresh", label: "Actualizar", icon: "pi pi-refresh", command: loadExpenses }, + ], + }, + ], + }); +} + async function loadCatalogs() { const [cat, pr] = await Promise.all([ api<{ companies?: { id: number; name: string }[] }>("/v1/catalogs"), @@ -204,6 +272,11 @@ async function save() { toast.add({ severity: "success", summary: r.message || "Gasto registrado", life: 2500 }); showForm.value = false; await loadExpenses(); + if (route.query.nuevo || route.query.budget_item_id) { + const qs = new URLSearchParams(); + if (projectId.value) qs.set("project_id", String(projectId.value)); + await navigateTo(`/gastos${qs.toString() ? `?${qs}` : ""}`, { replace: true }); + } } catch (e) { formError.value = displayErrorMessage((e as { data?: unknown }).data ?? e); } @@ -211,25 +284,20 @@ async function save() { onMounted(async () => { await loadCatalogs(); - desk.configure({ - tab: "Gastos", - treeTitle: "Obras", - tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-building" })), - onTreeSelect: (key) => { - if (key.startsWith("p:")) navigateTo(`/gastos?project_id=${key.slice(2)}`); - }, - ribbon: [ - { - key: "gastos", - label: "Captura", - items: [ - { key: "new", label: "Nuevo gasto", icon: "pi pi-plus", command: () => { showForm.value = true; } }, - ], - }, - ], - }); + syncChrome(); await loadExpenses(); + if (route.query.nuevo) openNew(); }); -watch(projectId, loadExpenses); +watch(projectId, () => { + loadExpenses(); + syncChrome(); +}); + +watch( + () => String(route.query.nuevo || ""), + (nuevo) => { + if (nuevo) openNew(); + }, +);