panels-origin/web-panel/pages/nomina.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

825 lines
33 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="list-page nomina-page">
<div class="nomina-board">
<Card>
<template #content>
<div class="muted">Requerida (jueves)</div>
<div class="nomina-kpi">{{ money(board.required_net) }}</div>
</template>
</Card>
<Card>
<template #content>
<div class="muted">A pagar (sábado)</div>
<div class="nomina-kpi">{{ money(board.payable_net) }}</div>
</template>
</Card>
<Card>
<template #content>
<div class="muted">Recuperación de préstamos</div>
<div class="nomina-kpi" :class="board.loan_recovery > 0 ? 'ok' : ''">{{ money(board.loan_recovery) }}</div>
</template>
</Card>
<Card v-if="board.faltante > 0">
<template #content>
<div class="muted">Faltante de caja</div>
<div class="nomina-kpi warn">{{ money(board.faltante) }}</div>
</template>
</Card>
</div>
<Toolbar>
<template #start>
<Button icon="pi pi-chevron-left" text rounded @click="shiftWeek(-7)" />
<strong>{{ weekLabel }}</strong>
<Button icon="pi pi-chevron-right" text rounded @click="shiftWeek(7)" />
<Tag :value="statusLabel" :severity="statusSeverity" />
<span v-if="destajoPeriod" class="muted destajo-cut">Corte destajo {{ destajoPeriod.period_start }} → {{ destajoPeriod.period_end }}</span>
</template>
</Toolbar>
<Tabs v-model:value="tab">
<TabList>
<Tab value="obra">Jornal</Tab>
<Tab value="destajo">Destajo</Tab>
<Tab value="admin">Administrativa</Tab>
</TabList>
<TabPanels>
<TabPanel value="obra">
<Message severity="secondary" :closable="false">
Marque L–S para asistencia y desmarque para falta. Clic en la persona abre la ficha (consulta). El préstamo se registra con <strong>Préstamo</strong> en la barra o desde la ficha.
</Message>
<DataTable
:value="obraLines"
scrollable
scroll-height="flex"
table-style="min-width: 52rem"
empty-message="Sin personal de jornal en esta obra"
>
<Column header="Persona" frozen>
<template #body="{ data }">
<button type="button" class="person-link" @click="openPerson(data.worker_id)">
{{ personLabel(data) }}
</button>
</template>
</Column>
<Column
v-for="(day, idx) in days"
:key="day"
:header="dayHeaders[idx]"
style="width: 4.2rem; min-width: 3.6rem; text-align: center"
>
<template #body="{ data }">
<Tag
v-if="cell(data, idx)?.other_project_name"
:value="cell(data, idx)?.other_project_name"
severity="info"
class="staff-loan-tag"
:title="`Préstamo de personal · ${cell(data, idx)?.other_project_name}`"
/>
<Checkbox
v-else
:model-value="Boolean(cell(data, idx)?.present)"
binary
:disabled="locked"
@update:model-value="(v: boolean) => toggleDay(data, day, v)"
/>
</template>
</Column>
<Column header="Días"><template #body="{ data }">{{ data.days }}</template></Column>
<Column header="Jornal"><template #body="{ data }">{{ money(Number(data.daily_wage)) }}</template></Column>
<Column header="A pagar"><template #body="{ data }">{{ money(Number(data.payable_net)) }}</template></Column>
<Column header="Préstamo">
<template #body="{ data }">
<button type="button" class="person-link loan-link" @click="openLoanFor(data.worker_id, personLabel(data))">
{{ data.loan_label || "Registrar" }}
</button>
</template>
</Column>
</DataTable>
</TabPanel>
<TabPanel value="destajo">
<Message severity="secondary" :closable="false">
Trabajo largo, corte jueves a jueves. El jueves pide el estimado de este corte; el sábado paga lo hecho más adicionales del viernes.
</Message>
<DataTable :value="destajoCuts" empty-message="Sin destajo en este corte">
<Column header="Obra" field="project_name" />
<Column header="Persona">
<template #body="{ data }">
<button type="button" class="person-link" @click="openPerson(data.worker_id)">
{{ personLabel(data) }}
</button>
</template>
</Column>
<Column header="Concepto" field="concepto" />
<Column header="Unidad" field="unit_code" />
<Column header="Total">
<template #body="{ data }">{{ data.qty_total_estimated }}</template>
</Column>
<Column header="Estimado corte">
<template #body="{ data }">
<InputNumber
:model-value="Number(data.qty_planned)"
:min="0"
:max-fraction-digits="2"
:disabled="paid"
class="cut-input"
@update:model-value="(v: number) => patchCut(data.id, { qty_planned: Number(v || 0) })"
/>
</template>
</Column>
<Column header="Realizado">
<template #body="{ data }">
<InputNumber
:model-value="Number(data.qty_actual)"
:min="0"
:max-fraction-digits="2"
:disabled="paid"
class="cut-input"
@update:model-value="(v: number) => patchCut(data.id, { qty_actual: Number(v || 0) })"
/>
</template>
</Column>
<Column header="Adicional vie">
<template #body="{ data }">
<InputNumber
:model-value="Number(data.qty_extra)"
:min="0"
:max-fraction-digits="2"
:disabled="paid"
class="cut-input"
@update:model-value="(v: number) => patchCut(data.id, { qty_extra: Number(v || 0) })"
/>
</template>
</Column>
<Column header="Precio">
<template #body="{ data }">{{ money(Number(data.unit_price)) }}</template>
</Column>
</DataTable>
</TabPanel>
<TabPanel value="admin">
<Message severity="secondary" :closable="false">Lista aparte, sueldo semanal, sin asistencia. Proyecto opcional.</Message>
<DataTable :value="adminLines" empty-message="Sin personal administrativo">
<Column header="Persona">
<template #body="{ data }">
<button type="button" class="person-link" @click="openPerson(data.worker_id)">
{{ personLabel(data) }}
</button>
</template>
</Column>
<Column header="Sueldo">
<template #body="{ data }">
<InputNumber
:model-value="Number(data.amount)"
mode="currency"
currency="MXN"
locale="es-MX"
:disabled="paid"
class="cut-input"
@update:model-value="(v: number) => saveAdmin(data.id, Number(v || 0))"
/>
</template>
</Column>
<Column header="A pagar"><template #body="{ data }">{{ money(Number(data.payable_net)) }}</template></Column>
<Column header="">
<template #body="{ data }">
<Button icon="pi pi-times" text rounded severity="danger" :disabled="paid" @click="removeAdmin(data.id)" />
</template>
</Column>
</DataTable>
</TabPanel>
</TabPanels>
</Tabs>
<Dialog v-model:visible="showDestajo" header="Alta de destajo" modal :style="{ width: '28rem' }">
<div class="loan-form">
<Select v-model="jobForm.project_id" :options="projects" option-label="name" option-value="id" placeholder="Obra" class="w-full" />
<Select v-model="jobForm.worker_id" :options="destajoWorkers" option-label="label" option-value="id" placeholder="Quién cobra" class="w-full" filter />
<InputText v-model="jobForm.concepto" placeholder="Concepto" class="w-full" />
<Select v-model="jobForm.unit_code" :options="units" option-label="label" option-value="code" placeholder="Unidad" class="w-full" />
<InputNumber v-model="jobForm.qty_total_estimated" placeholder="Cantidad total" :min="0" class="w-full" />
<InputNumber v-model="jobForm.qty_planned" placeholder="Estimado de este corte" :min="0" class="w-full" />
<InputNumber v-model="jobForm.unit_price" mode="currency" currency="MXN" locale="es-MX" placeholder="Precio unitario" class="w-full" />
</div>
<template #footer>
<Button label="Cancelar" text @click="showDestajo = false" />
<Button label="Dar de alta" icon="pi pi-check" @click="createJob" />
</template>
</Dialog>
<Dialog v-model:visible="showAdmin" header="Agregar administrativa" modal :style="{ width: '24rem' }">
<div class="loan-form">
<Select v-model="adminForm.worker_id" :options="allWorkers" option-label="label" option-value="id" placeholder="Persona" class="w-full" filter />
<InputNumber v-model="adminForm.amount" mode="currency" currency="MXN" locale="es-MX" placeholder="Sueldo semanal" class="w-full" />
<Select v-model="adminForm.project_id" :options="projects" option-label="name" option-value="id" placeholder="Proyecto (opcional)" show-clear class="w-full" />
</div>
<template #footer>
<Button label="Cancelar" text @click="showAdmin = false" />
<Button label="Agregar" icon="pi pi-check" @click="createAdmin" />
</template>
</Dialog>
<Dialog v-model:visible="showAdjust" header="Bono, descuento u horas" modal :style="{ width: '24rem' }">
<div class="loan-form">
<Select v-model="adjust.kind" :options="adjustKinds" option-label="label" option-value="value" class="w-full" />
<InputText v-model="adjust.concept" placeholder="Concepto" class="w-full" />
<InputNumber v-model="adjust.amount" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
<InputNumber v-model="adjust.hours" :min-fraction-digits="0" :max-fraction-digits="2" placeholder="Horas" class="w-full" />
<Textarea v-model="adjust.note" placeholder="Observación (obligatoria en descuento)" class="w-full" rows="2" />
<Button label="Guardar" @click="saveAdjust" />
</div>
</Dialog>
<Dialog v-model:visible="showHoliday" header="Día festivo" modal :style="{ width: '22rem' }">
<div class="loan-form">
<InputText v-model="holiday.holiday_date" placeholder="AAAA-MM-DD" class="w-full" />
<InputText v-model="holiday.name" placeholder="Nombre" class="w-full" />
<Button label="Guardar" @click="saveHoliday" />
</div>
</Dialog>
<Dialog v-model:visible="showDispersion" header="Dispersión" modal :style="{ width: '36rem' }">
<p>Neto semana {{ money(dispersionNet) }} · {{ dispersionStatus }}</p>
<DataTable :value="dispersionLines" size="small">
<Column field="last_name_p" header="Persona" />
<Column field="amount" header="Importe">
<template #body="{ data }">
<InputNumber v-model="data.amount" mode="currency" currency="MXN" locale="es-MX" :disabled="dispersionStatus === 'definitiva'" @blur="saveDispLine(data)" />
</template>
</Column>
</DataTable>
<div class="flex gap-2 mt-3">
<Button label="Corte semanal" :disabled="dispersionStatus === 'definitiva'" @click="cutDispersion" />
<Button label="Definitiva" :disabled="dispersionStatus !== 'corte'" @click="finalizeDispersion" />
<input type="file" accept="application/pdf,.pdf" @change="attachDispersion" />
</div>
</Dialog>
<Dialog v-model:visible="showSettlement" header="Finiquito o liquidación" modal :style="{ width: '24rem' }">
<div class="loan-form">
<Select v-model="settlement.kind" :options="[{ label: 'Finiquito', value: 'finiquito' }, { label: 'Liquidación', value: 'liquidacion' }]" option-label="label" option-value="value" class="w-full" />
<InputText v-model="settlement.termination_date" placeholder="Fecha de baja AAAA-MM-DD" class="w-full" />
<InputText v-model="settlement.concept" placeholder="Concepto" class="w-full" />
<InputNumber v-model="settlement.amount" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
<Textarea v-model="settlement.note" placeholder="Observación" class="w-full" rows="2" />
<Button label="Guardar y ver importe" @click="saveSettlement" />
<p v-if="settlementNet != null">Importe final {{ money(settlementNet) }}</p>
</div>
</Dialog>
<Dialog v-model:visible="showReport" header="Reporte de nómina" modal :style="{ width: '28rem' }">
<div class="loan-form">
<InputText v-model="reportFrom" placeholder="Desde AAAA-MM-DD" class="w-full" />
<InputText v-model="reportTo" placeholder="Hasta AAAA-MM-DD" class="w-full" />
<div class="flex gap-2">
<Button label="Excel" @click="downloadReport('xlsx')" />
<Button label="PDF" @click="downloadReport('pdf')" />
<Button label="IMSS Excel" @click="downloadImss" />
</div>
</div>
</Dialog>
<Dialog v-model:visible="showAddJornal" header="Agregar a jornal" modal :style="{ width: '22rem' }">
<Select v-model="jornalWorkerId" :options="allWorkers" option-label="label" option-value="id" placeholder="Persona" class="w-full" filter />
<template #footer>
<Button label="Cancelar" text @click="showAddJornal = false" />
<Button label="Agregar" icon="pi pi-check" @click="addJornal" />
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { workerFullName } from "~/types/padron";
type Cell = { date: string; present: boolean; other_project_id: number | null; other_project_name: string | null };
type NameParts = {
first_name?: string | null;
middle_name?: string | null;
last_name_p?: string | null;
last_name_m?: string | null;
};
type Line = NameParts & {
id: number;
worker_id: number;
days: number;
daily_wage: number;
payable_net: number;
amount: number;
loan_label?: string | null;
cells?: Cell[];
};
type Sheet = { id: number; kind: string; project_id: number | null; project_name?: string; lines: Line[] };
type Cut = NameParts & {
id: number;
worker_id: number;
project_name: string;
concepto: string;
unit_code: string;
qty_total_estimated: number;
qty_planned: number;
qty_actual: number;
qty_extra: number;
unit_price: number;
};
const { api, download } = useApi();
const desk = useDesktop();
const toast = useToast();
const confirm = useConfirm();
const weekStart = ref(mondayOf(new Date()));
const tab = ref("obra");
const projectId = ref<number | null>(null);
const projects = ref<{ id: number; name: string }[]>([]);
const allWorkers = ref<{ id: number; label: string; work_type?: string }[]>([]);
const units = ref<{ code: string; label: string }[]>([]);
const week = ref<Record<string, unknown> | null>(null);
const destajoPeriod = ref<{ period_start: string; period_end: string } | null>(null);
const sheets = ref<Sheet[]>([]);
const destajoCuts = ref<Cut[]>([]);
const days = ref<string[]>([]);
const board = ref({ required_net: 0, payable_net: 0, loan_recovery: 0, faltante: 0, delta: 0 });
const canAssemble = ref(false);
const assembleFrom = ref("");
const showDestajo = ref(false);
const showAdmin = ref(false);
const showAddJornal = ref(false);
const showAdjust = ref(false);
const showHoliday = ref(false);
const showDispersion = ref(false);
const showReport = ref(false);
const showSettlement = ref(false);
const settlement = reactive({ kind: "finiquito", termination_date: "", concept: "Sueldo pendiente", amount: 0, note: "" });
const adjustKinds = [
{ label: "Bono", value: "bono" },
{ label: "Descuento", value: "descuento" },
{ label: "Horas", value: "horas" },
];
const adjust = reactive({ kind: "bono", concept: "", amount: 0, hours: 0, note: "" });
const holiday = reactive({ holiday_date: "", name: "" });
const dispersionId = ref<number | null>(null);
const dispersionStatus = ref("abierta");
const dispersionNet = ref(0);
const dispersionLines = ref<{ id: number; last_name_p: string; amount: number }[]>([]);
const settlementNet = ref<number | null>(null);
const reportFrom = ref("");
const reportTo = ref("");
const selectedWorkerId = ref<number | null>(null);
const selectedWorkerName = ref("");
const jornalWorkerId = ref<number | null>(null);
const jobForm = reactive({
project_id: null as number | null,
worker_id: null as number | null,
concepto: "",
unit_code: "m",
qty_total_estimated: 0,
qty_planned: 0,
unit_price: 0,
});
const adminForm = reactive({ worker_id: null as number | null, amount: 0, project_id: null as number | null });
const dayHeaders = ["L", "M", "X", "J", "V", "S"];
const paid = computed(() => week.value?.status === "paid");
const locked = computed(() => paid.value);
const statusLabel = computed(() => {
const s = String(week.value?.status || "draft");
if (s === "assembled") return "Armada";
if (s === "paid") return "Pagada";
return "Borrador";
});
const statusSeverity = computed(() => {
const s = String(week.value?.status || "draft");
if (s === "paid") return "success";
if (s === "assembled") return "info";
return "warn";
});
const weekLabel = computed(() => {
const a = week.value?.week_start || weekStart.value;
const b = week.value?.week_end || "";
return b ? `${fmt(String(a))} – ${fmt(String(b))}` : fmt(weekStart.value);
});
const obraSheet = computed(() =>
sheets.value.find((s) => s.kind === "obra" && s.project_id === projectId.value) ||
sheets.value.find((s) => s.kind === "obra") ||
null,
);
const obraLines = computed(() => obraSheet.value?.lines || []);
const adminLines = computed(() => sheets.value.find((s) => s.kind === "admin")?.lines || []);
const destajoWorkers = computed(() => {
const d = allWorkers.value.filter((w) => w.work_type === "D");
return d.length ? d : allWorkers.value;
});
function mondayOf(d: Date) {
const iso = d.toLocaleDateString("en-CA", { timeZone: "America/Cancun" });
const [y, m, day] = iso.split("-").map(Number);
const utc = new Date(Date.UTC(y, m - 1, day));
const mon0 = (utc.getUTCDay() + 6) % 7;
utc.setUTCDate(utc.getUTCDate() - mon0);
return utc.toISOString().slice(0, 10);
}
function addDaysIso(iso: string, n: number) {
const [y, m, d] = iso.split("-").map(Number);
const utc = new Date(Date.UTC(y, m - 1, d + n));
return utc.toISOString().slice(0, 10);
}
function fmt(iso: string) {
const [y, m, d] = iso.split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString("es-MX", { day: "numeric", month: "short", timeZone: "UTC" });
}
function money(n: number) {
return (Number(n) || 0).toLocaleString("es-MX", { style: "currency", currency: "MXN", maximumFractionDigits: 0 });
}
function cell(line: Line, idx: number) {
return line.cells?.[idx];
}
async function load() {
const [p, w, bundle] = await Promise.all([
api<{ projects: { id: number; name: string }[] }>("/v1/projects?status=activo,pausado"),
api<{ workers: { id: number; first_name: string; middle_name?: string | null; last_name_p: string; last_name_m?: string | null; work_type: string }[] }>("/v1/workers?status=activo"),
api<{
week: Record<string, unknown>;
destajo_period: { period_start: string; period_end: string } | null;
sheets: Sheet[];
destajo_cuts: Cut[];
days: string[];
board: { required_net: number; payable_net: number; loan_recovery: number; faltante: number; delta: number };
units: { code: string; label: string }[];
can_assemble: boolean;
assemble_from: string;
}>(`/v1/payroll/weeks?week_start=${weekStart.value}`),
]);
projects.value = p.projects;
if (!projectId.value && p.projects[0]) projectId.value = p.projects[0].id;
allWorkers.value = w.workers.map((x) => ({
id: x.id,
label: workerFullName(x),
work_type: x.work_type,
}));
week.value = bundle.week;
destajoPeriod.value = bundle.destajo_period;
sheets.value = bundle.sheets;
destajoCuts.value = bundle.destajo_cuts || [];
const start = String(bundle.week?.week_start || weekStart.value);
days.value = bundle.days?.length ? bundle.days : [0, 1, 2, 3, 4, 5].map((n) => addDaysIso(start, n));
board.value = {
required_net: 0,
payable_net: 0,
loan_recovery: 0,
faltante: 0,
delta: 0,
...bundle.board,
};
units.value = bundle.units || [];
canAssemble.value = Boolean(bundle.can_assemble);
assembleFrom.value = bundle.assemble_from || "";
syncChrome();
}
function syncChrome() {
const st = String(week.value?.status || "draft");
const recovery = board.value.loan_recovery || 0;
const short = board.value.faltante || 0;
const title = st === "paid"
? "Semana pagada"
: recovery > 0
? `Caja ${money(board.value.required_net)} · a pagar ${money(board.value.payable_net)} · préstamos ${money(recovery)}`
: `Caja ${money(board.value.required_net)} · a pagar ${money(board.value.payable_net)}`;
const detail = short > 0
? "Hay faltante de caja respecto a lo armado el jueves (destajo extra)."
: recovery > 0
? "La diferencia es recuperación de préstamos, no sobrante de caja."
: "Jueves se arma, sábado se paga.";
desk.setStatusMessages([
{
key: "nom_week",
severity: st === "paid" ? "info" : short > 0 ? "warn" : "info",
title,
detail,
route: "/nomina",
},
]);
desk.configure({
tab: "Nómina",
treeTitle: "Obras",
showTree: true,
showDetail: true,
treeKey: projectId.value ? `p:${projectId.value}` : "",
countLabel: weekLabel.value,
onTreeSelect: (key) => {
if (!key.startsWith("p:")) return;
projectId.value = Number(key.slice(2));
},
tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-folder" })),
ribbon: [
{
key: "acciones",
label: "Nómina",
items: [
{
key: "assemble",
label: "Armar jueves",
icon: "pi pi-box",
disabled: paid.value || st === "assembled" || !canAssemble.value,
title: canAssemble.value
? "Congela la requerida del jueves"
: `Disponible a partir del jueves${assembleFrom.value ? ` (${fmt(assembleFrom.value)})` : ""}`,
command: assemble,
},
{ key: "pay", label: "Pagar sábado", icon: "pi pi-wallet", disabled: paid.value, command: pay },
{ key: "csv", label: "CSV", icon: "pi pi-download", command: csv },
{ key: "ajuste", label: "Bono o descuento", icon: "pi pi-plus", disabled: paid.value, command: () => { showAdjust.value = true; } },
{ key: "festivo", label: "Festivo", icon: "pi pi-calendar", command: () => { showHoliday.value = true; } },
{ key: "dispersion", label: "Dispersión", icon: "pi pi-send", command: openDispersion },
{ key: "reporte", label: "Reporte", icon: "pi pi-chart-bar", command: () => { showReport.value = true; } },
{ key: "finiquito", label: "Finiquito", icon: "pi pi-file", disabled: !selectedWorkerId.value, command: () => { showSettlement.value = true; } },
],
},
{
key: "alta",
label: "Alta",
items: [
{ key: "destajo", label: "Destajo", icon: "pi pi-plus", disabled: paid.value, command: () => { jobForm.project_id = projectId.value; showDestajo.value = true; } },
{ key: "admin", label: "Administrativa", icon: "pi pi-user-plus", disabled: paid.value, command: () => { showAdmin.value = true; } },
{ key: "jornal", label: "Agregar a jornal", icon: "pi pi-users", disabled: paid.value, command: () => { showAddJornal.value = true; } },
{
key: "loan",
label: "Préstamo",
icon: "pi pi-wallet",
disabled: !selectedWorkerId.value,
title: selectedWorkerId.value ? "Registrar préstamo a la persona seleccionada" : "Elija una persona en la tabla",
command: openLoanFromNomina,
},
],
},
{
key: "vista",
label: "Vista",
items: [{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", command: load }],
},
],
});
}
function shiftWeek(n: number) {
weekStart.value = addDaysIso(weekStart.value, n);
load();
}
function openPerson(id?: number) {
if (!id) return;
selectedWorkerId.value = id;
selectedWorkerName.value = personName(id);
desk.openWorker(id, undefined, "nomina");
syncChrome();
}
function openLoanFor(id: number, name: string) {
selectedWorkerId.value = id;
selectedWorkerName.value = name;
desk.openWorker(id, undefined, "nomina");
desk.openLoan(id, name);
syncChrome();
}
function openLoanFromNomina() {
const id = selectedWorkerId.value;
if (!id) {
toast.add({ severity: "info", summary: "Elija una persona en la tabla", life: 2400 });
return;
}
desk.openLoan(id, selectedWorkerName.value || personName(id));
}
function personLabel(row: NameParts) {
return workerFullName(row);
}
function personName(id: number) {
const listed = allWorkers.value.find((w) => w.id === id);
if (listed) return listed.label;
const line = obraLines.value.find((row) => row.worker_id === id)
|| destajoCuts.value.find((row) => row.worker_id === id)
|| adminLines.value.find((row) => row.worker_id === id);
return line ? personLabel(line) : "";
}
async function toggleDay(line: Line, date: string, present: boolean) {
if (!obraSheet.value?.project_id || paid.value) return;
try {
await api("/v1/attendance", {
method: "PUT",
json: { project_id: obraSheet.value.project_id, worker_id: line.worker_id, work_date: date, present },
});
await load();
} catch (error) {
const detail = error as { data?: { error?: string } };
toast.add({ severity: "warn", summary: detail.data?.error || "No se pudo marcar el día", life: 2800 });
}
}
async function assemble() {
if (!week.value) return;
try {
await api(`/v1/payroll/weeks/${week.value.id}/assemble`, { method: "POST" });
toast.add({ severity: "success", summary: "Nómina armada", detail: "Requerida congelada", life: 2200 });
await load();
} catch (error) {
const detail = error as { data?: { error?: string } };
toast.add({
severity: "warn",
summary: detail.data?.error || "Aún no se puede armar esta semana",
life: 3200,
});
}
}
async function pay() {
if (!week.value) return;
confirm.require({
header: "Pagar sábado",
message: `Se descontarán préstamos y se congelará el gasto de ${money(board.value.payable_net)}.`,
acceptLabel: "Pagar",
rejectLabel: "Cancelar",
accept: async () => {
await api(`/v1/payroll/weeks/${week.value!.id}/pay`, { method: "POST" });
toast.add({ severity: "success", summary: "Semana pagada", life: 2200 });
await load();
},
});
}
async function saveSettlement() {
if (!selectedWorkerId.value) return;
const saved = await api<{ id?: number; data?: { id?: number } }>("/v1/settlements", {
method: "POST",
json: {
worker_id: selectedWorkerId.value,
project_id: projectId.value,
kind: settlement.kind,
termination_date: settlement.termination_date,
lines: [{ concept: settlement.concept, amount: settlement.amount, note: settlement.note }],
},
});
const id = saved.id ?? saved.data?.id;
if (!id) return;
const got = await api<{ settlement?: { net: number }; data?: { settlement?: { net: number } } }>(`/v1/settlements/${id}`);
settlementNet.value = Number(got.settlement?.net ?? got.data?.settlement?.net ?? 0);
}
async function saveAdjust() {
if (!week.value || !selectedWorkerId.value) return;
await api("/v1/payroll/adjustments", {
method: "POST",
json: { ...adjust, week_id: week.value.id, worker_id: selectedWorkerId.value, project_id: projectId.value },
});
showAdjust.value = false;
await load();
}
async function saveHoliday() {
await api("/v1/payroll/holidays", { method: "POST", json: holiday });
showHoliday.value = false;
await load();
}
async function openDispersion() {
if (!week.value) return;
const r = await api<{ dispersion?: { id: number; status: string; net: number }; lines?: { id: number; last_name_p: string; amount: number }[]; data?: { dispersion?: { id: number; status: string; net: number }; lines?: { id: number; last_name_p: string; amount: number }[] } }>(`/v1/payroll/weeks/${week.value.id}/dispersion`);
const d = r.dispersion ?? r.data?.dispersion;
dispersionId.value = d?.id ?? null;
dispersionStatus.value = d?.status ?? "abierta";
dispersionNet.value = Number(d?.net ?? 0);
dispersionLines.value = r.lines ?? r.data?.lines ?? [];
showDispersion.value = true;
}
async function cutDispersion() {
if (!dispersionId.value) return;
await api(`/v1/payroll/dispersions/${dispersionId.value}/cut`, { method: "POST" });
await openDispersion();
}
async function saveDispLine(row: { id: number; amount: number }) {
await api(`/v1/payroll/dispersion-lines/${row.id}`, { method: "PATCH", json: { amount: row.amount } });
}
async function finalizeDispersion() {
if (!dispersionId.value) return;
await api(`/v1/payroll/dispersions/${dispersionId.value}/finalize`, { method: "POST", json: {} });
await openDispersion();
}
async function attachDispersion(ev: Event) {
const file = (ev.target as HTMLInputElement).files?.[0];
if (!file || !dispersionId.value) return;
const body = new FormData();
body.set("file", file);
await api(`/v1/payroll/dispersions/${dispersionId.value}/attachment`, { method: "POST", body });
toast.add({ severity: "success", summary: "Comprobante anexado", life: 1800 });
}
function downloadReport(kind: "xlsx" | "pdf") {
download(`/v1/payroll/report.${kind}?date_from=${reportFrom.value}&date_to=${reportTo.value}`, `nomina.${kind}`);
}
function downloadImss() {
download(`/v1/payroll/imss-quote.xlsx?date_from=${reportFrom.value}&date_to=${reportTo.value}`, "imss.xlsx");
}
function csv() {
if (!week.value) return;
download(`/v1/payroll/weeks/${week.value.id}/csv`, `nomina-${week.value.id}.csv`).then(() => {
toast.add({ severity: "success", summary: "CSV descargado", life: 2000 });
});
}
let cutTimer: number | undefined;
async function patchCut(id: number, patch: Record<string, number>) {
if (!week.value) return;
window.clearTimeout(cutTimer);
cutTimer = window.setTimeout(async () => {
await api(`/v1/destajo/cuts/${id}`, { method: "PATCH", json: { week_id: week.value!.id, ...patch } });
await load();
}, 450);
}
async function createJob() {
if (!week.value || !jobForm.project_id || !jobForm.worker_id || !jobForm.concepto.trim()) return;
await api("/v1/destajo/jobs", { method: "POST", json: { week_id: week.value.id, ...jobForm } });
showDestajo.value = false;
jobForm.concepto = "";
jobForm.qty_total_estimated = 0;
jobForm.qty_planned = 0;
jobForm.unit_price = 0;
toast.add({ severity: "success", summary: "Destajo dado de alta", life: 1800 });
await load();
}
async function createAdmin() {
if (!week.value || !adminForm.worker_id || adminForm.amount <= 0) return;
await api(`/v1/payroll/weeks/${week.value.id}/admin`, { method: "POST", json: adminForm });
showAdmin.value = false;
adminForm.worker_id = null;
adminForm.amount = 0;
adminForm.project_id = null;
await load();
}
async function saveAdmin(lineId: number, amount: number) {
if (!week.value) return;
await api(`/v1/payroll/weeks/${week.value.id}/admin/${lineId}`, { method: "PATCH", json: { amount } });
await load();
}
async function removeAdmin(lineId: number) {
if (!week.value) return;
await api(`/v1/payroll/weeks/${week.value.id}/admin/${lineId}`, { method: "DELETE" });
await load();
}
async function addJornal() {
if (!week.value || !projectId.value || !jornalWorkerId.value) return;
await api(`/v1/payroll/weeks/${week.value.id}/jornal`, {
method: "POST",
json: { project_id: projectId.value, worker_id: jornalWorkerId.value },
});
showAddJornal.value = false;
jornalWorkerId.value = null;
await load();
}
watch(projectId, syncChrome);
watch(() => desk.detailRev.value, () => {
if (week.value) load();
});
onMounted(load);
onBeforeUnmount(() => desk.clearStatusMessages());
</script>
<style scoped>
.nomina-board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.nomina-kpi {
font-size: 1.45rem;
font-weight: 700;
margin-top: 4px;
}
.nomina-kpi.ok { color: var(--p-green-600); }
.nomina-kpi.warn { color: var(--p-orange-600); }
.destajo-cut { margin-left: 10px; }
.staff-loan-tag { max-width: 5rem; overflow: hidden; text-overflow: ellipsis; }
.person-link {
background: none;
border: 0;
padding: 0;
margin: 0;
font: inherit;
color: var(--p-primary-color, #2563eb);
cursor: pointer;
text-align: left;
font-weight: 600;
}
.person-link:hover { text-decoration: underline; }
.loan-link {
font-weight: 500;
font-size: 0.8rem;
}
.cut-input { width: 7.5rem; }
.loan-form { display: flex; flex-direction: column; gap: 10px; }
@media (max-width: 800px) {
.nomina-board { grid-template-columns: 1fr; }
}
</style>