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

327 lines
9.8 KiB
Vue

<template>
<div class="kanban-page">
<Toolbar>
<template #start>
<Chip :label="`${activos.length} activos`" icon="pi pi-users" />
<Chip :label="`${unassigned.length} sin asignar`" icon="pi pi-exclamation-triangle" />
<Chip :label="`${faltantes.length} sin expediente`" icon="pi pi-folder" />
<Chip :label="`${conPrestamo.length} con préstamo`" icon="pi pi-wallet" />
<Chip :label="`${altos.length} riesgo alto`" icon="pi pi-exclamation-circle" />
</template>
<template #end>
<Button icon="pi pi-refresh" text v-tooltip.top="'Actualizar'" @click="load" />
</template>
</Toolbar>
<div class="kanban">
<Panel v-for="col in cols" :key="col.key" class="kanban-col">
<template #header>
<div v-if="col.id === 0" class="flex align-items-center gap-2">
<span>Sin asignar</span>
<Badge :value="peopleOf(col).length" severity="warn" />
</div>
<div v-else>
<div class="flex align-items-center gap-2">
<Tag :value="col.code" />
<Badge :value="peopleOf(col).length" />
</div>
<div class="data-text" style="margin-top:4px">{{ col.name }}</div>
</div>
</template>
<Card
v-for="w in peopleOf(col)"
:key="col.key + '-' + w.id"
class="kanban-card"
@click="openWorker(w)"
@contextmenu.prevent="openCtx($event, w)"
>
<template #title>
<div class="person-cell">
<Avatar :label="initials(w.first_name, w.last_name_p)" size="small" shape="circle" />
<div>
<div class="data-text">{{ w.first_name }} {{ w.last_name_p }}</div>
<div class="muted data-text">{{ w.position }}</div>
</div>
</div>
</template>
<template #content>
<div class="kanban-flags">
<Tag :value="riskLevelLabel(String(w.risk_code))" :severity="riskLevelSeverity(String(w.risk_code))" />
<Tag
v-if="Number(w.missing_docs) > 0 || w.pipeline_status === 'incompleto'"
:value="missingLabel(w)"
severity="danger"
/>
<Tag
v-if="w.pipeline_status === 'listo_gafete'"
:value="gafeteLabel(w.pipeline_status)"
:severity="gafeteSeverity(w.pipeline_status)"
/>
<Tag v-if="Number(w.loan_balance) > 0" :value="loanLabel(w)" severity="info" />
<Tag :value="String(w.hire_type)" :severity="hireSeverity(String(w.hire_type))" />
<Tag v-if="w.work_type === 'D'" value="Destajo" severity="warn" />
</div>
</template>
</Card>
<Message v-if="!peopleOf(col).length" severity="secondary" :closable="false" class="kanban-empty">
{{ col.id === 0 ? "Nadie sin proyecto" : "Sin personal" }}
</Message>
</Panel>
</div>
<ContextMenu ref="cm" :model="ctxItems" />
</div>
</template>
<script setup lang="ts">
type W = {
id: number;
first_name: string;
last_name_p: string;
position: string;
risk_code: string;
hire_type: string;
work_type: string;
pipeline_status: string;
project_ids?: string | null;
status: string;
missing_docs?: number;
loan_balance?: number;
};
type P = { id: number; code: string; name: string; address?: string; status: string };
type Col = { key: string; id: number; code?: string; name?: string; address?: string };
const { initials, riskLevel, riskLevelLabel, riskLevelSeverity, hireSeverity, gafeteLabel, gafeteSeverity } = useUi();
const desk = useDesktop();
const { api } = useApi();
const workers = ref<W[]>([]);
const projects = ref<P[]>([]);
const selected = ref<W | null>(null);
const cm = ref();
const ctxItems = computed(() => [
{ label: "Abrir expediente", icon: "pi pi-folder-open", command: () => selected.value && openWorker(selected.value) },
]);
const activos = computed(() =>
workers.value.filter((w) => w.status === "activo" && w.pipeline_status !== "baja"),
);
const unassigned = computed(() => activos.value.filter((w) => !projectIds(w).length));
const faltantes = computed(() =>
activos.value.filter((w) => Number(w.missing_docs) > 0 || w.pipeline_status === "incompleto"),
);
const conPrestamo = computed(() => activos.value.filter((w) => Number(w.loan_balance) > 0));
const altos = computed(() => activos.value.filter((w) => riskLevel(String(w.risk_code)) === "alto"));
const cols = computed<Col[]>(() => {
const all: Col[] = [
{ key: "none", id: 0, name: "Sin asignar" },
...projects.value.map((p) => ({ key: `p${p.id}`, id: p.id, code: p.code, name: p.name, address: p.address })),
];
const key = desk.treeKey.value || "all";
if (key.startsWith("p:")) {
const id = Number(key.slice(2));
return all.filter((c) => c.id === 0 || c.id === id);
}
return all;
});
function projectIds(w: W): number[] {
return String(w.project_ids || "")
.split(",")
.map((x) => Number(x.trim()))
.filter((n) => n > 0);
}
function peopleOf(col: Col) {
if (col.id === 0) return unassigned.value;
return activos.value.filter((w) => projectIds(w).includes(col.id));
}
function missingLabel(w: W) {
const n = Number(w.missing_docs) || 0;
return n > 1 ? `Faltan ${n} docs` : "Falta expediente";
}
function loanLabel(w: W) {
const n = Number(w.loan_balance) || 0;
return `Préstamo ${n.toLocaleString("es-MX", { style: "currency", currency: "MXN", maximumFractionDigits: 0 })}`;
}
function openWorker(w: W) {
desk.openWorker(w.id);
navigateTo("/padron");
}
function openCtx(e: MouseEvent, w: W) {
selected.value = w;
cm.value.show(e);
}
async function load() {
if (!desk.treeKey.value || (desk.treeKey.value !== "all" && !desk.treeKey.value.startsWith("p:"))) {
desk.treeKey.value = "all";
}
const [w, p] = await Promise.all([
api<{ workers: W[] }>("/v1/workers?status=activo"),
api<{ projects: P[] }>("/v1/projects?status=activo,pausado"),
]);
workers.value = w.workers.filter((row) => row.status === "activo" && row.pipeline_status !== "baja");
projects.value = p.projects;
syncChrome();
}
function syncChrome() {
const messages = [];
if (faltantes.value.length > 0) {
messages.push({
key: "kan_faltantes",
severity: "warn" as const,
title: `${faltantes.value.length} con expediente incompleto`,
detail: "Personas activas sin documentos completos en el tablero.",
route: "/kanban",
});
}
if (unassigned.value.length > 0) {
messages.push({
key: "kan_sin_asignar",
severity: "info" as const,
title: `${unassigned.value.length} sin asignar a obra`,
detail: "Arrástralos a un proyecto o asígnalos desde el padrón.",
route: "/kanban",
treeKey: "all",
});
}
if (conPrestamo.value.length > 0) {
messages.push({
key: "kan_prestamo",
severity: "info" as const,
title: `${conPrestamo.value.length} con préstamo activo`,
detail: "Revisa saldos en ficha o nómina antes de baja/cambio.",
route: "/kanban",
});
}
if (altos.value.length > 0) {
messages.push({
key: "kan_riesgo",
severity: "warn" as const,
title: `${altos.value.length} con riesgo alto`,
detail: "Personal con barra de gafete de riesgo alto.",
route: "/kanban",
});
}
desk.setStatusMessages(messages);
desk.configure({
tab: "Kanban",
treeTitle: "Proyectos",
showTree: true,
showDetail: false,
treeKey: desk.treeKey.value || "all",
countLabel: `${activos.value.length} activos`,
tree: [
{ key: "all", label: "Todos", icon: "pi pi-th-large", data: { count: activos.value.length } },
...projects.value.map((p) => ({
key: `p:${p.id}`,
label: p.name,
icon: "pi pi-folder",
data: { count: activos.value.filter((w) => projectIds(w).includes(p.id)).length },
})),
],
ribbon: [
{
key: "vista",
label: "Vista",
items: [
{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", command: load },
{ key: "padron", label: "Padrón", icon: "pi pi-users", command: () => navigateTo("/padron") },
],
},
],
});
}
onMounted(load);
onBeforeUnmount(() => desk.clearStatusMessages());
watch([workers, projects], syncChrome);
</script>
<style scoped>
.kanban-page {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
gap: 12px;
}
.kanban {
flex: 1;
min-height: 0;
width: 100%;
display: flex;
gap: 10px;
overflow-x: auto;
overflow-y: hidden;
align-items: stretch;
}
.kanban-col {
flex: 1 1 0;
min-width: 340px;
max-height: 100%;
display: flex;
flex-direction: column;
}
.kanban-col :deep(.p-panel-header) {
flex-shrink: 0;
align-items: flex-start;
padding: 8px 10px;
}
.kanban-col :deep(.p-panel-content-container),
.kanban-col :deep(.p-panel-content-wrapper) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.kanban-col :deep(.p-panel-content) {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
display: grid;
grid-template-columns: 1fr 1fr;
align-content: start;
gap: 6px;
padding: 8px;
}
.kanban-empty {
grid-column: 1 / -1;
}
.kanban-card {
cursor: pointer;
min-width: 0;
}
.kanban-card :deep(.p-card-body) {
padding: 6px 8px;
gap: 4px;
}
.kanban-card :deep(.p-card-caption) {
gap: 0;
}
.kanban-card :deep(.p-card-title) {
font-size: 12px;
line-height: 1.2;
}
.kanban-card :deep(.p-card-content) {
padding: 0;
}
.kanban-card .person-cell {
gap: 6px;
min-width: 0;
}
.kanban-card .person-cell > div {
min-width: 0;
overflow: hidden;
}
.kanban-card .muted {
font-size: 10px;
}
.kanban-flags {
display: flex;
flex-wrap: wrap;
gap: 3px;
}
.kanban-flags :deep(.p-tag) {
font-size: 10px;
padding: 1px 5px;
}
</style>