panels-origin/web-panel/pages/nomina.vue
2026-08-25 20:21:49 -05:00

537 lines
21 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">{{ board.delta >= 0 ? "Sobrante" : "Faltante" }}</div>
<div class="nomina-kpi" :class="board.delta >= 0 ? 'ok' : 'warn'">{{ money(Math.abs(board.delta)) }}</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">
Lun–sáb. El jueves se prellenan jue–vie–sáb. Un día marcado en otra obra viaja con esa obra (préstamo de personal).
</Message>
<DataTable :value="obraLines" scrollable scroll-height="flex" empty-message="Sin personal de jornal en esta obra">
<Column header="Persona" frozen>
<template #body="{ data }">{{ data.last_name_p }} {{ data.first_name }}</template>
</Column>
<Column
v-for="(day, idx) in days"
:key="day"
:header="dayHeaders[idx]"
style="width: 5.5rem; 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 }">
<span v-if="data.loan_label" class="loan-label">{{ data.loan_label }}</span>
<span v-else class="muted">—</span>
</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 }">{{ data.last_name_p }} {{ data.first_name }}</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 }">{{ data.last_name_p }} {{ data.first_name }}</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">
type Cell = { date: string; present: boolean; other_project_id: number | null; other_project_name: string | null };
type Line = {
id: number;
worker_id: number;
first_name: string;
last_name_p: string;
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 = {
id: number;
project_name: string;
first_name: string;
last_name_p: 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, delta: 0 });
const showDestajo = ref(false);
const showAdmin = ref(false);
const showAddJornal = ref(false);
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; last_name_p: string; 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; delta: number };
units: { code: string; label: 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: `${x.last_name_p} ${x.first_name}`,
work_type: x.work_type,
}));
week.value = bundle.week;
destajoPeriod.value = bundle.destajo_period;
sheets.value = bundle.sheets;
destajoCuts.value = bundle.destajo_cuts || [];
days.value = bundle.days || [];
board.value = bundle.board;
units.value = bundle.units || [];
syncChrome();
}
function syncChrome() {
const st = String(week.value?.status || "draft");
desk.setStatusMessages([
{
key: "nom_week",
severity: st === "paid" ? "info" : board.value.delta < 0 ? "warn" : "info",
title: st === "paid" ? "Semana pagada" : `Caja ${money(board.value.required_net)} · a pagar ${money(board.value.payable_net)}`,
detail: board.value.delta < 0 ? "Hay faltante respecto a lo armado el jueves." : "Jueves se arma, sábado se paga.",
route: "/nomina",
},
]);
desk.configure({
tab: "Nómina",
treeTitle: "Obras",
showTree: true,
showDetail: false,
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", 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: "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();
}
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;
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();
}
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);
onMounted(load);
onBeforeUnmount(() => desk.clearStatusMessages());
</script>
<style scoped>
.nomina-board {
display: grid;
grid-template-columns: repeat(3, minmax(0, 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; }
.loan-label { 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>