panels-origin/web-panel/components/padron/WorkerFormDialog.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

143 lines
8.3 KiB
Vue

<template>
<Dialog v-model:visible="visibleModel" :header="form.id ? 'Editar persona' : 'Alta de persona'" modal :style="{ width: '860px' }" class="worker-form-dialog">
<div class="dialog-intro">
<div><div class="section-title">{{ form.id ? displayName : "Nuevo registro de padrón" }}</div><div class="muted">La persona vive en el padrón; el proyecto es una asignación.</div></div>
<Tag v-if="form.id" value="Datos maestros" severity="secondary" />
</div>
<Tabs v-model:value="tab">
<TabList>
<Tab value="identidad"><i class="pi pi-user" /> Identidad</Tab>
<Tab value="laboral"><i class="pi pi-briefcase" /> Alta laboral</Tab>
<Tab v-if="!form.id" value="asignacion"><i class="pi pi-building" /> Asignación inicial</Tab>
</TabList>
<TabPanels>
<TabPanel value="identidad">
<Fluid>
<div class="form-grid form-grid-compact">
<FormField label="Nombre" required :error="fieldErr.first_name"><InputText v-model="form.first_name" :invalid="!!fieldErr.first_name" /></FormField>
<FormField label="Segundo nombre"><InputText v-model="form.middle_name" /></FormField>
<FormField label="Apellido paterno" required :error="fieldErr.last_name_p"><InputText v-model="form.last_name_p" :invalid="!!fieldErr.last_name_p" /></FormField>
<FormField label="Apellido materno" required :error="fieldErr.last_name_m"><InputText v-model="form.last_name_m" :invalid="!!fieldErr.last_name_m" /></FormField>
<FormField label="CURP" required :error="fieldErr.curp"><InputText v-model="form.curp" maxlength="18" class="identifier-input" :invalid="!!fieldErr.curp" @blur="validateLive" /></FormField>
<FormField label="RFC" required :error="fieldErr.rfc"><InputText v-model="form.rfc" class="identifier-input" :invalid="!!fieldErr.rfc" @blur="validateLive" /></FormField>
<FormField label="NSS" required :error="fieldErr.nss"><InputMask v-model="form.nss" mask="99999999999" :invalid="!!fieldErr.nss" @blur="validateLive" /></FormField>
<FormField label="Tipo de sangre"><Select v-model="form.blood_type" :options="bloodTypes" show-clear /></FormField>
<FormField label="Teléfono" required :error="fieldErr.phone"><InputText v-model="form.phone" :invalid="!!fieldErr.phone" /></FormField>
<FormField label="Correo" required :error="fieldErr.email"><InputText v-model="form.email" class="no-caps" :invalid="!!fieldErr.email" /></FormField>
<FormField label="Dirección" required :error="fieldErr.address" class="span-2"><InputText v-model="form.address" :invalid="!!fieldErr.address" /></FormField>
</div>
</Fluid>
</TabPanel>
<TabPanel value="laboral">
<Fluid>
<Message severity="info" :closable="false" class="mb-3">
La empresa patrón se asigna al registrar el alta IMSS. Sin alta, la persona no pertenece a un patrón.
</Message>
<div class="form-grid form-grid-compact">
<FormField label="Cargo" required :error="fieldErr.position"><InputText v-model="form.position" :invalid="!!fieldErr.position" /></FormField>
<FormField label="Riesgo / barra de gafete" required :error="fieldErr.risk_code">
<Select v-model="form.risk_code" :options="riskChoices" option-label="display" option-value="code">
<template #option="{ option }"><div class="risk-option"><span class="risk-swatch" :style="{ backgroundColor: option.color }" /><span>{{ option.display }}</span></div></template>
</Select>
</FormField>
<FormField label="Tipo de trabajo"><SelectButton v-model="form.work_type" :options="workTypes" option-label="label" option-value="value" :allow-empty="false" /></FormField>
<FormField label="Jornal diario" required :error="fieldErr.daily_wage"><InputNumber v-model="form.daily_wage" mode="currency" currency="MXN" locale="es-MX" :min="0" /></FormField>
<FormField label="Jornal IMSS"><InputNumber v-model="form.imss_daily_wage" mode="currency" currency="MXN" locale="es-MX" :min="0" /></FormField>
<FormField label="Gafete">
<SelectButton
:model-value="form.needs_badge ? 'si' : 'no'"
:options="badgeOpts"
option-label="label"
option-value="value"
:allow-empty="false"
@update:model-value="(value: string) => form.needs_badge = value === 'si'"
/>
</FormField>
</div>
</Fluid>
</TabPanel>
<TabPanel v-if="!form.id" value="asignacion">
<Message severity="info" :closable="false">La asignación es opcional. Solo aparecen proyectos activos; también puede dejar a la persona en “Sin asignar”.</Message>
<FormField label="Proyecto activo" class="mt-3"><Select v-model="form.project_id" :options="projects" option-label="name" option-value="id" placeholder="Sin asignar" show-clear class="w-full" /></FormField>
</TabPanel>
</TabPanels>
</Tabs>
<Message v-if="formError" severity="error" class="mt-3" :closable="false">{{ formError }}</Message>
<template #footer>
<Button label="Cancelar" severity="secondary" text @click="visibleModel = false" />
<Button label="Guardar" icon="pi pi-check" :loading="saving" @click="submit" />
</template>
</Dialog>
</template>
<script setup lang="ts">
import type { CompanyOption, ProjectOption, RiskOption, Worker, WorkerForm } from "~/types/padron";
import { emptyWorkerForm } from "~/types/padron";
import FormField from "./FormField.vue";
const props = defineProps<{
visible: boolean;
worker: Worker | null;
projects: ProjectOption[];
risks: RiskOption[];
companies: CompanyOption[];
saving?: boolean;
error?: string;
}>();
const emit = defineEmits<{ "update:visible": [value: boolean]; save: [form: WorkerForm] }>();
const { api } = useApi();
const { riskLevelLabel } = useUi();
const visibleModel = computed({ get: () => props.visible, set: (value) => emit("update:visible", value) });
const form = reactive<WorkerForm>(emptyWorkerForm());
const fieldErr = ref<Record<string, string>>({});
const liveError = ref("");
const tab = ref("identidad");
const formError = computed(() => props.error || liveError.value);
const displayName = computed(() => [form.first_name, form.middle_name, form.last_name_p, form.last_name_m].filter(Boolean).join(" "));
const workTypes = [{ label: "Normal", value: "N" }, { label: "Destajo", value: "D" }];
const badgeOpts = [{ label: "Sí lleva", value: "si" }, { label: "No lleva", value: "no" }];
const bloodTypes = ["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"];
const riskChoices = computed(() => props.risks.map((risk) => ({ ...risk, display: `${riskLevelLabel(risk.code)} · ${risk.label}` })));
async function validateLive() {
try {
const result = await api<{ errors: Record<string, string> }>("/v1/workers/validate", { method: "POST", json: { ...form, id: form.id || undefined } });
fieldErr.value = result.errors || {};
liveError.value = "";
} catch (error) {
liveError.value = error instanceof Error ? error.message : "No se pudo validar";
}
}
async function submit() {
await validateLive();
if (Object.keys(fieldErr.value).length) {
tab.value = ["first_name", "last_name_p", "last_name_m", "curp", "rfc", "nss", "phone", "email", "address"].some((key) => fieldErr.value[key]) ? "identidad" : "laboral";
liveError.value = "Revise los campos marcados.";
return;
}
emit("save", { ...form });
}
function hydrate() {
Object.assign(form, emptyWorkerForm());
if (props.worker) {
Object.assign(form, props.worker, {
middle_name: props.worker.middle_name || "",
blood_type: props.worker.blood_type || "",
needs_badge: Boolean(props.worker.needs_badge),
project_id: null,
});
} else {
form.hire_type = "";
}
if (!props.risks.some((risk) => risk.code === form.risk_code) && props.risks[0]) form.risk_code = props.risks[0].code;
fieldErr.value = {};
liveError.value = "";
tab.value = "identidad";
}
watch(() => [props.visible, props.worker?.id, props.risks.length, props.companies.length], () => { if (props.visible) hydrate(); });
</script>