From 1f85b44e80c7e8b727b09f6365acaacb456c21cd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 4 Sep 2026 06:56:25 +0000 Subject: [PATCH] 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 --- web-panel/app.vue | 64 ++++++++++++++++++-- web-panel/assets/css/app.css | 65 ++++++++++++++++++++ web-panel/composables/api-response.ts | 20 +++++++ web-panel/composables/useApi.ts | 17 ++++-- web-panel/pages/index.vue | 86 ++++++++++++++++----------- 5 files changed, 207 insertions(+), 45 deletions(-) diff --git a/web-panel/app.vue b/web-panel/app.vue index e4ce5c4..f9ce952 100644 --- a/web-panel/app.vue +++ b/web-panel/app.vue @@ -4,10 +4,32 @@ @@ -16,3 +38,37 @@ + + diff --git a/web-panel/assets/css/app.css b/web-panel/assets/css/app.css index fd93069..b52c61f 100644 --- a/web-panel/assets/css/app.css +++ b/web-panel/assets/css/app.css @@ -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; +} diff --git a/web-panel/composables/api-response.ts b/web-panel/composables/api-response.ts index 0df0070..786c95e 100644 --- a/web-panel/composables/api-response.ts +++ b/web-panel/composables/api-response.ts @@ -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(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 + : {}; + 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; diff --git a/web-panel/composables/useApi.ts b/web-panel/composables/useApi.ts index 751d6b1..0b46375 100644 --- a/web-panel/composables/useApi.ts +++ b/web-panel/composables/useApi.ts @@ -1,3 +1,5 @@ +import { displayErrorMessage, isApiEnvelope, unwrapApiPayload } from "./api-response.ts"; + export function useApi() { async function api(path: string, opts: RequestInit & { json?: unknown } = {}): Promise { 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(raw); return (await res.blob()) as T; } async function download(path: string, filename: string) { diff --git a/web-panel/pages/index.vue b/web-panel/pages/index.vue index f77eb16..c80d6bf 100644 --- a/web-panel/pages/index.vue +++ b/web-panel/pages/index.vue @@ -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(() => {