panels-origin/web-panel/pages/padron/index.vue
Cursor Agent e89b51bf43
fix: subir documentos sin fecha de emisión ni vencimiento
La vigencia de 3 meses para alta o reingreso cuenta desde la fecha de
carga. Quien recibe el expediente revisa si el documento es válido.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-08 20:47:16 +00:00

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 subidos en los últimos 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: "Vuelve a subir CURP, NSS, RFC y domicilio (máx. 3 meses desde la carga) 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 subidos hace 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>