mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 22:23:18 +00:00
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Validación de endpoints y SPs Se probaron todos los endpoints nuevos (gastos, almacén, control presupuestal, IAM) y sus RPCs asociados. ### Bugs corregidos en migraciones/SQL 1. **IAM grants en schema core** — `iam-004e` intentaba `GRANT` sobre `core` con rol `iam_owner` (sin permiso). Los grants cruzados se movieron a `core-027-iam-rpc-cross-grants.sql`. 2. **`_cost_settings` ambiguo** — columnas `budget_warn_pct` etc. colisionaban con `RETURNS TABLE` en PL/pgSQL, rompiendo `fn_expense_create` y control presupuestal. Corregido en `core-028-fix-cost-settings-ambiguous.sql`. ### Tests añadidos - `api/cost_modules_test.ts` — CRUD RPC gastos, flujo almacén completo, control presupuestal, IAM permisos - `scripts/crud-smoke-test.sh` — smoke HTTP de todos los endpoints nuevos ### Resultados - `deno test cost_modules_test.ts` — 5/5 OK - `./scripts/crud-smoke-test.sh` — todos los checks OK (gastos CRUD, IVA, cost-settings, almacén, transferencias, cost-control, IAM) <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-eeec3b5c-f789-43e8-a353-0755e4706377?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a href="https://cursor.com/background-agent?bcId=bc-eeec3b5c-f789-43e8-a353-0755e4706377&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div>
153 lines
5.2 KiB
Vue
153 lines
5.2 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>
|
|
|
|
<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."
|
|
>
|
|
<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 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 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));
|
|
}
|
|
|
|
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;
|
|
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 ?? [];
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadProjects();
|
|
desk.configure({
|
|
tab: "Control presupuesto",
|
|
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(`/control-presupuesto?project_id=${key.slice(2)}`);
|
|
},
|
|
});
|
|
await loadControl();
|
|
});
|
|
|
|
watch(projectId, loadControl);
|
|
</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; }
|
|
</style>
|