Compare commits

..

5 commits

Author SHA1 Message Date
Alberto Martinez
f4667dd325 fix(web-panel): corregir crash post-login y mejorar pantalla de error
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Problema
Tras iniciar sesión en producción (`panels.mrdev.mx`), el panel mostraba "No se pudo cargar el panel" con:
`TypeError: Cannot read properties of undefined (reading 'filter')`

## Causa
La API devuelve respuestas con envelope RPC (`{ ok, code, data, message, layer }`), pero pantallas legacy como el dashboard esperaban shape plano (`workers`, `projects`, etc.). Al asignar `workers.value = w.workers`, el valor quedaba `undefined` y los `.filter()` en computed fallaban.

## Solución
- Normalizar respuestas en `useApi` con `unwrapApiPayload()` para compatibilidad con pantallas existentes
- Endurecer `index.vue` con arrays por defecto y manejo de errores en carga
- Rediseñar la pantalla de error global: mensaje "¡Oops! Algo falló", código de error, resumen legible y detalle técnico colapsable

## Deploy
Reconstruir y desplegar `web-panel`. Si las migraciones `027`/`028` no están en prod, aplicar `./db/update.sh core 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>&nbsp;<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>&nbsp;</div>
2026-09-04 06:57:33 +00:00
Cursor Agent
1f85b44e80
fix(web-panel): unwrap API envelope post-login and improve error screen
- Normalize RPC responses in useApi so legacy pages receive flat shapes
- Harden dashboard index.vue against undefined arrays and load failures
- Redesign Nuxt error boundary with Oops message, error code, and retry

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-04 06:56:25 +00:00
Cursor Agent
130decd48c
test: validar endpoints y RPCs de gastos, almacén e IAM
- Añade cost_modules_test.ts con CRUD de gastos, almacén, control presupuestal e IAM
- Amplía crud-smoke-test.sh para cubrir todos los endpoints HTTP nuevos
- Corrige migración IAM: grants de core.rpc_* movidos a changeset core-027
- Corrige ambigüedad PL/pgSQL en _cost_settings que rompía fn_expense_create

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-04 04:17:47 +00:00
Cursor Agent
b8e97b5137
fix(api): corregir tipos en iam_http y warehouse_http para deno check
Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-04 04:12:43 +00:00
Cursor Agent
ab89727522
feat: gastos, almacén central/obra y control presupuestal
Implementa el módulo integral de costos según el plan acordado:

- Esquema Liquibase: expense_entries, almacén (central + obra), IVA,
  tenant_cost_settings, permisos IAM y RLS
- Funciones RPC core: gastos, almacén, control presupuestal, sync nómina,
  cierre de almacén al concluir proyecto
- API HTTP: expenses_http, warehouse_http, cost_control_http, iam_http
- Middleware requirePermission con matriz IAM
- UI web-panel: /gastos, /almacen, /control-presupuesto, /usuarios
- Componentes BudgetItemPicker, CostSemaphore y semáforos CSS
- Smoke tests extendidos para gastos/almacén/control

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-04 04:04:17 +00:00
5 changed files with 207 additions and 45 deletions

View file

@ -4,10 +4,32 @@
<NuxtPage />
</NuxtLayout>
<template #error="{ error, clearError }">
<div style="padding: 32px; max-width: 640px">
<Message severity="error">No se pudo cargar el panel</Message>
<pre class="muted" style="white-space: pre-wrap">{{ error }}</pre>
<Button label="Reintentar" icon="pi pi-refresh" class="mt-3" @click="clearError" />
<div class="crash-shell">
<Card class="crash-card">
<template #title>
<div class="crash-title">
<i class="pi pi-exclamation-triangle" aria-hidden="true" />
<span>¡Oops! Algo falló</span>
</div>
</template>
<template #content>
<p class="crash-lead">
No pudimos cargar esta pantalla. Puede ser un problema temporal, de conexión o de datos del servidor.
</p>
<div class="crash-meta">
<Tag :value="crashCode(error)" severity="danger" />
<span class="crash-reason">{{ crashSummary(error) }}</span>
</div>
<details v-if="crashDetail(error)" class="crash-details">
<summary>Detalle técnico</summary>
<pre>{{ crashDetail(error) }}</pre>
</details>
<div class="crash-actions">
<Button label="Reintentar" icon="pi pi-refresh" @click="clearError" />
<Button label="Ir al inicio" icon="pi pi-home" severity="secondary" outlined @click="goHome(clearError)" />
</div>
</template>
</Card>
</div>
</template>
</NuxtErrorBoundary>
@ -16,3 +38,37 @@
<ConfirmDialog />
</ClientOnly>
</template>
<script setup lang="ts">
function crashCode(error: unknown): string {
if (error && typeof error === "object") {
const row = error as Record<string, unknown>;
if (typeof row.code === "string" && row.code.trim()) return row.code;
if (typeof row.statusCode === "number") return `HTTP_${row.statusCode}`;
}
if (error instanceof Error && error.name) return error.name;
return "PANEL_RENDER_ERROR";
}
function crashSummary(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
try {
return JSON.stringify(error);
} catch {
return "Error desconocido al renderizar la pantalla.";
}
}
function crashDetail(error: unknown): string {
if (error instanceof Error && error.stack) return error.stack;
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
return "";
}
async function goHome(clearError: () => void) {
clearError();
await navigateTo("/");
}
</script>

View file

@ -367,3 +367,68 @@ html, body, #__nuxt {
}
.dash-span-2 { grid-column: auto; }
}
.crash-shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: linear-gradient(180deg, #eef1f4 0%, #e2e8f0 100%);
}
.crash-card {
width: min(560px, 100%);
box-shadow: 0 12px 40px rgba(15, 39, 68, 0.12);
}
.crash-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 1.25rem;
font-weight: 700;
color: #0f2744;
}
.crash-title .pi {
color: #a20000;
font-size: 1.4rem;
}
.crash-lead {
margin: 0 0 16px;
color: #4b5563;
line-height: 1.5;
}
.crash-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.crash-reason {
color: #1c2430;
font-size: 0.95rem;
}
.crash-details {
margin-bottom: 16px;
border: 1px solid #d5dde6;
border-radius: 8px;
background: #f8fafc;
padding: 8px 12px;
}
.crash-details summary {
cursor: pointer;
font-weight: 600;
color: #0f2744;
}
.crash-details pre {
margin: 10px 0 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
color: #334155;
}
.crash-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}

View file

@ -41,11 +41,31 @@ export function isApiEnvelope(value: unknown): value is {
status?: number;
layer: string;
message: string;
data?: unknown;
} {
return !!value && typeof value === "object"
&& "ok" in value && "message" in value && "layer" in value;
}
/** Normaliza respuestas RPC `{ ok, data, message }` al shape plano que esperan las pantallas. */
export function unwrapApiPayload<T>(body: unknown): T {
if (!isApiEnvelope(body)) return body as T;
const env = body;
const data = env.data;
const spread = data && typeof data === "object" && !Array.isArray(data)
? data as Record<string, unknown>
: {};
return {
...spread,
data,
ok: env.ok,
code: env.code,
message: env.message,
status: env.status,
layer: env.layer,
} as T;
}
/** Muestra el mensaje detallado de la API o front sin reescribirlo. */
export function displayErrorMessage(value: unknown, fallback = "Ocurrió un error"): string {
if (isApiEnvelope(value)) return value.message;

View file

@ -1,3 +1,5 @@
import { displayErrorMessage, isApiEnvelope, unwrapApiPayload } from "./api-response.ts";
export function useApi() {
async function api<T>(path: string, opts: RequestInit & { json?: unknown } = {}): Promise<T> {
const headers = new Headers(opts.headers);
@ -8,12 +10,17 @@ export function useApi() {
credentials: "include",
body: opts.json !== undefined ? JSON.stringify(opts.json) : opts.body,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw Object.assign(new Error(err.error || "Error"), { status: res.status, data: err });
}
const ct = res.headers.get("content-type") || "";
if (ct.includes("application/json")) return await res.json() as T;
const isJson = ct.includes("application/json");
const raw = isJson ? await res.json().catch(() => ({})) : null;
if (!res.ok) {
const errBody = isApiEnvelope(raw) ? raw : (raw as { error?: string } | null);
throw Object.assign(
new Error(displayErrorMessage(errBody, res.statusText)),
{ status: res.status, data: errBody },
);
}
if (isJson) return unwrapApiPayload<T>(raw);
return (await res.blob()) as T;
}
async function download(path: string, filename: string) {

View file

@ -178,13 +178,13 @@ const stats = ref([
]);
const listedReady = computed(() =>
workers.value
(workers.value ?? [])
.filter((w) => w.pipeline_status === "listo_gafete")
.filter((w) => matches(w.first_name, w.last_name_p, w.last_name_m, w.middle_name, w.position, w.risk_code)),
);
const activos = computed(() =>
workers.value.filter((w) => w.status === "activo" && w.pipeline_status !== "baja"),
(workers.value ?? []).filter((w) => w.status === "activo" && w.pipeline_status !== "baja"),
);
const themeKey = computed(() => (isDark.value ? "dark" : "light"));
@ -224,7 +224,7 @@ function doughnut(labels: string[], data: number[], colors: string[]): ChartData
const pipelineChart = computed(() => {
const order = ["incompleto", "listo_gafete", "impreso", "activo", "baja"];
const colors = ["#6b7785", "#1c4a7a", "#c9a227", "#008514", "#a20000"];
const counts = order.map((key) => workers.value.filter((w) => w.pipeline_status === key).length);
const counts = order.map((key) => (workers.value ?? []).filter((w) => w.pipeline_status === key).length);
return doughnut(order.map((key) => pipelineLabel(key)), counts, colors);
});
@ -537,39 +537,53 @@ onMounted(async () => {
},
],
});
const [w, p, j, pe] = await Promise.all([
api<{ workers: Worker[]; imss_stats?: { imss_ready_count?: number } }>("/v1/workers"),
api<{ projects: ProjectRow[] }>("/v1/projects?status=activo,pausado"),
api<{ jobs: JobRow[] }>("/v1/badge-jobs"),
api<{ weeks: WeekRow[] }>("/v1/payroll/weeks/open"),
]);
workers.value = w.workers;
projects.value = p.projects;
const currentStart = mondayOf(new Date());
openWeek.value = pe.weeks.find((week) => week.week_start === currentStart) || pe.weeks[0] || null;
const activeList = w.workers.filter((x) => x.status === "activo" && x.pipeline_status !== "baja");
const activosN = activeList.length;
const listos = w.workers.filter((x) => x.pipeline_status === "listo_gafete").length;
const activosProyectos = p.projects.filter((x) => x.status === "activo").length;
const loanSaldo = activeList.reduce((sum, row) => sum + Number(row.loan_balance || 0), 0);
const sinImssObra = activeList.filter((x) => x.in_project_without_imss).length;
stats.value = [
{ label: "Activos", value: String(activosN), icon: "pi pi-users", bg: "#0f2744", hint: "Alta", severity: "success", to: "/padron" },
{ label: "Obras", value: String(activosProyectos), icon: "pi pi-building", bg: "#1c4a7a", hint: "Proyectos", severity: "info", to: "/proyectos" },
{ label: "A pagar (semana)", value: openWeek.value ? money(Number(openWeek.value.payable_net || 0)) : "—", icon: "pi pi-wallet", bg: "#194ca8", hint: "Nómina", severity: "info", to: "/nomina" },
{ label: "Saldo préstamos", value: money(loanSaldo), icon: "pi pi-replay", bg: "#008514", hint: "Por cobrar", severity: "success", to: "/nomina" },
{ label: "Listos gafete", value: String(listos), icon: "pi pi-id-card", bg: "#c9a227", hint: "Imprimir", severity: "warn", to: "/gafetes" },
{ label: "En obra sin IMSS", value: String(sinImssObra), icon: "pi pi-exclamation-triangle", bg: "#a20000", hint: "Prioridad", severity: "danger", to: "/padron" },
];
desk.setStatusMessages(
buildHomeAlerts({
workers: w.workers,
projects: p.projects,
jobs: j.jobs,
weeks: pe.weeks,
imssReady: w.imss_stats?.imss_ready_count || 0,
}),
);
try {
const [w, p, j, pe] = await Promise.all([
api<{ workers?: Worker[]; imss_stats?: { imss_ready_count?: number } }>("/v1/workers"),
api<{ projects?: ProjectRow[] }>("/v1/projects?status=activo,pausado"),
api<{ jobs?: JobRow[] }>("/v1/badge-jobs"),
api<{ weeks?: WeekRow[] }>("/v1/payroll/weeks/open"),
]);
const workerRows = w.workers ?? [];
const projectRows = p.projects ?? [];
const jobRows = j.jobs ?? [];
const weekRows = pe.weeks ?? [];
workers.value = workerRows;
projects.value = projectRows;
const currentStart = mondayOf(new Date());
openWeek.value = weekRows.find((week) => week.week_start === currentStart) || weekRows[0] || null;
const activeList = workerRows.filter((x) => x.status === "activo" && x.pipeline_status !== "baja");
const activosN = activeList.length;
const listos = workerRows.filter((x) => x.pipeline_status === "listo_gafete").length;
const activosProyectos = projectRows.filter((x) => x.status === "activo").length;
const loanSaldo = activeList.reduce((sum, row) => sum + Number(row.loan_balance || 0), 0);
const sinImssObra = activeList.filter((x) => x.in_project_without_imss).length;
stats.value = [
{ label: "Activos", value: String(activosN), icon: "pi pi-users", bg: "#0f2744", hint: "Alta", severity: "success", to: "/padron" },
{ label: "Obras", value: String(activosProyectos), icon: "pi pi-building", bg: "#1c4a7a", hint: "Proyectos", severity: "info", to: "/proyectos" },
{ label: "A pagar (semana)", value: openWeek.value ? money(Number(openWeek.value.payable_net || 0)) : "—", icon: "pi pi-wallet", bg: "#194ca8", hint: "Nómina", severity: "info", to: "/nomina" },
{ label: "Saldo préstamos", value: money(loanSaldo), icon: "pi pi-replay", bg: "#008514", hint: "Por cobrar", severity: "success", to: "/nomina" },
{ label: "Listos gafete", value: String(listos), icon: "pi pi-id-card", bg: "#c9a227", hint: "Imprimir", severity: "warn", to: "/gafetes" },
{ label: "En obra sin IMSS", value: String(sinImssObra), icon: "pi pi-exclamation-triangle", bg: "#a20000", hint: "Prioridad", severity: "danger", to: "/padron" },
];
desk.setStatusMessages(
buildHomeAlerts({
workers: workerRows,
projects: projectRows,
jobs: jobRows,
weeks: weekRows,
imssReady: w.imss_stats?.imss_ready_count || 0,
}),
);
} catch (e) {
desk.setStatusMessages([{
key: "home_load_error",
severity: "danger",
title: "No se pudo cargar el tablero",
detail: e instanceof Error ? e.message : "Revise la conexión con el servidor.",
route: "/",
}]);
}
});
onBeforeUnmount(() => {