panels-origin/web-panel/pages/nomina.vue
2026-08-21 16:55:01 -05:00

203 lines
7.6 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">
<DeskTableBar v-model="tableQ" placeholder="Buscar periodo o proyecto">
<Chip :label="`${listedPeriods.length} periodos`" icon="pi pi-calendar" />
<Chip :label="`${openPeriods} abiertos`" icon="pi pi-unlock" />
<Chip :label="`${closedPeriods} cerrados`" icon="pi pi-lock" />
</DeskTableBar>
<Toolbar>
<template #start>
<DatePicker v-model="weekStart" date-format="yy-mm-dd" placeholder="Inicio semana" show-icon />
<DatePicker v-model="weekEnd" date-format="yy-mm-dd" placeholder="Fin semana" show-icon />
</template>
</Toolbar>
<Toolbar>
<template #start>
<Select v-model="attWorker" :options="workers" option-label="label" option-value="id" placeholder="Persona" />
<DatePicker v-model="attDate" date-format="yy-mm-dd" placeholder="Día" show-icon />
<ToggleSwitch v-model="attPresent" />
<Tag :value="attPresent ? 'Presente' : 'Falta'" :severity="attPresent ? 'success' : 'danger'" />
<Button label="Registrar" icon="pi pi-check" @click="saveAtt" />
</template>
</Toolbar>
<Message severity="secondary" :closable="false">
El neto es días presentes × jornal, menos descuentos y abono de préstamo. Solo personal activo del proyecto.
</Message>
<DataTable :value="listedPeriods" paginator :rows="12" :rows-per-page-options="[12, 25, 50]" scrollable scroll-height="flex" empty-message="Sin periodos" @row-click="(e: { data: { id: number } }) => open(e.data.id)">
<Column field="id" header="#" />
<Column field="project_name" header="Proyecto" />
<Column field="week_start" header="Desde" />
<Column field="week_end" header="Hasta" />
<Column header="Estado">
<template #body="{ data }">
<Tag :value="String(data.status)" :severity="data.status === 'closed' ? 'success' : 'warn'" />
</template>
</Column>
</DataTable>
<Dialog v-model:visible="show" header="Detalle de nómina" modal :style="{ width: '920px' }">
<DeskTableBar v-model="linesQ" placeholder="Buscar persona">
<Chip :label="`${listedLines.length} líneas`" icon="pi pi-users" />
</DeskTableBar>
<DataTable :value="listedLines" class="mt-3">
<Column field="last_name_p" header="Apellido" />
<Column field="first_name" header="Nombre" />
<Column field="days" header="Días" />
<Column field="daily_wage" header="Jornal" />
<Column field="gross" header="Bruto" />
<Column field="discounts" header="Desc." />
<Column field="loan_payment" header="Préstamo" />
<Column field="net" header="Neto" />
</DataTable>
<template #footer>
<Button label="CSV" icon="pi pi-download" outlined @click="csv" />
<Button label="Cerrar periodo" icon="pi pi-lock" @click="closeP" />
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
const { api, download } = useApi();
const desk = useDesktop();
const toast = useToast();
const projects = ref<{ id: number; name: string }[]>([]);
const projectId = ref<number | null>(null);
const weekStart = ref<Date | null>(null);
const weekEnd = ref<Date | null>(null);
const periods = ref<Record<string, unknown>[]>([]);
const workers = ref<{ id: number; label: string }[]>([]);
const attWorker = ref<number | null>(null);
const attDate = ref<Date | null>(null);
const attPresent = ref(true);
const show = ref(false);
const lines = ref<Record<string, unknown>[]>([]);
const periodId = ref(0);
const { tableQ, matches } = useTableSearch();
const linesQ = ref("");
const listedPeriods = computed(() =>
periods.value.filter((period) => matches(period.id, period.project_name, period.week_start, period.week_end, period.status)),
);
const openPeriods = computed(() => listedPeriods.value.filter((period) => period.status !== "closed").length);
const closedPeriods = computed(() => listedPeriods.value.filter((period) => period.status === "closed").length);
const listedLines = computed(() => {
const q = linesQ.value.trim().toLowerCase();
if (!q) return lines.value;
return lines.value.filter((row) =>
[row.last_name_p, row.first_name].some((part) => String(part ?? "").toLowerCase().includes(q)),
);
});
function iso(d: Date | null) {
if (!d) return "";
return d.toISOString().slice(0, 10);
}
async function load() {
const p = await api<{ projects: { id: number; name: string }[] }>("/v1/projects?status=activo,pausado");
projects.value = p.projects;
if (!projectId.value && p.projects[0]) projectId.value = p.projects[0].id;
const pe = await api<{ periods: Record<string, unknown>[] }>(
`/v1/payroll/periods${projectId.value ? `?project_id=${projectId.value}` : ""}`,
);
periods.value = pe.periods;
const w = await api<{ workers: { id: number; first_name: string; last_name_p: string }[] }>(
`/v1/workers?status=activo${projectId.value ? `&project_id=${projectId.value}` : ""}`,
);
workers.value = w.workers.map((x) => ({ id: x.id, label: `${x.last_name_p} ${x.first_name}` }));
syncChrome();
}
function syncChrome() {
const messages = [];
if (openPeriods.value > 0) {
messages.push({
key: "nom_abiertos",
severity: "info" as const,
title: `${openPeriods.value} periodos de nómina abiertos`,
detail: "Genera CSV o cierra el periodo cuando esté listo.",
route: "/nomina",
});
}
desk.setStatusMessages(messages);
desk.configure({
tab: "Nómina",
treeTitle: "Proyectos",
showTree: true,
showDetail: false,
treeKey: projectId.value ? `p:${projectId.value}` : "",
countLabel: `${periods.value.length} periodos`,
onTreeSelect: (key) => {
if (!key.startsWith("p:")) return;
projectId.value = Number(key.slice(2));
load();
},
tree: projects.value.map((p) => ({
key: `p:${p.id}`,
label: p.name,
icon: "pi pi-folder",
})),
ribbon: [
{
key: "acciones",
label: "Nómina",
items: [{ key: "gen", label: "Periodo", icon: "pi pi-calculator", command: createPeriod }],
},
{
key: "vista",
label: "Vista",
items: [{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", command: load }],
},
],
});
}
async function saveAtt() {
if (!projectId.value || !attWorker.value || !attDate.value) return;
await api("/v1/attendance", {
method: "PUT",
json: {
project_id: projectId.value,
worker_id: attWorker.value,
work_date: iso(attDate.value),
present: attPresent.value,
},
});
toast.add({ severity: "success", summary: "Asistencia", life: 1500 });
}
async function createPeriod() {
if (!projectId.value || !weekStart.value || !weekEnd.value) return;
await api("/v1/payroll/periods", {
method: "POST",
json: { project_id: projectId.value, week_start: iso(weekStart.value), week_end: iso(weekEnd.value) },
});
toast.add({ severity: "success", summary: "Periodo generado", life: 2000 });
await load();
}
async function open(id: number) {
periodId.value = id;
const r = await api<{ lines: Record<string, unknown>[] }>(`/v1/payroll/periods/${id}`);
lines.value = r.lines;
show.value = true;
}
function csv() {
download(`/v1/payroll/periods/${periodId.value}/csv`, `nomina-${periodId.value}.csv`).then(() => {
toast.add({ severity: "success", summary: "CSV descargado", life: 2500 });
});
}
async function closeP() {
await api(`/v1/payroll/periods/${periodId.value}`, { method: "PATCH", json: { status: "closed" } });
show.value = false;
await load();
}
onMounted(load);
onBeforeUnmount(() => desk.clearStatusMessages());
</script>