panels-origin/web-panel/pages/nomina.vue
Cursor Agent 9c8b6ea19c
fix: devolver días L–S y celdas de asistencia en el paquete de nómina
La UI de Jornal ya tenía checkboxes por día, pero GET /v1/payroll/weeks
no enviaba days ni cells, así que las columnas L–S no se renderizaban
y solo se veía el total Días en cero.

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

677 lines
25 KiB
Vue
Raw 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="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 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: "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();
},
});
}
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>