mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 13:23:17 +00:00
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>
677 lines
25 KiB
Vue
677 lines
25 KiB
Vue
<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>
|