panels-origin/web-panel/pages/control-presupuesto.vue
Cursor Agent 6fd673f581
fix: ribbon de Control presupuesto y captura de gastos por concepto
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 <alberto.martinez@mrdev.mx>
2026-09-08 19:55:37 +00:00

234 lines
7.6 KiB
Vue

<template>
<div class="control-page">
<DeskTableBar v-model="tableQ" placeholder="Buscar partida o concepto">
<Chip :label="projectName" icon="pi pi-building" />
<Chip v-if="summary" :label="`${summary.pct_executed}% ejecutado`" icon="pi pi-chart-line" />
<Chip v-if="summary" :label="money(summary.executed_subtotal)" icon="pi pi-wallet" />
</DeskTableBar>
<Message
v-if="summary && items.length && Number(summary.executed_subtotal) === 0"
severity="info"
:closable="false"
class="mb-3"
>
Esta pantalla compara presupuesto contra lo ejecutado. Los gastos se capturan en
<strong>Gastos</strong>, ligados a un concepto. Use «Registrar gasto» o haga clic en un renglón.
</Message>
<div v-if="summary" class="kpi-row mb-3">
<div class="kpi-card">
<span class="kpi-label">Presupuesto</span>
<strong>{{ money(summary.budget_subtotal) }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-label">Ejecutado</span>
<strong>{{ money(summary.executed_subtotal) }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-label">En bodega</span>
<strong>{{ money(summary.warehouse_stock_value) }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-label">Sin clasificar</span>
<strong>{{ money(summary.unclassified_amount) }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-label">Conceptos en rojo</span>
<strong>{{ summary.concepts_over_budget }}</strong>
</div>
</div>
<DataTable
:value="filteredItems"
data-key="id"
paginator
:rows="50"
scrollable
scroll-height="flex"
table-style="min-width: 72rem"
empty-message="Seleccione una obra con presupuesto cargado."
@row-click="onRowClick"
>
<Column field="code" header="Clave" style="width: 7rem">
<template #body="{ data }"><span class="identifier">{{ data.code || "—" }}</span></template>
</Column>
<Column field="description" header="Concepto" />
<Column field="amount_budget" header="Ppto $" style="width: 9rem">
<template #body="{ data }">{{ money(data.amount_budget) }}</template>
</Column>
<Column field="amount_executed" header="Ejecutado $" style="width: 9rem">
<template #body="{ data }">{{ money(data.amount_executed) }}</template>
</Column>
<Column field="pct_amount" header="%" style="width: 6rem">
<template #body="{ data }"><CostSemaphore :pct="Number(data.pct_amount)" compact /></template>
</Column>
<Column field="delta_amount" header="Δ $" style="width: 9rem">
<template #body="{ data }">
<span :class="Number(data.delta_amount) > 0 ? 'cost-critical-text' : ''">{{ money(data.delta_amount) }}</span>
</template>
</Column>
<Column field="qty_budget" header="Ppto cant." style="width: 8rem" />
<Column field="qty_executed" header="Ejec. cant." style="width: 8rem" />
</DataTable>
</div>
</template>
<script setup lang="ts">
import CostSemaphore from "~/components/CostSemaphore.vue";
type ControlItem = {
id: number;
code: string;
description: string;
amount_budget: number;
amount_executed: number;
pct_amount: number;
delta_amount: number;
qty_budget: number;
qty_executed: number;
semaphore: string;
};
type Summary = {
budget_subtotal: number;
executed_subtotal: number;
pct_executed: number;
warehouse_stock_value: number;
unclassified_amount: number;
concepts_over_budget: number;
};
const desk = useDesktop();
const { api } = useApi();
const route = useRoute();
const perms = usePermissions();
const tableQ = ref("");
const projects = ref<{ id: number; name: string }[]>([]);
const items = ref<ControlItem[]>([]);
const summary = ref<Summary | null>(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));
});
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) {
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`),
]);
summary.value = s.data ?? null;
items.value = i.data?.items ?? [];
}
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, async () => {
await loadControl();
syncChrome();
});
</script>
<style scoped>
.kpi-row { display: flex; flex-wrap: wrap; gap: 12px; }
.kpi-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px 14px;
min-width: 140px;
}
.kpi-label { display: block; font-size: 11px; color: #748095; margin-bottom: 4px; }
.cost-critical-text { color: #b42318; font-weight: 600; }
.control-page :deep(.p-datatable-tbody > tr) { cursor: pointer; }
</style>