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 />
</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,20 +537,25 @@ onMounted(async () => {
},
],
});
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"),
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 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 = 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");
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 = w.workers.filter((x) => x.pipeline_status === "listo_gafete").length;
const activosProyectos = p.projects.filter((x) => x.status === "activo").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 = [
@ -563,13 +568,22 @@ onMounted(async () => {
];
desk.setStatusMessages(
buildHomeAlerts({
workers: w.workers,
projects: p.projects,
jobs: j.jobs,
weeks: pe.weeks,
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(() => {