mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 15:33:17 +00:00
Pre-computed merge for change #4
This commit is contained in:
commit
d35095eb2c
5 changed files with 207 additions and 45 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue