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"
>
{{ data.code || "—" }}
@@ -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();
+ },
+);