panels-origin/web-panel/components/WorkerFicha.vue
Alberto Martinez 90bcd48f95 Facturas, nómina, dispersión e IMSS (#28)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Resumen

Implementa las observaciones de facturas, nómina e IMSS sobre el catálogo de proveedores y la nómina semanal que ya existían.

## Qué queda disponible

- Proveedores: régimen fiscal, código postal, estatus activo/inactivo y filtro por nombre o RFC.
- Facturas recibidas y emitidas: Excel, UUID, revisión si el RFC no está en el catálogo, clasificación a obra/concepto y gasto `source = invoice` solo para la recibida clasificada.
- Nómina: bono, descuento con observación, horas y día festivo dentro del armado semanal. La semana pagada no acepta cambios.
- Préstamos: edición (libre sin pagos; con pagos solo nota y cuota) y estado de cuenta con saldo = entregado + comisión − pagos.
- Finiquitos y liquidaciones: conceptos, neto descontando descuentos vigentes y saldo de préstamo, y gasto al pagar.
- Dispersión: corte semanal, corrección antes de definitiva y comprobante.
- Historial de alta, baja y reingreso en la ficha, más movimientos IMSS.
- Reporte por periodo en Excel y PDF.
- Jornal IMSS distinto del jornal real y consulta de cuotas exportable a Excel.

## Verificación

`deno check` de la API pasó. En este entorno no hay Postgres, así que `api/pendientes_test.ts` queda listo para correr cuando la base tenga las migraciones 042–050 y el permiso IAM 007.

<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a href="https://cursor.com/agents/bc-5e23dc3b-921e-4091-9650-7091d3a52bbf?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-5e23dc3b-921e-4091-9650-7091d3a52bbf&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>
2026-09-25 03:26:30 +00:00

352 lines
15 KiB
Vue

<template>
<div class="ficha worker-detail">
<div v-if="!workerId" class="detail-empty">
<i class="pi pi-file detail-empty-icon" />
<strong>Seleccione un registro</strong>
<span>La ficha aparecerá en este panel.</span>
</div>
<div v-else-if="loading" class="detail-loading"><ProgressSpinner /></div>
<div v-else-if="data" class="ficha-body worker-record">
<div class="worker-record-code">{{ recordCode }}</div>
<div class="worker-photo worker-record-photo">
<img v-if="photoOk" :src="photoSrc" :alt="title" @error="photoOk = false" />
<Avatar v-else :label="initials(data.worker.first_name, data.worker.last_name_p)" shape="circle" size="xlarge" />
</div>
<div class="worker-record-lead">
<div class="worker-detail-name">{{ title }}</div>
<div class="muted">{{ data.worker.position }}</div>
</div>
<div class="profile-list worker-record-fields">
<div v-for="row in recordRows" :key="row.label">
<span>{{ row.label }}</span>
<strong :class="{ identifier: row.identifier }">{{ row.value }}</strong>
</div>
</div>
<div class="worker-record-status">
<span>Estatus</span>
<div class="ficha-tags">
<Tag :value="pipelineLabel(data.worker.pipeline_status)" :severity="pipelineSeverity(data.worker.pipeline_status)" />
<Tag :value="riskLevelLabel(data.worker.risk_code)" :severity="riskLevelSeverity(data.worker.risk_code)" />
<Tag v-if="empresaTag" :value="empresaTag" :severity="hireSeverity(empresaTag)" />
<Tag v-else value="Sin patrón" severity="secondary" />
<Tag :value="imssStatusLabel" :severity="imssStatusSeverity" />
</div>
</div>
<div class="worker-imss-block">
<div class="worker-imss-head">
<div class="worker-imss-title">IMSS</div>
<div class="worker-imss-actions">
<template v-if="canEdit">
<Button
v-if="data.worker.imss_status !== 'alta'"
label="Registrar alta"
icon="pi pi-file-pdf"
size="small"
@click="openImss('alta')"
/>
<template v-else>
<Button
label="Reemplazar alta"
icon="pi pi-upload"
size="small"
outlined
@click="openImss('alta')"
/>
<Button
label="Registrar baja"
icon="pi pi-times-circle"
size="small"
severity="warn"
outlined
@click="openImss('baja')"
/>
</template>
</template>
<Button
label="Historial"
icon="pi pi-history"
size="small"
text
@click="openImss('historial')"
/>
</div>
</div>
<div class="profile-list worker-record-fields">
<div><span>Estado</span><strong>{{ imssStatusLabel }}</strong></div>
<div v-if="data.worker.imss_status === 'alta'">
<span>Alta desde</span>
<strong>{{ formatDay(data.worker.imss_alta_at) }}</strong>
</div>
<div v-if="data.worker.imss_status === 'baja_imss' && data.worker.imss_baja_at">
<span>Baja desde</span>
<strong>{{ formatDay(data.worker.imss_baja_at) }}</strong>
</div>
<div v-if="data.worker.imss_company_name || data.worker.imss_company_code">
<span>Patrón</span>
<strong>{{ data.worker.imss_company_name || data.worker.imss_company_code }}</strong>
</div>
<div v-if="data.worker.imss_registro_patronal">
<span>Registro patronal</span>
<strong class="identifier">{{ data.worker.imss_registro_patronal }}</strong>
</div>
</div>
</div>
<Button
v-if="!canEdit"
label="Ir a Padrón"
icon="pi pi-users"
outlined
class="w-full worker-record-edit"
@click="goPadron"
/>
<div class="worker-mini-stats">
<div><strong>{{ requiredDone }}/{{ requiredItems.length }}</strong><span>Documentos</span></div>
<div><strong>{{ activeAssignments }}</strong><span>Proyectos</span></div>
<div><strong>{{ money(loanBalance) }}</strong><span>Préstamos</span></div>
</div>
<Tabs v-model:value="tab" class="mt-3 detail-tabs">
<TabList>
<Tab value="docs">Expediente</Tab>
<Tab value="proyectos">Proyectos</Tab>
<Tab value="nomina">Nómina</Tab>
</TabList>
<TabPanels>
<TabPanel value="docs">
<WorkerDocuments
:worker-id="workerId"
:documents="data.documents"
:checklist="data.checklist"
:companies="companies"
:initial-type="doc"
:readonly="!canEdit"
:freshness-required="data.freshness_required || data.worker.freshness_required"
:imss-ready="data.worker.imss_ready"
:in-project-without-imss="data.worker.in_project_without_imss"
@changed="refreshRelated"
@register-alta="openImss('alta')"
/>
</TabPanel>
<TabPanel value="proyectos">
<WorkerAssignments
:worker-id="workerId"
:assignments="data.assignments"
:projects="projects"
:readonly="!canEdit"
@changed="refreshRelated"
/>
</TabPanel>
<TabPanel value="nomina">
<Message :closable="false" class="nomina-jornal-msg">Jornal {{ money(Number(data.worker.daily_wage)) }} · {{ data.worker.work_type === "D" ? "Destajo" : "Normal" }}</Message>
<Message severity="secondary" :closable="false" class="mt-2">
<template v-if="canEdit">Use <strong>Préstamo</strong> en la barra para registrar. Esta ficha no edita los datos de la persona.</template>
<template v-else>La ficha es solo consulta. El préstamo se registra aquí; para editar datos de la persona, abra Padrón.</template>
</Message>
<Button
label="Registrar préstamo"
icon="pi pi-wallet"
class="w-full mt-2"
@click="registerLoan"
/>
<DataTable :value="movements" size="small" class="mt-3" empty-message="Sin movimientos">
<Column field="movement_date" header="Fecha" />
<Column field="kind" header="Movimiento" />
<Column field="note" header="Nota" />
</DataTable>
<DataTable :value="data.loans" size="small" class="mt-3" empty-message="Sin préstamos">
<Column header="Entregado"><template #body="{ data: row }">{{ money(Number(row.delivered || row.amount)) }}</template></Column>
<Column header="Saldo"><template #body="{ data: row }">{{ money(Number(row.balance)) }}</template></Column>
<Column header="Vence"><template #body="{ data: row }">{{ row.first_due || "—" }}</template></Column>
<Column header="">
<template #body="{ data: row }">
<Button icon="pi pi-file-pdf" text rounded size="small" aria-label="Recibo" @click="downloadLoan(row.id)" />
<Button icon="pi pi-list" text rounded size="small" aria-label="Estado de cuenta" @click="loadStatement(row.id)" />
</template>
</Column>
</DataTable>
<DataTable v-if="statementPayments.length" :value="statementPayments" size="small" class="mt-2">
<Column field="week_end" header="Semana" />
<Column field="amount" header="Descuento">
<template #body="{ data: row }">{{ money(Number(row.amount)) }}</template>
</Column>
<Column field="label" header="Etiqueta" />
</DataTable>
</TabPanel>
</TabPanels>
</Tabs>
<AltaImssDialog
v-if="workerId"
v-model="showAltaImss"
:worker-id="workerId"
:companies="companies"
:documents="data.documents"
:default-company-id="data.worker.company_id"
:imss-status="data.worker.imss_status"
:initial-tab="imssDialogTab"
@saved="onImssSaved"
/>
</div>
</div>
</template>
<script setup lang="ts">
import WorkerDocuments from "~/components/padron/WorkerDocuments.vue";
import WorkerAssignments from "~/components/padron/WorkerAssignments.vue";
import AltaImssDialog from "~/components/padron/AltaImssDialog.vue";
import type { ChecklistItem, CompanyOption, ProjectOption, Worker } from "~/types/padron";
type DetailData = {
worker: Worker & { full_name: string };
documents: {
id: number;
type_code: string;
original_name: string;
uploaded_at: string;
is_current: number;
mime?: string;
issued_at?: string | null;
expires_at?: string | null;
imss_company_id?: number | null;
imss_alta_at?: string | null;
}[];
assignments: { id: number; project_id: number; project_name: string; project_code: string; active: number; start_date: string; end_date?: string | null }[];
loans: { id: number; amount: number; delivered?: number; balance: number; weekly_payment: number; first_due?: string | null }[];
checklist: ChecklistItem[];
freshness_required?: boolean;
};
const props = defineProps<{ workerId?: number; doc?: string; tab?: string }>();
const emit = defineEmits<{ edit: []; changed: [] }>();
const { pipelineLabel, pipelineSeverity, initials, riskLevelLabel, riskLevelSeverity, hireSeverity } = useUi();
const { api, download } = useApi();
const toast = useToast();
const desk = useDesktop();
const route = useRoute();
const canEdit = computed(() => route.path.startsWith("/padron"));
const data = ref<DetailData | null>(null);
const projects = ref<ProjectOption[]>([]);
const companies = ref<CompanyOption[]>([]);
const loading = ref(false);
const tab = ref("docs");
const photoOk = ref(true);
const showAltaImss = ref(false);
const imssDialogTab = ref<"alta" | "baja" | "historial">("alta");
const title = computed(() => data.value ? [data.value.worker.first_name, data.value.worker.middle_name, data.value.worker.last_name_p, data.value.worker.last_name_m].filter(Boolean).join(" ") : "Expediente");
const recordCode = computed(() => data.value ? `PER-${String(data.value.worker.id).padStart(4, "0")}` : "PER-0000");
const photoSrc = computed(() => `/v1/workers/${props.workerId}/photo?v=${desk.detailRev.value}`);
const requiredItems = computed(() => data.value?.checklist.filter((item) => item.required) || []);
const requiredDone = computed(() => requiredItems.value.filter((item) => item.present && item.valid !== false).length);
const activeAssignments = computed(() => data.value?.assignments.filter((assignment) => assignment.active).length || 0);
const activeProjectNames = computed(() => data.value?.assignments.filter((assignment) => assignment.active).map((assignment) => assignment.project_name).join(", ") || "Sin asignar");
const loanBalance = computed(() => data.value?.loans.reduce((sum, loan) => sum + Math.max(0, Number(loan.balance)), 0) || 0);
const movements = ref<{ movement_date: string; kind: string; note: string }[]>([]);
const statementPayments = ref<{ week_end?: string; amount: number; label?: string }[]>([]);
async function loadStatement(id: number) {
const r = await api<{ payments?: { week_end?: string; amount: number; label?: string }[]; data?: { payments?: { week_end?: string; amount: number; label?: string }[] } }>(`/v1/loans/${id}/statement`);
statementPayments.value = r.payments ?? r.data?.payments ?? [];
}
const imssStatusLabel = computed(() => {
const status = data.value?.worker.imss_status;
if (status === "alta") return "Alta IMSS";
if (status === "baja_imss") return "Baja IMSS";
return "Sin alta IMSS";
});
const imssStatusSeverity = computed(() => {
const status = data.value?.worker.imss_status;
if (status === "alta") return "success";
if (data.value?.worker.in_project_without_imss) return "danger";
return "warn";
});
const empresaTag = computed(() => {
const worker = data.value?.worker;
if (!worker || worker.imss_status !== "alta") return "";
return worker.imss_company_code || worker.imss_company_name || worker.company_name || worker.hire_type || "";
});
const recordRows = computed(() => {
const worker = data.value?.worker;
if (!worker) return [];
return [
{ label: "Empresa", value: empresaTag.value || "Sin patrón" },
{ label: "Proyecto", value: activeProjectNames.value },
{ label: "CURP", value: worker.curp, identifier: true },
{ label: "RFC", value: worker.rfc, identifier: true },
{ label: "NSS", value: worker.nss, identifier: true },
{ label: "Teléfono", value: worker.phone },
{ label: "Correo", value: worker.email },
{ label: "Dirección", value: worker.address },
{ label: "Tipo de sangre", value: worker.blood_type || "—" },
{ label: "Jornal", value: money(Number(worker.daily_wage)) },
];
});
function money(value: number) {
return value.toLocaleString("es-MX", { style: "currency", currency: "MXN", maximumFractionDigits: 0 });
}
function formatDay(value?: string | null) {
if (!value) return "—";
const date = new Date(`${String(value).slice(0, 10)}T12:00:00`);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString("es-MX", { day: "2-digit", month: "short", year: "numeric" });
}
async function load() {
if (!props.workerId) { data.value = null; return; }
loading.value = true;
try {
const [detail, projectResponse, companyResponse] = await Promise.all([
api<DetailData>(`/v1/workers/${props.workerId}`),
api<{ projects: ProjectOption[] }>("/v1/projects?status=activo"),
api<{ companies: CompanyOption[] }>("/v1/companies"),
]);
data.value = detail;
const mov = await api<{ movements?: { movement_date: string; kind: string; note: string }[]; data?: { movements?: { movement_date: string; kind: string; note: string }[] } }>(`/v1/workers/${props.workerId}/movements`);
movements.value = mov.movements ?? mov.data?.movements ?? [];
projects.value = projectResponse.projects;
companies.value = companyResponse.companies.filter((c) => c.status === "activo");
photoOk.value = true;
} finally {
loading.value = false;
}
}
function refreshRelated() {
desk.bumpDetail();
}
function openImss(tab: "alta" | "baja" | "historial") {
imssDialogTab.value = tab;
showAltaImss.value = true;
}
function onImssSaved(kind: "alta" | "baja") {
toast.add({
severity: "success",
summary: kind === "baja" ? "Baja IMSS registrada" : "Alta IMSS registrada",
life: 2200,
});
refreshRelated();
}
function goPadron() {
if (!props.workerId) return;
navigateTo(`/padron?worker=${props.workerId}`);
}
function registerLoan() {
if (!props.workerId) return;
desk.openLoan(props.workerId, title.value);
}
function downloadLoan(id: number) {
download(`/v1/loans/${id}/recibo`, `prestamo-aceptacion-${id}.pdf`);
}
watch(() => [props.workerId, props.tab] as const, ([, fichaTab]) => {
tab.value = fichaTab || "docs";
});
watch(() => [props.workerId, desk.detailRev.value] as const, () => {
load();
}, { immediate: true });
</script>