panels-origin/web-panel/pages/index.vue
2026-08-21 16:55:01 -05:00

323 lines
12 KiB
Vue

<template>
<div>
<div class="kpi-grid">
<Card v-for="s in stats" :key="s.label">
<template #content>
<div class="flex align-items-center gap-2" style="justify-content:space-between">
<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>
<Panel header="Flujo de expediente" class="mt-3" toggleable>
<MeterGroup :value="meter" label-position="start" />
</Panel>
<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>{{ data.last_name_p }} {{ data.first_name }}</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>
<Panel header="Atajos">
<div style="display:flex;flex-direction:column;gap:10px">
<Button label="Padrón completo" icon="pi pi-users" outlined @click="navigateTo('/padron')" />
<Button label="Personal por proyecto" icon="pi pi-th-large" outlined @click="navigateTo('/kanban')" />
<Button label="Nómina de la semana" icon="pi pi-wallet" outlined @click="navigateTo('/nomina')" />
<Message severity="info" :closable="false">Clic derecho en tablas para el menú de expediente, baja y documentos.</Message>
</div>
</Panel>
</div>
</div>
</template>
<script setup lang="ts">
import type { DeskStatusMessage } from "~/composables/useDesktop";
const { initials, riskLevelLabel, riskLevelSeverity, riskLevel } = useUi();
const desk = useDesktop();
type Worker = {
first_name: string;
last_name_p: string;
status: string;
pipeline_status: string;
position: string;
risk_code: string;
risk_color: 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 = {
status: string;
missing_docs?: number;
};
type JobRow = {
delivered?: number | boolean;
};
type PeriodRow = {
status?: string;
};
const { api } = useApi();
const workers = ref<Worker[]>([]);
const stats = ref([
{ label: "Activos", value: "—", icon: "pi pi-users", bg: "#0f2744", hint: "Alta", severity: "success" },
{ label: "Proyectos", value: "—", icon: "pi pi-building", bg: "#1c4a7a", hint: "Proyectos", severity: "info" },
{ label: "Listos gafete", value: "—", icon: "pi pi-id-card", bg: "#c9a227", hint: "Imprimir", severity: "warn" },
{ label: "Jobs PDF", value: "—", icon: "pi pi-file-pdf", bg: "#a20000", hint: "Archivo", severity: "danger" },
]);
const meter = ref<{ label: string; color: string; value: number }[]>([]);
const { tableQ, matches } = useTableSearch();
const listedReady = computed(() =>
workers.value
.filter((w) => w.pipeline_status === "listo_gafete")
.filter((w) => matches(w.first_name, w.last_name_p, w.position, w.risk_code)),
);
function hasProject(worker: Worker) {
return String(worker.project_ids || "")
.split(",")
.map((part) => Number(part.trim()))
.some((id) => id > 0);
}
function buildHomeAlerts(input: {
workers: Worker[];
projects: ProjectRow[];
jobs: JobRow[];
periods: PeriodRow[];
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.periods.filter((p) => p.status !== "closed").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 con 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} periodos de nómina abiertos`,
detail: "Periodos sin cerrar en el módulo de nómina.",
route: "/nomina",
});
}
return messages;
}
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") },
],
},
],
});
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<{ periods: PeriodRow[] }>("/v1/payroll/periods"),
]);
workers.value = w.workers;
const activos = w.workers.filter((x) => x.status === "activo" && x.pipeline_status !== "baja").length;
const listos = w.workers.filter((x) => x.pipeline_status === "listo_gafete").length;
const activosProyectos = p.projects.filter((x) => x.status === "activo").length;
stats.value = [
{ label: "Activos", value: String(activos), icon: "pi pi-users", bg: "#0f2744", hint: "Alta", severity: "success" },
{ label: "Proyectos", value: String(activosProyectos), icon: "pi pi-building", bg: "#1c4a7a", hint: "Proyectos", severity: "info" },
{ label: "Listos gafete", value: String(listos), icon: "pi pi-id-card", bg: "#c9a227", hint: "Imprimir", severity: "warn" },
{ label: "Jobs PDF", value: String(j.jobs.length), icon: "pi pi-file-pdf", bg: "#a20000", hint: "Archivo", severity: "danger" },
];
desk.setStatusMessages(
buildHomeAlerts({
workers: w.workers,
projects: p.projects,
jobs: j.jobs,
periods: pe.periods,
imssReady: w.imss_stats?.imss_ready_count || 0,
}),
);
const counts: Record<string, number> = {};
for (const row of w.workers) counts[row.pipeline_status] = (counts[row.pipeline_status] || 0) + 1;
const total = w.workers.length || 1;
meter.value = [
{ label: "Incompleto", color: "#6b7785", value: Math.round(((counts.incompleto || 0) / total) * 100) },
{ label: "Listo", color: "#1c4a7a", value: Math.round(((counts.listo_gafete || 0) / total) * 100) },
{ label: "Impreso", color: "#c9a227", value: Math.round(((counts.impreso || 0) / total) * 100) },
{ label: "Activo", color: "#008514", value: Math.round(((counts.activo || 0) / total) * 100) },
{ label: "Baja", color: "#a20000", value: Math.round(((counts.baja || 0) / total) * 100) },
];
});
onBeforeUnmount(() => {
desk.clearStatusMessages();
});
</script>