mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 22:43:17 +00:00
327 lines
9.8 KiB
Vue
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>
|