panels-origin/web-panel/pages/index.vue
Cursor Agent e89b51bf43
fix: subir documentos sin fecha de emisión ni vencimiento
La vigencia de 3 meses para alta o reingreso cuenta desde la fecha de
carga. Quien recibe el expediente revisa si el documento es válido.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-08 20:47:16 +00:00

592 lines
21 KiB
Vue

<template>
<div class="dash">
<div class="kpi-grid">
<Card
v-for="s in stats"
:key="s.label"
class="dash-kpi"
:class="{ clickable: Boolean(s.to) }"
@click="s.to && navigateTo(s.to)"
>
<template #content>
<div class="dash-kpi-head">
<Avatar :icon="s.icon" shape="circle" :style="{ background: s.bg, color: '#fff' }" />
<Tag :value="s.hint" :severity="s.severity" />
</div>
<div class="kpi-value mt-3">{{ s.value }}</div>
<div class="muted">{{ s.label }}</div>
</template>
</Card>
</div>
<div class="dash-charts">
<Card>
<template #content>
<div class="dash-chart-label">Expediente</div>
<div class="dash-chart">
<Chart v-if="hasValues(pipelineChart)" type="doughnut" :data="pipelineChart" :options="pieOptions" :key="`pipe-${themeKey}`" />
<p v-else class="muted dash-empty">Sin personal cargado</p>
</div>
</template>
</Card>
<Card>
<template #content>
<div class="dash-chart-label">IMSS</div>
<div class="dash-chart">
<Chart v-if="hasValues(imssChart)" type="doughnut" :data="imssChart" :options="pieOptions" :key="`imss-${themeKey}`" />
<p v-else class="muted dash-empty">Sin activos</p>
</div>
</template>
</Card>
<Card>
<template #content>
<div class="dash-chart-label">Riesgo de gafete</div>
<div class="dash-chart">
<Chart v-if="hasValues(riskChart)" type="doughnut" :data="riskChart" :options="pieOptions" :key="`risk-${themeKey}`" />
<p v-else class="muted dash-empty">Sin activos</p>
</div>
</template>
</Card>
<Card class="dash-span-2">
<template #content>
<div class="dash-chart-label">Personal por obra</div>
<div class="dash-chart dash-chart--bar">
<Chart v-if="hasValues(projectChart)" type="bar" :data="projectChart" :options="barOptions" :key="`proy-${themeKey}`" />
<p v-else class="muted dash-empty">Nadie asignado a obra</p>
</div>
</template>
</Card>
<Card>
<template #content>
<div class="dash-chart-label">Tipo de trabajo</div>
<div class="dash-chart">
<Chart v-if="hasValues(workChart)" type="doughnut" :data="workChart" :options="pieOptions" :key="`work-${themeKey}`" />
<p v-else class="muted dash-empty">Sin activos</p>
</div>
</template>
</Card>
<Card class="dash-span-2">
<template #content>
<div class="dash-chart-label">{{ payrollTitle }}</div>
<div class="dash-chart dash-chart--bar">
<Chart v-if="hasValues(payrollChart)" type="bar" :data="payrollChart" :options="moneyBarOptions" :key="`pay-${themeKey}`" />
<p v-else class="muted dash-empty">No hay semana de nómina abierta</p>
</div>
</template>
</Card>
<Card>
<template #content>
<div class="dash-chart-label">Atajos</div>
<div class="dash-shortcuts">
<Button label="Proyectos" icon="pi pi-building" outlined @click="navigateTo('/proyectos')" />
<Button label="Padrón" icon="pi pi-users" outlined @click="navigateTo('/padron')" />
<Button label="Por proyecto" icon="pi pi-th-large" outlined @click="navigateTo('/kanban')" />
<Button label="Nómina" icon="pi pi-wallet" outlined @click="navigateTo('/nomina')" />
<Button label="Gafetes" icon="pi pi-id-card" outlined @click="navigateTo('/gafetes')" />
</div>
</template>
</Card>
</div>
<div class="home-split">
<div class="list-page">
<DeskTableBar v-model="tableQ" placeholder="Buscar persona o cargo">
<Chip :label="`${listedReady.length} listos para gafete`" icon="pi pi-id-card" />
</DeskTableBar>
<DataTable :value="listedReady" paginator :rows="6" :rows-per-page-options="[6, 12, 25]" empty-message="Nadie en listo gafete">
<Column header="Persona">
<template #body="{ data }">
<div class="person-cell">
<Avatar :label="initials(data.first_name, data.last_name_p)" shape="circle" size="small" />
<span>{{ workerFullName(data) }}</span>
</div>
</template>
</Column>
<Column field="position" header="Cargo" />
<Column header="Riesgo">
<template #body="{ data }">
<Tag :value="riskLevelLabel(data.risk_code)" :severity="riskLevelSeverity(data.risk_code)" />
</template>
</Column>
</DataTable>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { ChartData, ChartOptions } from "chart.js";
import type { DeskStatusMessage } from "~/composables/useDesktop";
import { workerFullName } from "~/types/padron";
const { initials, riskLevelLabel, riskLevelSeverity, riskLevel, pipelineLabel } = useUi();
const { isDark } = useTheme();
const desk = useDesktop();
const { api } = useApi();
const { tableQ, matches } = useTableSearch();
type Worker = {
id?: number;
first_name: string;
middle_name?: string | null;
last_name_p: string;
last_name_m?: string | null;
status: string;
pipeline_status: string;
position: string;
risk_code: string;
risk_color: string;
work_type?: string;
imss_status?: string;
imss_ready?: boolean;
in_project_without_imss?: boolean;
freshness_required?: boolean;
loan_balance?: number;
project_ids?: string | null;
missing_docs?: number;
};
type ProjectRow = {
id?: number;
name?: string;
code?: string;
status: string;
active_count?: number;
missing_docs?: number;
};
type JobRow = { delivered?: number | boolean };
type WeekRow = {
status?: string;
week_start?: string;
week_end?: string;
required_net?: number;
payable_net?: number;
loan_recovery?: number;
};
const workers = ref<Worker[]>([]);
const projects = ref<ProjectRow[]>([]);
const openWeek = ref<WeekRow | null>(null);
const stats = ref([
{ label: "Activos", value: "—", icon: "pi pi-users", bg: "#0f2744", hint: "Alta", severity: "success" as const, to: "/padron" },
{ label: "Obras", value: "—", icon: "pi pi-building", bg: "#1c4a7a", hint: "Proyectos", severity: "info" as const, to: "/proyectos" },
{ label: "A pagar (semana)", value: "—", icon: "pi pi-wallet", bg: "#194ca8", hint: "Nómina", severity: "info" as const, to: "/nomina" },
{ label: "Saldo préstamos", value: "—", icon: "pi pi-replay", bg: "#008514", hint: "Por cobrar", severity: "success" as const, to: "/nomina" },
{ label: "Listos gafete", value: "—", icon: "pi pi-id-card", bg: "#c9a227", hint: "Imprimir", severity: "warn" as const, to: "/gafetes" },
{ label: "En obra sin IMSS", value: "—", icon: "pi pi-exclamation-triangle", bg: "#a20000", hint: "Prioridad", severity: "danger" as const, to: "/padron" },
]);
const listedReady = computed(() =>
(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"),
);
const themeKey = computed(() => (isDark.value ? "dark" : "light"));
const ink = computed(() => (isDark.value ? "#e2e8f0" : "#0f2744"));
const grid = computed(() => (isDark.value ? "rgba(148,163,184,0.16)" : "rgba(15,39,68,0.08)"));
function money(n: number) {
return (Number(n) || 0).toLocaleString("es-MX", { style: "currency", currency: "MXN", maximumFractionDigits: 0 });
}
function hasProject(worker: Worker) {
return String(worker.project_ids || "")
.split(",")
.map((part) => Number(part.trim()))
.some((id) => id > 0);
}
function hasValues(chart: { datasets?: { data?: unknown[] }[] }) {
return (chart.datasets ?? []).some((set) => (set.data ?? []).some((n) => Number(n) > 0));
}
function doughnut(labels: string[], data: number[], colors: string[]): ChartData<"doughnut"> {
const packed = labels
.map((label, i) => ({ label, value: Number(data[i] || 0), color: colors[i] }))
.filter((row) => row.value > 0);
return {
labels: packed.map((row) => row.label),
datasets: [{
data: packed.map((row) => row.value),
backgroundColor: packed.map((row) => row.color),
borderWidth: 0,
hoverOffset: 4,
}],
};
}
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);
return doughnut(order.map((key) => pipelineLabel(key)), counts, colors);
});
const imssChart = computed(() => {
const list = activos.value;
const alta = list.filter((w) => w.imss_status === "alta").length;
const obra = list.filter((w) => w.in_project_without_imss).length;
const resto = Math.max(0, list.length - alta - obra);
return doughnut(
["Alta IMSS", "En obra sin alta", "Sin alta"],
[alta, obra, resto],
["#008514", "#a20000", "#c9a227"],
);
});
const riskChart = computed(() => {
const list = activos.value;
return doughnut(
["Alto", "Medio", "Bajo"],
[
list.filter((w) => riskLevel(w.risk_code) === "alto").length,
list.filter((w) => riskLevel(w.risk_code) === "medio").length,
list.filter((w) => riskLevel(w.risk_code) === "bajo").length,
],
["#a20000", "#c9a227", "#008514"],
);
});
const workChart = computed(() => {
const list = activos.value;
return doughnut(
["Jornal", "Destajo"],
[list.filter((w) => w.work_type !== "D").length, list.filter((w) => w.work_type === "D").length],
["#1c4a7a", "#2f6fe4"],
);
});
const projectChart = computed((): ChartData<"bar"> => {
const rows = [...projects.value]
.filter((p) => p.status === "activo" || Number(p.active_count) > 0)
.sort((a, b) => Number(b.active_count || 0) - Number(a.active_count || 0))
.slice(0, 8);
const unassigned = activos.value.filter((w) => !hasProject(w)).length;
const labels = [...rows.map((p) => String(p.name || p.code || "Obra")), ...(unassigned ? ["Sin asignar"] : [])];
const data = [...rows.map((p) => Number(p.active_count || 0)), ...(unassigned ? [unassigned] : [])];
const colors = [...rows.map(() => "#2f6fe4"), ...(unassigned ? ["#c9a227"] : [])];
return {
labels,
datasets: [{ label: "Personas", data, backgroundColor: colors, borderRadius: 6, maxBarThickness: 28 }],
};
});
const payrollTitle = computed(() => {
const week = openWeek.value;
if (!week?.week_start) return "Nómina de la semana";
return `Nómina ${week.week_start} → ${week.week_end || ""}`.trim();
});
const payrollChart = computed((): ChartData<"bar"> => {
const week = openWeek.value;
return {
labels: ["Requerida", "A pagar", "Préstamos"],
datasets: [{
label: "MXN",
data: [
Number(week?.required_net || 0),
Number(week?.payable_net || 0),
Number(week?.loan_recovery || 0),
],
backgroundColor: ["#0f2744", "#2f6fe4", "#008514"],
borderRadius: 6,
maxBarThickness: 48,
}],
};
});
const pieOptions = computed((): ChartOptions<"doughnut"> => ({
responsive: true,
maintainAspectRatio: false,
cutout: "62%",
plugins: {
legend: {
position: "bottom",
labels: { color: ink.value, boxWidth: 10, padding: 12, font: { size: 11 } },
},
},
}));
const barOptions = computed((): ChartOptions<"bar"> => ({
responsive: true,
maintainAspectRatio: false,
indexAxis: "y",
plugins: { legend: { display: false } },
scales: {
x: {
beginAtZero: true,
ticks: { color: ink.value, precision: 0 },
grid: { color: grid.value },
},
y: {
ticks: { color: ink.value },
grid: { display: false },
},
},
}));
const moneyBarOptions = computed((): ChartOptions<"bar"> => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => money(Number(ctx.parsed.y ?? 0)),
},
},
},
scales: {
x: {
ticks: { color: ink.value },
grid: { display: false },
},
y: {
beginAtZero: true,
ticks: {
color: ink.value,
callback: (value) => money(Number(value)),
},
grid: { color: grid.value },
},
},
}));
function buildHomeAlerts(input: {
workers: Worker[];
projects: ProjectRow[];
jobs: JobRow[];
weeks: WeekRow[];
imssReady: number;
}): DeskStatusMessage[] {
const active = input.workers.filter((w) => w.status === "activo" && w.pipeline_status !== "baja");
const sinImssObra = active.filter((w) => w.in_project_without_imss).length;
const incompletos = active.filter((w) => w.pipeline_status === "incompleto" || Number(w.missing_docs) > 0).length;
const freshness = active.filter((w) => w.freshness_required).length;
const sinAsignar = active.filter((w) => !hasProject(w)).length;
const listos = active.filter((w) => w.pipeline_status === "listo_gafete").length;
const sinImss = active.filter((w) => w.imss_status !== "alta").length;
const prestamos = active.filter((w) => Number(w.loan_balance) > 0).length;
const riesgoAlto = active.filter((w) => riskLevel(w.risk_code) === "alto").length;
const proyDocs = input.projects.filter((p) => Number(p.missing_docs) > 0).length;
const proyPausados = input.projects.filter((p) => p.status === "pausado").length;
const jobsPendientes = input.jobs.filter((j) => !j.delivered).length;
const periodosAbiertos = input.weeks.filter((p) => p.status !== "paid").length;
const messages: DeskStatusMessage[] = [];
if (sinImssObra > 0) {
messages.push({
key: "home_imss_obra",
severity: "danger",
title: `${sinImssObra} en obra sin alta IMSS`,
detail: "Personal en proyecto sin movimiento de alta. Prioridad operativa.",
route: "/padron",
filter: "in_project_without_imss",
});
}
if (incompletos > 0) {
messages.push({
key: "home_expediente",
severity: "warn",
title: `${incompletos} con expediente incompleto`,
detail: "Documentos obligatorios faltantes o inválidos en el padrón.",
route: "/padron",
filter: "incomplete",
});
}
if (freshness > 0) {
messages.push({
key: "home_frescos",
severity: "warn",
title: `${freshness} con documentos por refrescar`,
detail: "Reingreso o alta pendiente: constancias subidas hace más de 3 meses.",
route: "/padron",
filter: "freshness",
});
}
if (proyDocs > 0) {
messages.push({
key: "home_proy_docs",
severity: "warn",
title: `${proyDocs} obras con expediente pendiente`,
detail: "Proyectos activos o pausados sin documentos de obra completos.",
route: "/proyectos",
treeKey: "activo",
});
}
if (riesgoAlto > 0) {
messages.push({
key: "home_riesgo",
severity: "warn",
title: `${riesgoAlto} con riesgo alto`,
detail: "Personal con barra de gafete de riesgo alto.",
route: "/kanban",
});
}
if (listos > 0) {
messages.push({
key: "home_gafete",
severity: "info",
title: `${listos} listos para gafete`,
detail: "Pueden pasar a impresión en el módulo de gafetes.",
route: "/gafetes",
});
}
if (input.imssReady > 0) {
messages.push({
key: "home_imss_ready",
severity: "info",
title: `${input.imssReady} listos para alta IMSS`,
detail: "Expediente completo; falta registrar el alta con el patrón.",
route: "/padron",
filter: "imss_ready",
});
}
if (sinAsignar > 0) {
messages.push({
key: "home_sin_asignar",
severity: "info",
title: `${sinAsignar} sin asignar a obra`,
detail: "Activos en padrón sin proyecto. Revisa kanban o padrón.",
route: "/padron",
filter: "unassigned",
treeKey: "unassigned",
});
}
if (sinImss > 0 && sinImssObra === 0) {
messages.push({
key: "home_sin_imss",
severity: "info",
title: `${sinImss} sin alta IMSS`,
detail: "Activos que aún no tienen patrón / alta registrada.",
route: "/padron",
filter: "without_imss",
});
}
if (jobsPendientes > 0) {
messages.push({
key: "home_jobs",
severity: "info",
title: `${jobsPendientes} gafetes PDF por entregar`,
detail: "Trabajos generados pendientes de marcar entrega en obra.",
route: "/gafetes",
});
}
if (proyPausados > 0) {
messages.push({
key: "home_pausados",
severity: "info",
title: `${proyPausados} proyectos pausados`,
detail: "Obras en pausa; confirma si reactivan o concluyen.",
route: "/proyectos",
treeKey: "pausado",
});
}
if (prestamos > 0) {
messages.push({
key: "home_prestamos",
severity: "info",
title: `${prestamos} con préstamo activo`,
detail: "Hay saldos por descontar; revisa ficha o nómina.",
route: "/kanban",
});
}
if (periodosAbiertos > 0) {
messages.push({
key: "home_nomina",
severity: "info",
title: `${periodosAbiertos} semanas de nómina abiertas`,
detail: "Hay una semana sin pagar. Ármala el jueves y págala el sábado.",
route: "/nomina",
});
}
return messages;
}
function mondayOf(d: Date) {
const iso = d.toLocaleDateString("en-CA", { timeZone: "America/Cancun" });
const [y, m, day] = iso.split("-").map(Number);
const utc = new Date(Date.UTC(y, m - 1, day));
const mon0 = (utc.getUTCDay() + 6) % 7;
utc.setUTCDate(utc.getUTCDate() - mon0);
return utc.toISOString().slice(0, 10);
}
onMounted(async () => {
desk.configure({
tab: "Inicio",
treeTitle: "Inicio",
showTree: false,
showDetail: false,
ribbon: [
{
key: "acciones",
label: "Acciones",
items: [
{ key: "alta", label: "Alta", icon: "pi pi-plus", command: () => navigateTo("/padron?nuevo=1") },
{ key: "print", label: "Gafetes", icon: "pi pi-print", command: () => navigateTo("/gafetes") },
],
},
],
});
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(() => {
desk.clearStatusMessages();
});
</script>