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>
This commit is contained in:
Cursor Agent 2026-09-04 06:56:25 +00:00
parent 130decd48c
commit 1f85b44e80
No known key found for this signature in database
5 changed files with 207 additions and 45 deletions

View file

@ -4,10 +4,32 @@
<NuxtPage /> <NuxtPage />
</NuxtLayout> </NuxtLayout>
<template #error="{ error, clearError }"> <template #error="{ error, clearError }">
<div style="padding: 32px; max-width: 640px"> <div class="crash-shell">
<Message severity="error">No se pudo cargar el panel</Message> <Card class="crash-card">
<pre class="muted" style="white-space: pre-wrap">{{ error }}</pre> <template #title>
<Button label="Reintentar" icon="pi pi-refresh" class="mt-3" @click="clearError" /> <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> </div>
</template> </template>
</NuxtErrorBoundary> </NuxtErrorBoundary>
@ -16,3 +38,37 @@
<ConfirmDialog /> <ConfirmDialog />
</ClientOnly> </ClientOnly>
</template> </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; } .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; status?: number;
layer: string; layer: string;
message: string; message: string;
data?: unknown;
} { } {
return !!value && typeof value === "object" return !!value && typeof value === "object"
&& "ok" in value && "message" in value && "layer" in value; && "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. */ /** Muestra el mensaje detallado de la API o front sin reescribirlo. */
export function displayErrorMessage(value: unknown, fallback = "Ocurrió un error"): string { export function displayErrorMessage(value: unknown, fallback = "Ocurrió un error"): string {
if (isApiEnvelope(value)) return value.message; if (isApiEnvelope(value)) return value.message;

View file

@ -1,3 +1,5 @@
import { displayErrorMessage, isApiEnvelope, unwrapApiPayload } from "./api-response.ts";
export function useApi() { export function useApi() {
async function api<T>(path: string, opts: RequestInit & { json?: unknown } = {}): Promise<T> { async function api<T>(path: string, opts: RequestInit & { json?: unknown } = {}): Promise<T> {
const headers = new Headers(opts.headers); const headers = new Headers(opts.headers);
@ -8,12 +10,17 @@ export function useApi() {
credentials: "include", credentials: "include",
body: opts.json !== undefined ? JSON.stringify(opts.json) : opts.body, 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") || ""; 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; return (await res.blob()) as T;
} }
async function download(path: string, filename: string) { async function download(path: string, filename: string) {

View file

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