mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 14:53:18 +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 />
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue