-
No se pudo cargar el panel
-
{{ error }}
-
+
+
+
+
+
+ ¡Oops! Algo falló
+
+
+
+
+ No pudimos cargar esta pantalla. Puede ser un problema temporal, de conexión o de datos del servidor.
+
+
+
+ {{ crashSummary(error) }}
+
+
+ Detalle técnico
+ {{ crashDetail(error) }}
+
+
+
+
+
+
+
@@ -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(() => {