mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 14:53:18 +00:00
323 lines
12 KiB
Vue
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>
|