mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 19:43:18 +00:00
464 lines
17 KiB
Vue
464 lines
17 KiB
Vue
<template>
|
|
<div class="padron-page">
|
|
<DeskTableBar v-model="tableQ" placeholder="Buscar persona, CURP, NSS o proyecto">
|
|
<Chip
|
|
:label="`${activeCount} activos`"
|
|
icon="pi pi-users"
|
|
:class="{ 'chip-filter-active': imssFilter === 'all' }"
|
|
@click="imssFilter = 'all'"
|
|
/>
|
|
<Chip
|
|
:label="`${unassignedCount} sin asignar`"
|
|
icon="pi pi-building"
|
|
:class="{ 'chip-filter-active': imssFilter === 'unassigned' }"
|
|
@click="imssFilter = imssFilter === 'unassigned' ? 'all' : 'unassigned'"
|
|
/>
|
|
<Chip
|
|
:label="`${incompleteCount} con expediente pendiente`"
|
|
icon="pi pi-folder-open"
|
|
:class="{ 'chip-filter-active': imssFilter === 'incomplete' }"
|
|
@click="imssFilter = imssFilter === 'incomplete' ? 'all' : 'incomplete'"
|
|
/>
|
|
<Chip
|
|
:label="`${readyCount} listos para gafete`"
|
|
icon="pi pi-id-card"
|
|
:class="{ 'chip-filter-active': imssFilter === 'ready_gafete' }"
|
|
@click="imssFilter = imssFilter === 'ready_gafete' ? 'all' : 'ready_gafete'"
|
|
/>
|
|
<Chip
|
|
v-if="freshnessCount > 0"
|
|
:label="`${freshnessCount} docs por refrescar`"
|
|
icon="pi pi-history"
|
|
:class="{ 'chip-filter-active': imssFilter === 'freshness' }"
|
|
@click="imssFilter = imssFilter === 'freshness' ? 'all' : 'freshness'"
|
|
/>
|
|
<Chip
|
|
:label="`${imssStats.with_imss} con IMSS`"
|
|
icon="pi pi-verified"
|
|
:class="{ 'chip-filter-active': imssFilter === 'with_imss' }"
|
|
@click="imssFilter = imssFilter === 'with_imss' ? 'all' : 'with_imss'"
|
|
/>
|
|
<Chip
|
|
:label="`${imssStats.without_imss} sin IMSS`"
|
|
icon="pi pi-exclamation-circle"
|
|
:class="{ 'chip-filter-active': imssFilter === 'without_imss' }"
|
|
@click="imssFilter = imssFilter === 'without_imss' ? 'all' : 'without_imss'"
|
|
/>
|
|
<Chip
|
|
:label="`${imssStats.in_project_without_imss} en obra sin IMSS`"
|
|
icon="pi pi-bolt"
|
|
:class="{ 'chip-filter-active': imssFilter === 'in_project_without_imss' }"
|
|
@click="imssFilter = imssFilter === 'in_project_without_imss' ? 'all' : 'in_project_without_imss'"
|
|
/>
|
|
<Chip
|
|
:label="`${imssStats.imss_ready_count} listos para alta`"
|
|
icon="pi pi-send"
|
|
:class="{ 'chip-filter-active': imssFilter === 'imss_ready' }"
|
|
@click="imssFilter = imssFilter === 'imss_ready' ? 'all' : 'imss_ready'"
|
|
/>
|
|
<ProgressSpinner v-if="loading" stroke-width="6" style="width: 22px; height: 22px" />
|
|
</DeskTableBar>
|
|
|
|
<PadronTable
|
|
v-model="selected"
|
|
:workers="visible"
|
|
:query="tableQ"
|
|
:projects="projects"
|
|
@open="openWorker"
|
|
@edit="editWorker"
|
|
@baja="baja"
|
|
@reactivate="reactivate"
|
|
/>
|
|
|
|
<WorkerFormDialog
|
|
v-model:visible="showForm"
|
|
:worker="editing"
|
|
:projects="projects"
|
|
:risks="risks"
|
|
:companies="companies"
|
|
:saving="saving"
|
|
:error="formError"
|
|
@save="save"
|
|
/>
|
|
<WorkerImportDialog v-model:visible="showImport" :projects="projects" @imported="loadWorkers" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import PadronTable from "~/components/padron/PadronTable.vue";
|
|
import WorkerFormDialog from "~/components/padron/WorkerFormDialog.vue";
|
|
import WorkerImportDialog from "~/components/padron/WorkerImportDialog.vue";
|
|
import type { CompanyOption, ImssStats, ProjectOption, RiskOption, Worker, WorkerForm } from "~/types/padron";
|
|
import { projectIdsOf, workerFullName } from "~/types/padron";
|
|
|
|
const { riskLevel } = useUi();
|
|
const desk = useDesktop();
|
|
const { api } = useApi();
|
|
const toast = useToast();
|
|
const confirm = useConfirm();
|
|
|
|
const workers = ref<Worker[]>([]);
|
|
const imssStats = ref<ImssStats>({
|
|
with_imss: 0,
|
|
without_imss: 0,
|
|
in_project_without_imss: 0,
|
|
imss_ready_count: 0,
|
|
});
|
|
const imssFilter = computed({
|
|
get: () => desk.padronImssFilter.value,
|
|
set: (value) => {
|
|
desk.padronImssFilter.value = value;
|
|
},
|
|
});
|
|
const projects = ref<ProjectOption[]>([]);
|
|
const risks = ref<RiskOption[]>([]);
|
|
const companies = ref<CompanyOption[]>([]);
|
|
const selected = ref<Worker | null>(null);
|
|
const tableQ = ref("");
|
|
const editing = ref<Worker | null>(null);
|
|
const showForm = ref(false);
|
|
const showImport = ref(false);
|
|
const saving = ref(false);
|
|
const loading = ref(false);
|
|
const formError = ref("");
|
|
|
|
const treeFiltered = computed(() => {
|
|
const key = desk.treeKey.value || "all";
|
|
if (key === "unassigned") return workers.value.filter((worker) => worker.status === "activo" && !projectIdsOf(worker).length);
|
|
if (key === "bajas") return workers.value.filter((worker) => worker.status === "baja" || worker.pipeline_status === "baja");
|
|
if (key.startsWith("p:")) {
|
|
const id = Number(key.slice(2));
|
|
return workers.value.filter((worker) => worker.status === "activo" && projectIdsOf(worker).includes(id));
|
|
}
|
|
return workers.value;
|
|
});
|
|
const visible = computed(() => {
|
|
const rows = treeFiltered.value;
|
|
switch (imssFilter.value) {
|
|
case "with_imss":
|
|
return rows.filter((w) => w.imss_status === "alta");
|
|
case "without_imss":
|
|
return rows.filter((w) => w.status === "activo" && w.imss_status !== "alta");
|
|
case "in_project_without_imss":
|
|
return rows.filter((w) => w.in_project_without_imss);
|
|
case "imss_ready":
|
|
return rows.filter((w) => w.imss_ready);
|
|
case "incomplete":
|
|
return rows.filter((w) => w.status === "activo" && w.pipeline_status === "incompleto");
|
|
case "ready_gafete":
|
|
return rows.filter((w) => w.pipeline_status === "listo_gafete");
|
|
case "unassigned":
|
|
return rows.filter((w) => w.status === "activo" && !projectIdsOf(w).length);
|
|
case "freshness":
|
|
return rows.filter((w) => w.freshness_required);
|
|
default:
|
|
return rows;
|
|
}
|
|
});
|
|
const activeWorkers = computed(() => workers.value.filter((worker) => worker.status === "activo" && worker.pipeline_status !== "baja"));
|
|
const activeCount = computed(() => activeWorkers.value.length);
|
|
const unassignedCount = computed(() => activeWorkers.value.filter((worker) => !projectIdsOf(worker).length).length);
|
|
const incompleteCount = computed(() => activeWorkers.value.filter((worker) => worker.pipeline_status === "incompleto").length);
|
|
const readyCount = computed(() => activeWorkers.value.filter((worker) => worker.pipeline_status === "listo_gafete").length);
|
|
const freshnessCount = computed(() => activeWorkers.value.filter((worker) => worker.freshness_required).length);
|
|
|
|
function rowOf(worker: Worker): Worker {
|
|
const pipeline = worker.pipeline_status;
|
|
return {
|
|
...worker,
|
|
full_name: workerFullName(worker),
|
|
risk_level: riskLevel(worker.risk_code),
|
|
gafete_key: pipeline === "incompleto" ? "sin" : pipeline === "listo_gafete" ? "por_imprimir" : ["impreso", "activo"].includes(pipeline) ? "impreso" : "na",
|
|
};
|
|
}
|
|
|
|
async function loadWorkers(preferredId?: number) {
|
|
loading.value = true;
|
|
try {
|
|
const response = await api<{ workers: Worker[]; imss_stats?: ImssStats }>("/v1/workers");
|
|
workers.value = response.workers.map(rowOf);
|
|
imssStats.value = response.imss_stats || {
|
|
with_imss: activeWorkers.value.filter((w) => w.imss_status === "alta").length,
|
|
without_imss: 0,
|
|
in_project_without_imss: 0,
|
|
imss_ready_count: 0,
|
|
};
|
|
syncStatusMessages();
|
|
const id = preferredId || selected.value?.id;
|
|
selected.value = id ? workers.value.find((worker) => worker.id === id) || null : null;
|
|
if (selected.value) desk.openWorker(selected.value.id);
|
|
syncChrome();
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function loadCatalogs() {
|
|
const [projectResponse, catalogs] = await Promise.all([
|
|
api<{ projects: ProjectOption[] }>("/v1/projects?status=activo"),
|
|
api<{ risks: RiskOption[]; companies: CompanyOption[] }>("/v1/catalogs"),
|
|
]);
|
|
projects.value = projectResponse.projects;
|
|
risks.value = catalogs.risks;
|
|
companies.value = catalogs.companies || [];
|
|
}
|
|
|
|
function openNew() {
|
|
editing.value = null;
|
|
formError.value = "";
|
|
showForm.value = true;
|
|
}
|
|
function editWorker(worker: Worker) {
|
|
editing.value = worker;
|
|
selected.value = worker;
|
|
formError.value = "";
|
|
showForm.value = true;
|
|
}
|
|
function openWorker(worker: Worker) {
|
|
selected.value = worker;
|
|
desk.openWorker(worker.id);
|
|
}
|
|
function openLoan() {
|
|
if (!selected.value) return;
|
|
desk.openWorker(selected.value.id, undefined, "nomina");
|
|
desk.openLoan(selected.value.id, workerFullName(selected.value));
|
|
}
|
|
async function save(form: WorkerForm) {
|
|
saving.value = true;
|
|
formError.value = "";
|
|
try {
|
|
let id = form.id;
|
|
if (id) {
|
|
await api(`/v1/workers/${id}`, { method: "PATCH", json: form });
|
|
} else {
|
|
const response = await api<{ id: number }>("/v1/workers", { method: "POST", json: form });
|
|
id = response.id;
|
|
}
|
|
showForm.value = false;
|
|
toast.add({ severity: "success", summary: id === form.id ? "Datos actualizados" : "Persona registrada", life: 2200 });
|
|
await loadWorkers(id);
|
|
desk.bumpDetail();
|
|
} catch (error) {
|
|
const detail = error as { data?: { error?: string; errors?: Record<string, string> } };
|
|
formError.value = detail.data?.error || (error instanceof Error ? error.message : "Revise los campos");
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
function baja(worker: Worker) {
|
|
confirm.require({
|
|
header: "Dar de baja",
|
|
message: `¿Dar de baja a ${worker.first_name} ${worker.last_name_p}? El registro permanecerá en el historial.`,
|
|
icon: "pi pi-exclamation-triangle",
|
|
acceptLabel: "Dar de baja",
|
|
rejectLabel: "Cancelar",
|
|
acceptProps: { severity: "danger" },
|
|
accept: async () => {
|
|
await api(`/v1/workers/${worker.id}/pipeline`, { method: "PATCH", json: { pipeline_status: "baja" } });
|
|
toast.add({ severity: "success", summary: "Baja registrada", life: 2200 });
|
|
await loadWorkers();
|
|
},
|
|
});
|
|
}
|
|
|
|
function reactivate(worker: Worker) {
|
|
confirm.require({
|
|
header: "Reactivar persona",
|
|
message: `¿Reactivar a ${worker.first_name} ${worker.last_name_p}? Se pedirán documentos frescos (máx. 3 meses) para una nueva alta IMSS.`,
|
|
icon: "pi pi-user-plus",
|
|
acceptLabel: "Reactivar",
|
|
rejectLabel: "Cancelar",
|
|
accept: async () => {
|
|
await api(`/v1/workers/${worker.id}/pipeline`, { method: "PATCH", json: { pipeline_status: "activo" } });
|
|
toast.add({
|
|
severity: "info",
|
|
summary: "Reingreso",
|
|
detail: "Actualiza documentos IMSS (máx. 3 meses) antes del alta.",
|
|
life: 4000,
|
|
});
|
|
await loadWorkers(worker.id);
|
|
desk.bumpDetail();
|
|
},
|
|
});
|
|
}
|
|
|
|
function syncStatusMessages() {
|
|
const messages = [];
|
|
if (imssStats.value.in_project_without_imss > 0) {
|
|
messages.push({
|
|
key: "imss_obra",
|
|
severity: "danger" as const,
|
|
title: `${imssStats.value.in_project_without_imss} en obra sin alta IMSS`,
|
|
detail: "Personal asignado a proyecto sin registro de alta. Atiéndelos lo antes posible.",
|
|
route: "/padron",
|
|
filter: "in_project_without_imss",
|
|
});
|
|
}
|
|
if (incompleteCount.value > 0) {
|
|
messages.push({
|
|
key: "expediente_pendiente",
|
|
severity: "warn" as const,
|
|
title: `${incompleteCount.value} con expediente incompleto`,
|
|
detail: "Faltan documentos obligatorios o están vencidos/desactualizados.",
|
|
route: "/padron",
|
|
filter: "incomplete",
|
|
});
|
|
}
|
|
if (freshnessCount.value > 0) {
|
|
messages.push({
|
|
key: "docs_frescos",
|
|
severity: "warn" as const,
|
|
title: `${freshnessCount.value} con documentos por refrescar`,
|
|
detail: "Reingreso o pendiente de alta: CURP, NSS, RFC o domicilio con más de 3 meses.",
|
|
route: "/padron",
|
|
filter: "freshness",
|
|
});
|
|
}
|
|
if (readyCount.value > 0) {
|
|
messages.push({
|
|
key: "listo_gafete",
|
|
severity: "info" as const,
|
|
title: `${readyCount.value} listos para gafete`,
|
|
detail: "Expediente en estado listo; pueden pasar a impresión.",
|
|
route: "/padron",
|
|
filter: "ready_gafete",
|
|
});
|
|
}
|
|
if (imssStats.value.imss_ready_count > 0) {
|
|
messages.push({
|
|
key: "imss_ready",
|
|
severity: "info" as const,
|
|
title: `${imssStats.value.imss_ready_count} listos para alta IMSS`,
|
|
detail: "Expediente completo pendiente de registrar el alta con el patrón.",
|
|
route: "/padron",
|
|
filter: "imss_ready",
|
|
});
|
|
}
|
|
if (unassignedCount.value > 0) {
|
|
messages.push({
|
|
key: "sin_asignar",
|
|
severity: "info" as const,
|
|
title: `${unassignedCount.value} sin asignar a obra`,
|
|
detail: "Activos en padrón sin proyecto. Asígnalos o déjalos en reserva.",
|
|
route: "/padron",
|
|
filter: "unassigned",
|
|
treeKey: "unassigned",
|
|
});
|
|
}
|
|
if (imssStats.value.without_imss > 0 && imssStats.value.in_project_without_imss === 0) {
|
|
messages.push({
|
|
key: "sin_imss",
|
|
severity: "info" as const,
|
|
title: `${imssStats.value.without_imss} sin alta IMSS`,
|
|
detail: "Activos que aún no tienen movimiento de alta registrado.",
|
|
route: "/padron",
|
|
filter: "without_imss",
|
|
});
|
|
}
|
|
desk.setStatusMessages(messages);
|
|
}
|
|
|
|
function syncChrome() {
|
|
syncStatusMessages();
|
|
const bajas = workers.value.filter((worker) => worker.status === "baja" || worker.pipeline_status === "baja");
|
|
desk.configure({
|
|
tab: "Padrón",
|
|
treeTitle: "Padrón",
|
|
showTree: true,
|
|
showDetail: true,
|
|
treeKey: desk.treeKey.value || "all",
|
|
countLabel: `Total de registros: ${visible.value.length}`,
|
|
tree: [
|
|
{ key: "all", label: "Todos", icon: "pi pi-users", data: { count: workers.value.length } },
|
|
{
|
|
key: "projects",
|
|
label: "Proyectos activos",
|
|
icon: "pi pi-building",
|
|
selectable: false,
|
|
children: projects.value.map((project) => ({
|
|
key: `p:${project.id}`,
|
|
label: project.name,
|
|
icon: "pi pi-folder",
|
|
data: { count: activeWorkers.value.filter((worker) => projectIdsOf(worker).includes(project.id)).length },
|
|
})),
|
|
},
|
|
{ key: "unassigned", label: "Sin asignar", icon: "pi pi-inbox", data: { count: unassignedCount.value } },
|
|
{ key: "bajas", label: "Bajas", icon: "pi pi-history", data: { count: bajas.length } },
|
|
],
|
|
ribbon: [
|
|
{
|
|
key: "acciones",
|
|
label: "Acciones",
|
|
items: [
|
|
{ key: "alta", label: "Nuevo", icon: "pi pi-plus", command: openNew },
|
|
{ key: "edit", label: "Editar", icon: "pi pi-pencil", disabled: !selected.value, command: () => selected.value && editWorker(selected.value) },
|
|
{ key: "loan", label: "Préstamo", icon: "pi pi-wallet", disabled: !selected.value || selected.value.status === "baja", command: openLoan },
|
|
{ key: "baja", label: "Baja", icon: "pi pi-user-minus", disabled: !selected.value || selected.value.status === "baja", command: () => selected.value && baja(selected.value) },
|
|
{
|
|
key: "reactivate",
|
|
label: "Reactivar",
|
|
icon: "pi pi-user-plus",
|
|
disabled: !selected.value || selected.value.status !== "baja",
|
|
command: () => selected.value && reactivate(selected.value),
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: "datos",
|
|
label: "Datos",
|
|
items: [
|
|
{ key: "import", label: "Importar", icon: "pi pi-upload", command: () => { showImport.value = true; } },
|
|
],
|
|
},
|
|
{
|
|
key: "expediente",
|
|
label: "Expediente",
|
|
items: [
|
|
{ key: "docs", label: "Documentos", icon: "pi pi-folder-open", disabled: !selected.value, command: () => selected.value && desk.openWorker(selected.value.id, "ine") },
|
|
{ key: "print", label: "Gafetes", icon: "pi pi-id-card", command: () => navigateTo("/gafetes") },
|
|
],
|
|
},
|
|
{
|
|
key: "vista",
|
|
label: "Vista",
|
|
items: [{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", command: () => loadWorkers() }],
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
onMounted(async () => {
|
|
if (!desk.treeKey.value || (!["all", "unassigned", "bajas"].includes(desk.treeKey.value) && !desk.treeKey.value.startsWith("p:"))) desk.treeKey.value = "all";
|
|
await loadCatalogs();
|
|
await loadWorkers();
|
|
const route = useRoute();
|
|
if (route.query.nuevo) openNew();
|
|
if (route.query.import) showImport.value = true;
|
|
const queryWorkerId = Number(route.query.worker || 0);
|
|
if (queryWorkerId) {
|
|
const worker = workers.value.find((item) => item.id === queryWorkerId);
|
|
if (worker) {
|
|
selected.value = worker;
|
|
desk.openWorker(worker.id, undefined, route.query.prestamo ? "nomina" : undefined);
|
|
}
|
|
} else if (desk.detail.value.kind === "worker" && desk.detail.value.id) {
|
|
const worker = workers.value.find((item) => item.id === desk.detail.value.id);
|
|
if (worker) selected.value = worker;
|
|
}
|
|
if (route.query.prestamo && selected.value) {
|
|
desk.openLoan(selected.value.id, workerFullName(selected.value));
|
|
}
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
desk.clearStatusMessages();
|
|
});
|
|
|
|
watch([workers, projects, selected, visible], syncChrome);
|
|
watch(() => desk.detailRev.value, () => {
|
|
if (desk.detail.value.kind === "worker" && desk.detail.value.id) {
|
|
loadWorkers(desk.detail.value.id);
|
|
}
|
|
});
|
|
</script>
|