mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 12:43:18 +00:00
Ajustes para nomina y dashboard
This commit is contained in:
parent
085366c287
commit
97c66282bf
20 changed files with 918 additions and 203 deletions
|
|
@ -83,6 +83,14 @@ export function weekDays(weekStart: string): string[] {
|
|||
return [0, 1, 2, 3, 4, 5].map((n) => addDays(weekStart, n));
|
||||
}
|
||||
|
||||
export function weekAssembleDate(weekStart: string): string {
|
||||
return addDays(weekStart, 3);
|
||||
}
|
||||
|
||||
export function canAssembleWeek(weekStart: string, today = todayIso()): boolean {
|
||||
return today >= weekAssembleDate(weekStart);
|
||||
}
|
||||
|
||||
export function tenantKey(tenantId: number | null | undefined): number {
|
||||
return tenantId ?? 0;
|
||||
}
|
||||
|
|
@ -207,8 +215,8 @@ export function createLoan(
|
|||
const grantIso = body.grantIso ?? todayIso();
|
||||
const week = weekContaining(grantIso);
|
||||
const current = db.prepare(
|
||||
"SELECT status FROM payroll_weeks WHERE tenant_id=? AND week_start=?",
|
||||
).get(tenantId, week.weekStart) as { status: string } | undefined;
|
||||
"SELECT id, status FROM payroll_weeks WHERE tenant_id=? AND week_start=?",
|
||||
).get(tenantId, week.weekStart) as { id: number; status: string } | undefined;
|
||||
const assembled = current?.status === "assembled" || current?.status === "paid";
|
||||
const firstDue = firstDueForLoan({
|
||||
delivered,
|
||||
|
|
@ -235,7 +243,9 @@ export function createLoan(
|
|||
installments,
|
||||
firstDue,
|
||||
);
|
||||
return { id: lid(db) };
|
||||
const id = lid(db);
|
||||
if (current && current.status !== "paid") recalcWeek(db, current.id);
|
||||
return { id };
|
||||
}
|
||||
|
||||
export function presentOnOtherProject(
|
||||
|
|
@ -596,7 +606,7 @@ function prefillThuSat(db: Database, projectId: number, weekStart: string, worke
|
|||
}
|
||||
}
|
||||
|
||||
export function assembleWeek(db: Database, weekId: number) {
|
||||
export function assembleWeek(db: Database, weekId: number, nowIso = todayIso()) {
|
||||
const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
|
||||
id: number;
|
||||
week_start: string;
|
||||
|
|
@ -605,6 +615,12 @@ export function assembleWeek(db: Database, weekId: number) {
|
|||
} | undefined;
|
||||
if (!week) throw Object.assign(new Error("Semana no encontrada"), { status: 404 });
|
||||
if (week.status === "paid") throw Object.assign(new Error("La semana ya está pagada"), { status: 400 });
|
||||
if (!canAssembleWeek(week.week_start, nowIso)) {
|
||||
throw Object.assign(
|
||||
new Error("La nómina se arma el jueves o después. Aún no es jueves de esta semana."),
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const sheets = db.prepare(
|
||||
"SELECT * FROM payroll_sheets WHERE week_id=? AND kind='obra'",
|
||||
).all(weekId) as { id: number; project_id: number | null }[];
|
||||
|
|
@ -886,7 +902,7 @@ export function getWeekBundle(db: Database, tenantId: number, weekStart: string)
|
|||
|
||||
const sheets = sheetsRaw.map((sheet) => {
|
||||
const lines = db.prepare(
|
||||
`SELECT l.*, w.first_name, w.last_name_p, w.position, w.work_type
|
||||
`SELECT l.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, w.position, w.work_type
|
||||
FROM payroll_week_lines l
|
||||
JOIN workers w ON w.id=l.worker_id
|
||||
WHERE l.sheet_id=?
|
||||
|
|
@ -913,7 +929,7 @@ export function getWeekBundle(db: Database, tenantId: number, weekStart: string)
|
|||
});
|
||||
|
||||
const jobs = db.prepare(
|
||||
`SELECT j.*, w.first_name, w.last_name_p, p.name AS project_name
|
||||
`SELECT j.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, p.name AS project_name
|
||||
FROM destajo_jobs j
|
||||
JOIN workers w ON w.id=j.worker_id
|
||||
JOIN projects p ON p.id=j.project_id
|
||||
|
|
@ -924,7 +940,7 @@ export function getWeekBundle(db: Database, tenantId: number, weekStart: string)
|
|||
const cuts = period
|
||||
? db.prepare(
|
||||
`SELECT c.*, j.concepto, j.unit_code, j.unit_price, j.qty_total_estimated, j.worker_id, j.project_id,
|
||||
j.status AS job_status, w.first_name, w.last_name_p, p.name AS project_name
|
||||
j.status AS job_status, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, p.name AS project_name
|
||||
FROM destajo_cut_lines c
|
||||
JOIN destajo_jobs j ON j.id=c.job_id
|
||||
JOIN workers w ON w.id=j.worker_id
|
||||
|
|
@ -937,6 +953,14 @@ export function getWeekBundle(db: Database, tenantId: number, weekStart: string)
|
|||
const units = listUnits(db, tenantId);
|
||||
const required = Number(week.required_net) || 0;
|
||||
const payable = Number(week.payable_net) || 0;
|
||||
const recoveryRow = db.prepare(
|
||||
`SELECT COALESCE(SUM(l.loan_discount), 0) AS n
|
||||
FROM payroll_week_lines l
|
||||
JOIN payroll_sheets s ON s.id=l.sheet_id
|
||||
WHERE s.week_id=?`,
|
||||
).get(weekId) as { n: number };
|
||||
const loanRecovery = roundMoney(recoveryRow.n);
|
||||
const faltante = roundMoney(Math.max(0, payable - required));
|
||||
return {
|
||||
week,
|
||||
destajo_period: period,
|
||||
|
|
@ -949,8 +973,12 @@ export function getWeekBundle(db: Database, tenantId: number, weekStart: string)
|
|||
board: {
|
||||
required_net: required,
|
||||
payable_net: payable,
|
||||
loan_recovery: loanRecovery,
|
||||
faltante,
|
||||
delta: roundMoney(required - payable),
|
||||
},
|
||||
can_assemble: canAssembleWeek(start),
|
||||
assemble_from: weekAssembleDate(start),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -992,6 +1020,13 @@ export function weekCsv(db: Database, weekId: number): string {
|
|||
|
||||
export function listOpenWeeks(db: Database, tenantId: number) {
|
||||
return db.prepare(
|
||||
`SELECT * FROM payroll_weeks WHERE tenant_id=? AND status != 'paid' ORDER BY week_start DESC`,
|
||||
`SELECT w.*,
|
||||
(SELECT COALESCE(SUM(l.loan_discount), 0)
|
||||
FROM payroll_week_lines l
|
||||
JOIN payroll_sheets s ON s.id=l.sheet_id
|
||||
WHERE s.week_id=w.id) AS loan_recovery
|
||||
FROM payroll_weeks w
|
||||
WHERE w.tenant_id=? AND w.status != 'paid'
|
||||
ORDER BY w.week_start DESC`,
|
||||
).all(tenantId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { AuthUser } from "./auth.ts";
|
|||
import { tenantScope, requireAuth } from "./auth.ts";
|
||||
import { getDb, lastInsertId, projectById, projectMustBe } from "./db.ts";
|
||||
import { generateLoanReceiptPdf } from "./pdf.ts";
|
||||
import { fullName } from "./mx.ts";
|
||||
import {
|
||||
addAdminLine,
|
||||
addJornalWorker,
|
||||
|
|
@ -157,7 +158,8 @@ export function registerPayrollRoutes(app: App) {
|
|||
const id = Number(c.req.param("id"));
|
||||
const db = await getDb();
|
||||
const loan = db.prepare(
|
||||
`SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.id=?`,
|
||||
`SELECT l.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m
|
||||
FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.id=?`,
|
||||
).get(id) as {
|
||||
delivered: number;
|
||||
commission_pct: number;
|
||||
|
|
@ -169,14 +171,16 @@ export function registerPayrollRoutes(app: App) {
|
|||
first_due: string;
|
||||
created_at: string;
|
||||
first_name: string;
|
||||
middle_name: string | null;
|
||||
last_name_p: string;
|
||||
last_name_m: string;
|
||||
note: string | null;
|
||||
} | undefined;
|
||||
if (!loan) return c.json({ error: "Préstamo no encontrado" }, 404);
|
||||
const bytes = await generateLoanReceiptPdf({
|
||||
title: "Recibo de aceptación de préstamo",
|
||||
subtitle: `Registrado ${String(loan.created_at).slice(0, 10)}`,
|
||||
person: `${loan.last_name_p} ${loan.first_name}`,
|
||||
person: fullName(loan),
|
||||
lines: [
|
||||
{ label: "Entregado", value: moneyLabel(loan.delivered) },
|
||||
{
|
||||
|
|
@ -202,7 +206,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
const id = Number(c.req.param("id"));
|
||||
const db = await getDb();
|
||||
const pay = db.prepare(
|
||||
`SELECT p.*, l.delivered, l.commission_pct, w.first_name, w.last_name_p, wk.week_end
|
||||
`SELECT p.*, l.delivered, l.commission_pct, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, wk.week_end
|
||||
FROM loan_payments p
|
||||
JOIN loans l ON l.id=p.loan_id
|
||||
JOIN workers w ON w.id=l.worker_id
|
||||
|
|
@ -215,14 +219,16 @@ export function registerPayrollRoutes(app: App) {
|
|||
delivered: number;
|
||||
commission_pct: number;
|
||||
first_name: string;
|
||||
middle_name: string | null;
|
||||
last_name_p: string;
|
||||
last_name_m: string;
|
||||
week_end: string;
|
||||
} | undefined;
|
||||
if (!pay) return c.json({ error: "Recibo no encontrado" }, 404);
|
||||
const bytes = await generateLoanReceiptPdf({
|
||||
title: "Recibo de pago de préstamo",
|
||||
subtitle: `Descuento del sábado ${pay.week_end}`,
|
||||
person: `${pay.last_name_p} ${pay.first_name}`,
|
||||
person: fullName(pay),
|
||||
lines: [
|
||||
{ label: "Descuento", value: moneyLabel(pay.amount) },
|
||||
{ label: "Exhibición", value: String(pay.installment_n) },
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { assertEquals, assertExists } from "jsr:@std/assert@1";
|
||||
import { assertEquals, assertExists, assertThrows } from "jsr:@std/assert@1";
|
||||
import { Database } from "@db/sqlite";
|
||||
import {
|
||||
addDays,
|
||||
assembleWeek,
|
||||
canAssembleWeek,
|
||||
commissionAmount,
|
||||
createDestajoJob,
|
||||
createLoan,
|
||||
|
|
@ -17,6 +18,7 @@ import {
|
|||
roundMoney,
|
||||
setAttendance,
|
||||
thisSaturday,
|
||||
weekAssembleDate,
|
||||
weekContaining,
|
||||
} from "./payroll.ts";
|
||||
|
||||
|
|
@ -117,6 +119,27 @@ Deno.test("semana lun-sáb y corte destajo jue-jue", () => {
|
|||
assertEquals(d.periodStart, "2026-08-20");
|
||||
});
|
||||
|
||||
Deno.test("armar jueves solo a partir del jueves de esa semana", () => {
|
||||
assertEquals(weekAssembleDate("2026-08-24"), "2026-08-27");
|
||||
assertEquals(canAssembleWeek("2026-08-24", "2026-08-26"), false);
|
||||
assertEquals(canAssembleWeek("2026-08-24", "2026-08-27"), true);
|
||||
assertEquals(canAssembleWeek("2026-08-24", "2026-08-29"), true);
|
||||
assertEquals(canAssembleWeek("2026-08-24", "2026-08-31"), true);
|
||||
assertEquals(canAssembleWeek("2026-08-31", "2026-08-26"), false);
|
||||
const db = mem();
|
||||
const weekId = ensureWeek(db, 1, "2026-08-24");
|
||||
assertThrows(
|
||||
() => assembleWeek(db, weekId, "2026-08-26"),
|
||||
Error,
|
||||
"jueves",
|
||||
);
|
||||
const draft = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string };
|
||||
assertEquals(draft.status, "draft");
|
||||
assembleWeek(db, weekId, "2026-08-28");
|
||||
const after = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string };
|
||||
assertEquals(after.status, "assembled");
|
||||
});
|
||||
|
||||
Deno.test("comisión suma al saldo", () => {
|
||||
assertEquals(commissionAmount(500, 10), 50);
|
||||
assertEquals(roundMoney(500 + 50), 550);
|
||||
|
|
@ -162,7 +185,7 @@ Deno.test("armar jueves prellena jue-vie-sáb y congela requerida", () => {
|
|||
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run();
|
||||
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run();
|
||||
const weekId = ensureWeek(db, 1, "2026-08-24");
|
||||
assembleWeek(db, weekId);
|
||||
assembleWeek(db, weekId, "2026-08-27");
|
||||
const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
|
||||
status: string;
|
||||
required_net: number;
|
||||
|
|
@ -182,7 +205,7 @@ Deno.test("falta viernes baja a pagar y no toca requerida", () => {
|
|||
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run();
|
||||
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run();
|
||||
const weekId = ensureWeek(db, 1, "2026-08-24");
|
||||
assembleWeek(db, weekId);
|
||||
assembleWeek(db, weekId, "2026-08-27");
|
||||
const r = setAttendance(db, 1, { project_id: 1, worker_id: 1, work_date: "2026-08-28", present: false });
|
||||
assertEquals("ok" in r && r.ok, true);
|
||||
const week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
|
||||
|
|
@ -200,7 +223,7 @@ Deno.test("préstamo no baja saldo al armar; sí al pagar", () => {
|
|||
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-26', 1)").run();
|
||||
createLoan(db, 1, { worker_id: 1, delivered: 2000, commission_pct: 10, grantIso: "2026-08-24" });
|
||||
const weekId = ensureWeek(db, 1, "2026-08-24");
|
||||
assembleWeek(db, weekId);
|
||||
assembleWeek(db, weekId, "2026-08-27");
|
||||
const before = db.prepare("SELECT balance FROM loans WHERE worker_id=1").get() as { balance: number };
|
||||
assertEquals(before.balance, 2200);
|
||||
const week = db.prepare("SELECT required_net FROM payroll_weeks WHERE id=?").get(weekId) as { required_net: number };
|
||||
|
|
@ -210,6 +233,26 @@ Deno.test("préstamo no baja saldo al armar; sí al pagar", () => {
|
|||
assertEquals(after.balance, 0);
|
||||
});
|
||||
|
||||
Deno.test("tablero: recuperación de préstamos no es sobrante", () => {
|
||||
const db = mem();
|
||||
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run();
|
||||
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run();
|
||||
const weekId = ensureWeek(db, 1, "2026-08-24");
|
||||
assembleWeek(db, weekId, "2026-08-27");
|
||||
createLoan(db, 1, { worker_id: 1, delivered: 1000, commission_pct: 20, grantIso: "2026-08-24" });
|
||||
const bundle = getWeekBundle(db, 1, "2026-08-24");
|
||||
const board = bundle.board as {
|
||||
required_net: number;
|
||||
payable_net: number;
|
||||
loan_recovery: number;
|
||||
faltante: number;
|
||||
};
|
||||
assertEquals(board.required_net, 2500);
|
||||
assertEquals(board.loan_recovery, 1200);
|
||||
assertEquals(board.payable_net, 1300);
|
||||
assertEquals(board.faltante, 0);
|
||||
});
|
||||
|
||||
Deno.test("un día no se marca en dos obras", () => {
|
||||
const db = mem();
|
||||
db.prepare("INSERT INTO projects (id, code, name, status, tenant_id) VALUES (2, 'PRY-0002', 'Obra Sur', 'activo', 1)").run();
|
||||
|
|
@ -223,7 +266,7 @@ Deno.test("préstamo de personal: el día en otra obra no se prellena aquí", ()
|
|||
db.prepare("INSERT INTO projects (id, code, name, status, tenant_id) VALUES (2, 'PRY-0002', 'Obra Sur', 'activo', 1)").run();
|
||||
setAttendance(db, 1, { project_id: 2, worker_id: 1, work_date: "2026-08-27", present: true });
|
||||
const weekId = ensureWeek(db, 1, "2026-08-24");
|
||||
assembleWeek(db, weekId);
|
||||
assembleWeek(db, weekId, "2026-08-27");
|
||||
const onA = db.prepare(
|
||||
"SELECT present FROM attendance WHERE worker_id=1 AND project_id=1 AND work_date='2026-08-27'",
|
||||
).get() as { present: number } | undefined;
|
||||
|
|
@ -251,7 +294,7 @@ Deno.test("destajo: estimado del corte, realizado menor, remanente y extra viern
|
|||
unit_price: 100,
|
||||
qty_planned: 80,
|
||||
});
|
||||
assembleWeek(db, weekId);
|
||||
assembleWeek(db, weekId, "2026-08-27");
|
||||
let week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
|
||||
required_net: number;
|
||||
payable_net: number;
|
||||
|
|
|
|||
|
|
@ -220,7 +220,7 @@ html, body, #__nuxt {
|
|||
}
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(11.5rem, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.kpi-value {
|
||||
|
|
@ -229,7 +229,56 @@ html, body, #__nuxt {
|
|||
color: #0f2744;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.home-split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.home-split .list-page { min-height: 0; }
|
||||
.dash {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.dash-charts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.dash-span-2 { grid-column: span 2; }
|
||||
.dash-chart-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #0f2744;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.dash-chart {
|
||||
height: 240px;
|
||||
position: relative;
|
||||
}
|
||||
.dash-chart--bar { height: 280px; }
|
||||
.dash-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.dash-kpi-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.dash-kpi.clickable { cursor: pointer; }
|
||||
.dash-kpi.clickable:hover {
|
||||
outline: 1px solid #bcd6ff;
|
||||
}
|
||||
.dash-shortcuts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
|
@ -313,7 +362,8 @@ html, body, #__nuxt {
|
|||
min-width: 0;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.kpi-grid, .home-split, .form-grid {
|
||||
.kpi-grid, .home-split, .form-grid, .dash-charts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.dash-span-2 { grid-column: auto; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1865,9 +1865,13 @@ html.dark .document-check.complete {
|
|||
background: rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
|
||||
html.dark .kpi-value {
|
||||
html.dark .kpi-value,
|
||||
html.dark .dash-chart-label {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
html.dark .dash-kpi.clickable:hover {
|
||||
outline-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Avisos de inicio (statusbar popup) */
|
||||
html.dark .desk-status-messages-inner {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
:key="item.key"
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
:title="item.title"
|
||||
:class="['ribbon-action', `ribbon-action--${item.key}`]"
|
||||
icon-pos="top"
|
||||
text
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
<div class="worker-imss-head">
|
||||
<div class="worker-imss-title">IMSS</div>
|
||||
<div class="worker-imss-actions">
|
||||
<template v-if="canEdit">
|
||||
<Button
|
||||
v-if="data.worker.imss_status !== 'alta'"
|
||||
label="Registrar alta"
|
||||
|
|
@ -65,6 +66,7 @@
|
|||
@click="openImss('baja')"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<Button
|
||||
label="Historial"
|
||||
icon="pi pi-history"
|
||||
|
|
@ -95,7 +97,14 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Button label="Editar registro" icon="pi pi-pencil" outlined class="w-full worker-record-edit" @click="emit('edit')" />
|
||||
<Button
|
||||
v-if="!canEdit"
|
||||
label="Ir a Padrón"
|
||||
icon="pi pi-users"
|
||||
outlined
|
||||
class="w-full worker-record-edit"
|
||||
@click="goPadron"
|
||||
/>
|
||||
|
||||
<div class="worker-mini-stats">
|
||||
<div><strong>{{ requiredDone }}/{{ requiredItems.length }}</strong><span>Documentos</span></div>
|
||||
|
|
@ -117,6 +126,7 @@
|
|||
:checklist="data.checklist"
|
||||
:companies="companies"
|
||||
:initial-type="doc"
|
||||
:readonly="!canEdit"
|
||||
:freshness-required="data.freshness_required || data.worker.freshness_required"
|
||||
:imss-ready="data.worker.imss_ready"
|
||||
:in-project-without-imss="data.worker.in_project_without_imss"
|
||||
|
|
@ -129,35 +139,22 @@
|
|||
:worker-id="workerId"
|
||||
:assignments="data.assignments"
|
||||
:projects="projects"
|
||||
:readonly="!canEdit"
|
||||
@changed="refreshRelated"
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel value="nomina">
|
||||
<Message :closable="false" class="nomina-jornal-msg">Jornal {{ money(Number(data.worker.daily_wage)) }} · {{ data.worker.work_type === "D" ? "Destajo" : "Normal" }}</Message>
|
||||
<Fieldset legend="Registrar préstamo" class="nomina-loan-fieldset">
|
||||
<FormField label="Monto a entregar">
|
||||
<InputNumber v-model="loanAmount" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
|
||||
</FormField>
|
||||
<div class="loan-switch">
|
||||
<ToggleSwitch v-model="loanCommissionOn" />
|
||||
<span>Comisión {{ loanCommissionPct }}%</span>
|
||||
</div>
|
||||
<FormField v-if="loanCommissionOn" label="Porcentaje" class="mt-2">
|
||||
<InputNumber v-model="loanCommissionPct" suffix=" %" :min="0" :max="100" :max-fraction-digits="2" class="w-full" />
|
||||
</FormField>
|
||||
<Message v-if="loanIsSmall" severity="secondary" :closable="false" class="mt-2">
|
||||
Préstamo chico (tope {{ money(loanSmallMax) }}): 1 exhibición el sábado de la semana siguiente. Recibe {{ money(loanAmount) }}; se descuenta {{ money(loanAmount + (loanCommissionOn ? loanAmount * loanCommissionPct / 100 : 0)) }}.
|
||||
<Message severity="secondary" :closable="false" class="mt-2">
|
||||
<template v-if="canEdit">Use <strong>Préstamo</strong> en la barra para registrar. Esta ficha no edita los datos de la persona.</template>
|
||||
<template v-else>La ficha es solo consulta. El préstamo se registra aquí; para editar datos de la persona, abra Padrón.</template>
|
||||
</Message>
|
||||
<template v-else>
|
||||
<FormField label="Plan" class="mt-2">
|
||||
<Select v-model="loanPlan" :options="planOpts" option-label="label" option-value="value" class="w-full" />
|
||||
</FormField>
|
||||
<FormField v-if="loanPlan === 'installments'" label="Cuotas" class="mt-2">
|
||||
<InputNumber v-model="loanInstallments" :min="2" :max="24" class="w-full" />
|
||||
</FormField>
|
||||
</template>
|
||||
<Button label="Registrar préstamo" icon="pi pi-plus" class="w-full mt-2" :disabled="loanAmount <= 0" @click="addLoan" />
|
||||
</Fieldset>
|
||||
<Button
|
||||
label="Registrar préstamo"
|
||||
icon="pi pi-wallet"
|
||||
class="w-full mt-2"
|
||||
@click="registerLoan"
|
||||
/>
|
||||
<DataTable :value="data.loans" size="small" class="mt-3" empty-message="Sin préstamos">
|
||||
<Column header="Entregado"><template #body="{ data: row }">{{ money(Number(row.delivered || row.amount)) }}</template></Column>
|
||||
<Column header="Saldo"><template #body="{ data: row }">{{ money(Number(row.balance)) }}</template></Column>
|
||||
|
|
@ -191,7 +188,6 @@
|
|||
import WorkerDocuments from "~/components/padron/WorkerDocuments.vue";
|
||||
import WorkerAssignments from "~/components/padron/WorkerAssignments.vue";
|
||||
import AltaImssDialog from "~/components/padron/AltaImssDialog.vue";
|
||||
import FormField from "~/components/padron/FormField.vue";
|
||||
import type { ChecklistItem, CompanyOption, ProjectOption, Worker } from "~/types/padron";
|
||||
|
||||
type DetailData = {
|
||||
|
|
@ -214,12 +210,14 @@ type DetailData = {
|
|||
freshness_required?: boolean;
|
||||
};
|
||||
|
||||
const props = defineProps<{ workerId?: number; doc?: string }>();
|
||||
const emit = defineEmits<{ edit: [] }>();
|
||||
const props = defineProps<{ workerId?: number; doc?: string; tab?: string }>();
|
||||
const emit = defineEmits<{ edit: []; changed: [] }>();
|
||||
const { pipelineLabel, pipelineSeverity, initials, riskLevelLabel, riskLevelSeverity, hireSeverity } = useUi();
|
||||
const { api, download } = useApi();
|
||||
const toast = useToast();
|
||||
const desk = useDesktop();
|
||||
const route = useRoute();
|
||||
const canEdit = computed(() => route.path.startsWith("/padron"));
|
||||
|
||||
const data = ref<DetailData | null>(null);
|
||||
const projects = ref<ProjectOption[]>([]);
|
||||
|
|
@ -229,17 +227,6 @@ const tab = ref("docs");
|
|||
const photoOk = ref(true);
|
||||
const showAltaImss = ref(false);
|
||||
const imssDialogTab = ref<"alta" | "baja" | "historial">("alta");
|
||||
const loanAmount = ref(0);
|
||||
const loanCommissionOn = ref(true);
|
||||
const loanCommissionPct = ref(10);
|
||||
const loanSmallMax = ref(500);
|
||||
const loanPlan = ref<"single" | "installments">("single");
|
||||
const loanInstallments = ref(4);
|
||||
const planOpts = [
|
||||
{ label: "1 exhibición", value: "single" },
|
||||
{ label: "Varias cuotas", value: "installments" },
|
||||
];
|
||||
const loanIsSmall = computed(() => loanAmount.value > 0 && loanAmount.value <= loanSmallMax.value);
|
||||
const title = computed(() => data.value ? [data.value.worker.first_name, data.value.worker.middle_name, data.value.worker.last_name_p, data.value.worker.last_name_m].filter(Boolean).join(" ") : "Expediente");
|
||||
const recordCode = computed(() => data.value ? `PER-${String(data.value.worker.id).padStart(4, "0")}` : "PER-0000");
|
||||
const photoSrc = computed(() => `/v1/workers/${props.workerId}/photo?v=${desk.detailRev.value}`);
|
||||
|
|
@ -295,19 +282,15 @@ async function load() {
|
|||
if (!props.workerId) { data.value = null; return; }
|
||||
loading.value = true;
|
||||
try {
|
||||
const [detail, projectResponse, companyResponse, payrollSettings] = await Promise.all([
|
||||
const [detail, projectResponse, companyResponse] = await Promise.all([
|
||||
api<DetailData>(`/v1/workers/${props.workerId}`),
|
||||
api<{ projects: ProjectOption[] }>("/v1/projects?status=activo"),
|
||||
api<{ companies: CompanyOption[] }>("/v1/companies"),
|
||||
api<{ settings: { loan_commission_enabled: number; loan_commission_pct: number; loan_small_max: number } }>("/v1/payroll/settings"),
|
||||
]);
|
||||
data.value = detail;
|
||||
projects.value = projectResponse.projects;
|
||||
companies.value = companyResponse.companies.filter((c) => c.status === "activo");
|
||||
photoOk.value = true;
|
||||
loanCommissionOn.value = Boolean(payrollSettings.settings.loan_commission_enabled);
|
||||
loanCommissionPct.value = Number(payrollSettings.settings.loan_commission_pct);
|
||||
loanSmallMax.value = Number(payrollSettings.settings.loan_small_max);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
|
@ -327,40 +310,22 @@ function onImssSaved(kind: "alta" | "baja") {
|
|||
});
|
||||
refreshRelated();
|
||||
}
|
||||
async function addLoan() {
|
||||
if (!props.workerId || loanAmount.value <= 0) return;
|
||||
const created = await api<{ id: number }>("/v1/loans", {
|
||||
method: "POST",
|
||||
json: {
|
||||
worker_id: props.workerId,
|
||||
delivered: loanAmount.value,
|
||||
commission_enabled: loanCommissionOn.value,
|
||||
commission_pct: loanCommissionPct.value,
|
||||
plan: loanIsSmall.value ? "single" : loanPlan.value,
|
||||
installments_n: loanIsSmall.value ? 1 : loanInstallments.value,
|
||||
},
|
||||
});
|
||||
loanAmount.value = 0;
|
||||
toast.add({ severity: "success", summary: "Préstamo registrado", life: 1800 });
|
||||
refreshRelated();
|
||||
if (created.id) download(`/v1/loans/${created.id}/recibo`, `prestamo-aceptacion-${created.id}.pdf`);
|
||||
function goPadron() {
|
||||
if (!props.workerId) return;
|
||||
navigateTo(`/padron?worker=${props.workerId}`);
|
||||
}
|
||||
function registerLoan() {
|
||||
if (!props.workerId) return;
|
||||
desk.openLoan(props.workerId, title.value);
|
||||
}
|
||||
function downloadLoan(id: number) {
|
||||
download(`/v1/loans/${id}/recibo`, `prestamo-aceptacion-${id}.pdf`);
|
||||
}
|
||||
|
||||
watch(() => [props.workerId, props.doc, desk.detailRev.value] as const, ([, doc]) => {
|
||||
if (doc) tab.value = "docs";
|
||||
watch(() => [props.workerId, props.tab] as const, ([, fichaTab]) => {
|
||||
tab.value = fichaTab || "docs";
|
||||
});
|
||||
watch(() => [props.workerId, desk.detailRev.value] as const, () => {
|
||||
load();
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loan-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
119
web-panel/components/padron/LoanDialog.vue
Normal file
119
web-panel/components/padron/LoanDialog.vue
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
<template>
|
||||
<Dialog
|
||||
:visible="visible"
|
||||
header="Registrar préstamo"
|
||||
modal
|
||||
:style="{ width: '28rem' }"
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
>
|
||||
<p v-if="personName" class="muted mb-3">{{ personName }}</p>
|
||||
<FormField label="Monto a entregar" required>
|
||||
<InputNumber v-model="loanAmount" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
|
||||
</FormField>
|
||||
<div class="loan-switch">
|
||||
<ToggleSwitch v-model="loanCommissionOn" />
|
||||
<span>Comisión {{ loanCommissionPct }}%</span>
|
||||
</div>
|
||||
<FormField v-if="loanCommissionOn" label="Porcentaje" class="mt-2">
|
||||
<InputNumber v-model="loanCommissionPct" suffix=" %" :min="0" :max="100" :max-fraction-digits="2" class="w-full" />
|
||||
</FormField>
|
||||
<Message v-if="loanIsSmall" severity="secondary" :closable="false" class="mt-3">
|
||||
Préstamo chico (tope {{ money(loanSmallMax) }}): 1 exhibición el sábado de la semana siguiente.
|
||||
Recibe {{ money(loanAmount) }}; se descuenta {{ money(loanAmount + (loanCommissionOn ? loanAmount * loanCommissionPct / 100 : 0)) }}.
|
||||
</Message>
|
||||
<template v-else>
|
||||
<FormField label="Plan" class="mt-3">
|
||||
<Select v-model="loanPlan" :options="planOpts" option-label="label" option-value="value" class="w-full" />
|
||||
</FormField>
|
||||
<FormField v-if="loanPlan === 'installments'" label="Cuotas" class="mt-2">
|
||||
<InputNumber v-model="loanInstallments" :min="2" :max="24" class="w-full" />
|
||||
</FormField>
|
||||
</template>
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text @click="emit('update:visible', false)" />
|
||||
<Button label="Registrar" icon="pi pi-check" :disabled="loanAmount <= 0 || saving || !workerId" :loading="saving" @click="save" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FormField from "~/components/padron/FormField.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
workerId?: number;
|
||||
personName?: string;
|
||||
}>();
|
||||
const emit = defineEmits<{ "update:visible": [boolean]; saved: [number] }>();
|
||||
const { api, download } = useApi();
|
||||
const toast = useToast();
|
||||
|
||||
const loanAmount = ref(0);
|
||||
const loanCommissionOn = ref(true);
|
||||
const loanCommissionPct = ref(10);
|
||||
const loanSmallMax = ref(500);
|
||||
const loanPlan = ref<"single" | "installments">("single");
|
||||
const loanInstallments = ref(4);
|
||||
const saving = ref(false);
|
||||
const planOpts = [
|
||||
{ label: "1 exhibición", value: "single" },
|
||||
{ label: "Varias cuotas", value: "installments" },
|
||||
];
|
||||
const loanIsSmall = computed(() => loanAmount.value > 0 && loanAmount.value <= loanSmallMax.value);
|
||||
|
||||
function money(value: number) {
|
||||
return value.toLocaleString("es-MX", { style: "currency", currency: "MXN", maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const payrollSettings = await api<{
|
||||
settings: { loan_commission_enabled: number; loan_commission_pct: number; loan_small_max: number };
|
||||
}>("/v1/payroll/settings");
|
||||
loanCommissionOn.value = Boolean(payrollSettings.settings.loan_commission_enabled);
|
||||
loanCommissionPct.value = Number(payrollSettings.settings.loan_commission_pct);
|
||||
loanSmallMax.value = Number(payrollSettings.settings.loan_small_max);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!props.workerId || loanAmount.value <= 0) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const created = await api<{ id: number }>("/v1/loans", {
|
||||
method: "POST",
|
||||
json: {
|
||||
worker_id: props.workerId,
|
||||
delivered: loanAmount.value,
|
||||
commission_enabled: loanCommissionOn.value,
|
||||
commission_pct: loanCommissionPct.value,
|
||||
plan: loanIsSmall.value ? "single" : loanPlan.value,
|
||||
installments_n: loanIsSmall.value ? 1 : loanInstallments.value,
|
||||
},
|
||||
});
|
||||
loanAmount.value = 0;
|
||||
toast.add({ severity: "success", summary: "Préstamo registrado", life: 1800 });
|
||||
emit("update:visible", false);
|
||||
emit("saved", created.id);
|
||||
if (created.id) download(`/v1/loans/${created.id}/recibo`, `prestamo-aceptacion-${created.id}.pdf`);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.visible, async (open) => {
|
||||
if (!open) return;
|
||||
loanAmount.value = 0;
|
||||
loanPlan.value = "single";
|
||||
loanInstallments.value = 4;
|
||||
await loadSettings();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loan-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
<div class="detail-section">
|
||||
<div class="section-title">Asignaciones a proyectos</div>
|
||||
<div class="muted">Una persona puede trabajar en varios proyectos activos al mismo tiempo.</div>
|
||||
<div class="assignment-create mt-3">
|
||||
<div v-if="!readonly" class="assignment-create mt-3">
|
||||
<Select v-model="projectId" :options="availableProjects" option-label="name" option-value="id" placeholder="Proyecto activo" class="w-full" />
|
||||
<Button label="Asignar" icon="pi pi-plus" :disabled="!projectId" @click="assignProject" />
|
||||
</div>
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
<Column header="Estado">
|
||||
<template #body="{ data }"><Tag :value="data.active ? 'Activa' : 'Finalizada'" :severity="data.active ? 'success' : 'secondary'" /></template>
|
||||
</Column>
|
||||
<Column header="">
|
||||
<Column v-if="!readonly" header="">
|
||||
<template #body="{ data }">
|
||||
<Button v-if="data.active" icon="pi pi-times" text rounded severity="danger" size="small" aria-label="Finalizar asignación" @click="deactivate(data)" />
|
||||
<Button v-else icon="pi pi-refresh" text rounded size="small" aria-label="Reactivar asignación" @click="reactivate(data)" />
|
||||
|
|
@ -31,7 +31,7 @@
|
|||
import type { ProjectOption } from "~/types/padron";
|
||||
|
||||
type Assignment = { id: number; project_id: number; project_name: string; project_code: string; active: number; start_date: string; end_date?: string | null };
|
||||
const props = defineProps<{ workerId: number; assignments: Assignment[]; projects: ProjectOption[] }>();
|
||||
const props = defineProps<{ workerId: number; assignments: Assignment[]; projects: ProjectOption[]; readonly?: boolean }>();
|
||||
const emit = defineEmits<{ changed: [] }>();
|
||||
const { api } = useApi();
|
||||
const confirm = useConfirm();
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@
|
|||
</Message>
|
||||
<Message v-else-if="imssReady" severity="info" :closable="false" class="mb-3">
|
||||
Expediente listo para alta IMSS.
|
||||
<Button label="Registrar alta" link class="p-0 ml-1" @click="emit('registerAlta')" />
|
||||
<Button v-if="!readonly" label="Registrar alta" link class="p-0 ml-1" @click="emit('registerAlta')" />
|
||||
</Message>
|
||||
<Message v-else-if="inProjectWithoutImss" severity="error" :closable="false" class="mb-3">
|
||||
Está en obra sin alta IMSS.
|
||||
<Button label="Registrar alta" link class="p-0 ml-1" @click="emit('registerAlta')" />
|
||||
<Button v-if="!readonly" label="Registrar alta" link class="p-0 ml-1" @click="emit('registerAlta')" />
|
||||
</Message>
|
||||
|
||||
<div class="document-progress">
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
</div>
|
||||
</Panel>
|
||||
|
||||
<Fieldset legend="Subir o reemplazar documento" class="mt-3">
|
||||
<Fieldset v-if="!readonly" legend="Subir o reemplazar documento" class="mt-3">
|
||||
<FormField label="Tipo de documento">
|
||||
<Select
|
||||
v-model="documentType"
|
||||
|
|
@ -157,6 +157,7 @@ const props = defineProps<{
|
|||
checklist: ChecklistItem[];
|
||||
companies?: CompanyOption[];
|
||||
initialType?: string;
|
||||
readonly?: boolean;
|
||||
freshnessRequired?: boolean;
|
||||
imssReady?: boolean;
|
||||
inProjectWithoutImss?: boolean;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export type RibbonItem = {
|
|||
label: string;
|
||||
icon: string;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
command?: () => void;
|
||||
};
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ export type DeskDetail = {
|
|||
kind: "none" | "worker" | "project" | "budget";
|
||||
id?: number;
|
||||
doc?: string;
|
||||
tab?: string;
|
||||
project?: Record<string, unknown>;
|
||||
budgetItem?: Record<string, unknown>;
|
||||
};
|
||||
|
|
@ -97,8 +99,8 @@ export function useDesktop() {
|
|||
onTreeSelect.value?.(key);
|
||||
}
|
||||
|
||||
function openWorker(id: number, doc?: string) {
|
||||
detail.value = { kind: "worker", id, doc };
|
||||
function openWorker(id: number, doc?: string, tab?: string) {
|
||||
detail.value = { kind: "worker", id, doc, tab };
|
||||
showDetail.value = true;
|
||||
}
|
||||
|
||||
|
|
@ -120,6 +122,18 @@ export function useDesktop() {
|
|||
detailRev.value += 1;
|
||||
}
|
||||
|
||||
const loanPrompt = useState<{ visible: boolean; workerId?: number; personName?: string }>("desk-loan", () => ({
|
||||
visible: false,
|
||||
}));
|
||||
|
||||
function openLoan(workerId: number, personName?: string) {
|
||||
loanPrompt.value = { visible: true, workerId, personName };
|
||||
}
|
||||
|
||||
function closeLoan() {
|
||||
loanPrompt.value = { visible: false };
|
||||
}
|
||||
|
||||
return {
|
||||
tabLabel,
|
||||
ribbon,
|
||||
|
|
@ -143,5 +157,8 @@ export function useDesktop() {
|
|||
openBudgetItem,
|
||||
clearDetail,
|
||||
bumpDetail,
|
||||
loanPrompt,
|
||||
openLoan,
|
||||
closeLoan,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@
|
|||
v-if="detail.kind === 'worker'"
|
||||
:worker-id="detail.id"
|
||||
:doc="detail.doc"
|
||||
:tab="detail.tab"
|
||||
@edit="editFromFicha"
|
||||
/>
|
||||
<ProjectFicha
|
||||
|
|
@ -92,10 +93,19 @@
|
|||
</div>
|
||||
|
||||
<AppStatusbar />
|
||||
<LoanDialog
|
||||
:visible="loanPrompt.visible"
|
||||
:worker-id="loanPrompt.workerId"
|
||||
:person-name="loanPrompt.personName"
|
||||
@update:visible="setLoanVisible"
|
||||
@saved="onLoanSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LoanDialog from "~/components/padron/LoanDialog.vue";
|
||||
|
||||
const { user, logout } = useAuth();
|
||||
const { initials } = useUi();
|
||||
const route = useRoute();
|
||||
|
|
@ -103,17 +113,28 @@ const desk = useDesktop();
|
|||
const toast = useToast();
|
||||
const { isDark, toggle: toggleThemeMode } = useTheme();
|
||||
const profileMenu = ref();
|
||||
const { tabLabel, showTree, showDetail, detail, globalQ } = desk;
|
||||
const { tabLabel, showTree, showDetail, detail, globalQ, loanPrompt } = desk;
|
||||
|
||||
const profileItems = computed(() => [
|
||||
{ label: user.value?.display_name || user.value?.username || "Usuario", disabled: true },
|
||||
{ label: user.value?.company_name || user.value?.company_code || "Empresa", disabled: true },
|
||||
{ separator: true },
|
||||
{
|
||||
label: "Empresas",
|
||||
icon: "pi pi-briefcase",
|
||||
command: () => navigateTo("/configuracion?tab=empresas"),
|
||||
},
|
||||
{
|
||||
label: "Configuración",
|
||||
icon: "pi pi-cog",
|
||||
command: () => navigateTo("/configuracion"),
|
||||
},
|
||||
{
|
||||
label: isDark.value ? "Tema claro" : "Tema oscuro",
|
||||
icon: isDark.value ? "pi pi-sun" : "pi pi-moon",
|
||||
command: toggleTheme,
|
||||
},
|
||||
{ separator: true },
|
||||
{ label: "Cerrar sesión", icon: "pi pi-sign-out", command: logout },
|
||||
]);
|
||||
|
||||
|
|
@ -121,13 +142,12 @@ const brand = computed(() => "PANELS");
|
|||
|
||||
const menu = [
|
||||
{ label: "Inicio", icon: "pi pi-home", to: "/", command: () => navigateTo("/") },
|
||||
{ label: "Padrón", icon: "pi pi-users", to: "/padron", command: () => navigateTo("/padron") },
|
||||
{ label: "Proyectos", icon: "pi pi-building", to: "/proyectos", command: () => navigateTo("/proyectos") },
|
||||
{ label: "Presupuesto", icon: "pi pi-calculator", to: "/presupuesto", command: () => navigateTo("/presupuesto") },
|
||||
{ label: "Padrón", icon: "pi pi-users", to: "/padron", command: () => navigateTo("/padron") },
|
||||
{ label: "Kanban", icon: "pi pi-th-large", to: "/kanban", command: () => navigateTo("/kanban") },
|
||||
{ label: "Gafetes", icon: "pi pi-id-card", to: "/gafetes", command: () => navigateTo("/gafetes") },
|
||||
{ label: "Nómina", icon: "pi pi-wallet", to: "/nomina", command: () => navigateTo("/nomina") },
|
||||
{ label: "Configuración", icon: "pi pi-cog", to: "/configuracion", command: () => navigateTo("/configuracion") },
|
||||
{ label: "Ayuda", icon: "pi pi-question-circle", command: showHelp },
|
||||
];
|
||||
|
||||
|
|
@ -168,10 +188,19 @@ function editFromFicha() {
|
|||
edit?.command?.();
|
||||
}
|
||||
|
||||
function onLoanSaved() {
|
||||
desk.bumpDetail();
|
||||
}
|
||||
|
||||
function setLoanVisible(open: boolean) {
|
||||
loanPrompt.value.visible = open;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
(path) => {
|
||||
if (!path.startsWith("/padron") && desk.detail.value.kind === "worker") desk.clearDetail();
|
||||
const workerOk = path.startsWith("/padron") || path.startsWith("/nomina") || path.startsWith("/kanban");
|
||||
if (!workerOk && desk.detail.value.kind === "worker") desk.clearDetail();
|
||||
if (!path.startsWith("/proyectos") && desk.detail.value.kind === "project") desk.clearDetail();
|
||||
if (!path.startsWith("/presupuesto") && desk.detail.value.kind === "budget") desk.clearDetail();
|
||||
},
|
||||
|
|
|
|||
23
web-panel/package-lock.json
generated
23
web-panel/package-lock.json
generated
|
|
@ -1,13 +1,14 @@
|
|||
{
|
||||
"name": "panel-obra-web",
|
||||
"name": "panel-obra-web-panel",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "panel-obra-web",
|
||||
"name": "panel-obra-web-panel",
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^1.2.3",
|
||||
"@primevue/nuxt-module": "^4.3.7",
|
||||
"chart.js": "^4.5.1",
|
||||
"nuxt": "^3.16.2",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.3.7",
|
||||
|
|
@ -1122,6 +1123,12 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@kwsites/file-exists": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
|
||||
|
|
@ -4402,6 +4409,18 @@
|
|||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
"dependencies": {
|
||||
"@primeuix/themes": "^1.2.3",
|
||||
"@primevue/nuxt-module": "^4.3.7",
|
||||
"chart.js": "^4.5.1",
|
||||
"nuxt": "^3.16.2",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.3.7",
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@
|
|||
<TabPanels>
|
||||
<TabPanel value="empresas">
|
||||
<Message severity="info" :closable="false">
|
||||
Primero la empresa (razón social, RFC, régimen y registro patronal), luego las obras y al final el padrón.
|
||||
La cuenta pertenece a <strong>{{ principal?.name || principal?.code || "la empresa principal" }}</strong>.
|
||||
Capture razón social, RFC, régimen y registro patronal; se usarán en IMSS y trámites posteriores.
|
||||
Use <strong>Nueva subempresa</strong> en la cinta para dar de alta otra razón o patrón IMSS.
|
||||
</Message>
|
||||
<DataTable :value="listedCompanies" class="mt-3" data-key="id" paginator :rows="15" empty-message="No hay empresas registradas.">
|
||||
<Column field="code" header="Código" />
|
||||
|
|
@ -245,6 +246,7 @@ const { riskSeverity, riskLevelLabel, riskLevelSeverity } = useUi();
|
|||
const desk = useDesktop();
|
||||
const { api } = useApi();
|
||||
const toast = useToast();
|
||||
const route = useRoute();
|
||||
const tab = ref("empresas");
|
||||
const risks = ref<Record<string, string>[]>([]);
|
||||
const themes = ref<Record<string, string>[]>([]);
|
||||
|
|
@ -497,6 +499,13 @@ function syncChrome() {
|
|||
});
|
||||
}
|
||||
onMounted(load);
|
||||
watch(
|
||||
() => String(route.query.tab || ""),
|
||||
(key) => {
|
||||
if (key === "empresas" || key === "riesgos" || key === "temas" || key === "nomina") tab.value = key;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
watch(tab, (v) => {
|
||||
desk.treeKey.value = v;
|
||||
syncChrome();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="dash">
|
||||
<div class="kpi-grid">
|
||||
<Card v-for="s in stats" :key="s.label">
|
||||
<Card
|
||||
v-for="s in stats"
|
||||
:key="s.label"
|
||||
class="dash-kpi"
|
||||
:class="{ clickable: Boolean(s.to) }"
|
||||
@click="s.to && navigateTo(s.to)"
|
||||
>
|
||||
<template #content>
|
||||
<div class="flex align-items-center gap-2" style="justify-content:space-between">
|
||||
<div class="dash-kpi-head">
|
||||
<Avatar :icon="s.icon" shape="circle" :style="{ background: s.bg, color: '#fff' }" />
|
||||
<Tag :value="s.hint" :severity="s.severity" />
|
||||
</div>
|
||||
|
|
@ -13,9 +19,74 @@
|
|||
</Card>
|
||||
</div>
|
||||
|
||||
<Panel header="Flujo de expediente" class="mt-3" toggleable>
|
||||
<MeterGroup :value="meter" label-position="start" />
|
||||
</Panel>
|
||||
<div class="dash-charts">
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="dash-chart-label">Expediente</div>
|
||||
<div class="dash-chart">
|
||||
<Chart v-if="hasValues(pipelineChart)" type="doughnut" :data="pipelineChart" :options="pieOptions" :key="`pipe-${themeKey}`" />
|
||||
<p v-else class="muted dash-empty">Sin personal cargado</p>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="dash-chart-label">IMSS</div>
|
||||
<div class="dash-chart">
|
||||
<Chart v-if="hasValues(imssChart)" type="doughnut" :data="imssChart" :options="pieOptions" :key="`imss-${themeKey}`" />
|
||||
<p v-else class="muted dash-empty">Sin activos</p>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="dash-chart-label">Riesgo de gafete</div>
|
||||
<div class="dash-chart">
|
||||
<Chart v-if="hasValues(riskChart)" type="doughnut" :data="riskChart" :options="pieOptions" :key="`risk-${themeKey}`" />
|
||||
<p v-else class="muted dash-empty">Sin activos</p>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card class="dash-span-2">
|
||||
<template #content>
|
||||
<div class="dash-chart-label">Personal por obra</div>
|
||||
<div class="dash-chart dash-chart--bar">
|
||||
<Chart v-if="hasValues(projectChart)" type="bar" :data="projectChart" :options="barOptions" :key="`proy-${themeKey}`" />
|
||||
<p v-else class="muted dash-empty">Nadie asignado a obra</p>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="dash-chart-label">Tipo de trabajo</div>
|
||||
<div class="dash-chart">
|
||||
<Chart v-if="hasValues(workChart)" type="doughnut" :data="workChart" :options="pieOptions" :key="`work-${themeKey}`" />
|
||||
<p v-else class="muted dash-empty">Sin activos</p>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card class="dash-span-2">
|
||||
<template #content>
|
||||
<div class="dash-chart-label">{{ payrollTitle }}</div>
|
||||
<div class="dash-chart dash-chart--bar">
|
||||
<Chart v-if="hasValues(payrollChart)" type="bar" :data="payrollChart" :options="moneyBarOptions" :key="`pay-${themeKey}`" />
|
||||
<p v-else class="muted dash-empty">No hay semana de nómina abierta</p>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="dash-chart-label">Atajos</div>
|
||||
<div class="dash-shortcuts">
|
||||
<Button label="Proyectos" icon="pi pi-building" outlined @click="navigateTo('/proyectos')" />
|
||||
<Button label="Padrón" icon="pi pi-users" outlined @click="navigateTo('/padron')" />
|
||||
<Button label="Por proyecto" icon="pi pi-th-large" outlined @click="navigateTo('/kanban')" />
|
||||
<Button label="Nómina" icon="pi pi-wallet" outlined @click="navigateTo('/nomina')" />
|
||||
<Button label="Gafetes" icon="pi pi-id-card" outlined @click="navigateTo('/gafetes')" />
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="home-split">
|
||||
<div class="list-page">
|
||||
|
|
@ -27,7 +98,7 @@
|
|||
<template #body="{ data }">
|
||||
<div class="person-cell">
|
||||
<Avatar :label="initials(data.first_name, data.last_name_p)" shape="circle" size="small" />
|
||||
<span>{{ data.last_name_p }} {{ data.first_name }}</span>
|
||||
<span>{{ workerFullName(data) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
|
@ -39,31 +110,33 @@
|
|||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
<Panel header="Atajos">
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<Button label="Padrón completo" icon="pi pi-users" outlined @click="navigateTo('/padron')" />
|
||||
<Button label="Personal por proyecto" icon="pi pi-th-large" outlined @click="navigateTo('/kanban')" />
|
||||
<Button label="Nómina de la semana" icon="pi pi-wallet" outlined @click="navigateTo('/nomina')" />
|
||||
<Message severity="info" :closable="false">Clic derecho en tablas para el menú de expediente, baja y documentos.</Message>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions } from "chart.js";
|
||||
import type { DeskStatusMessage } from "~/composables/useDesktop";
|
||||
import { workerFullName } from "~/types/padron";
|
||||
|
||||
const { initials, riskLevelLabel, riskLevelSeverity, riskLevel } = useUi();
|
||||
const { initials, riskLevelLabel, riskLevelSeverity, riskLevel, pipelineLabel } = useUi();
|
||||
const { isDark } = useTheme();
|
||||
const desk = useDesktop();
|
||||
const { api } = useApi();
|
||||
const { tableQ, matches } = useTableSearch();
|
||||
|
||||
type Worker = {
|
||||
id?: number;
|
||||
first_name: string;
|
||||
middle_name?: string | null;
|
||||
last_name_p: string;
|
||||
last_name_m?: string | null;
|
||||
status: string;
|
||||
pipeline_status: string;
|
||||
position: string;
|
||||
risk_code: string;
|
||||
risk_color: string;
|
||||
work_type?: string;
|
||||
imss_status?: string;
|
||||
imss_ready?: boolean;
|
||||
in_project_without_imss?: boolean;
|
||||
|
|
@ -74,35 +147,54 @@ type Worker = {
|
|||
};
|
||||
|
||||
type ProjectRow = {
|
||||
id?: number;
|
||||
name?: string;
|
||||
code?: string;
|
||||
status: string;
|
||||
active_count?: number;
|
||||
missing_docs?: number;
|
||||
};
|
||||
|
||||
type JobRow = {
|
||||
delivered?: number | boolean;
|
||||
};
|
||||
|
||||
type JobRow = { delivered?: number | boolean };
|
||||
type WeekRow = {
|
||||
status?: string;
|
||||
week_start?: string;
|
||||
week_end?: string;
|
||||
required_net?: number;
|
||||
payable_net?: number;
|
||||
loan_recovery?: number;
|
||||
};
|
||||
|
||||
const { api } = useApi();
|
||||
const workers = ref<Worker[]>([]);
|
||||
const projects = ref<ProjectRow[]>([]);
|
||||
const openWeek = ref<WeekRow | null>(null);
|
||||
const stats = ref([
|
||||
{ label: "Activos", value: "—", icon: "pi pi-users", bg: "#0f2744", hint: "Alta", severity: "success" },
|
||||
{ label: "Proyectos", value: "—", icon: "pi pi-building", bg: "#1c4a7a", hint: "Proyectos", severity: "info" },
|
||||
{ label: "Listos gafete", value: "—", icon: "pi pi-id-card", bg: "#c9a227", hint: "Imprimir", severity: "warn" },
|
||||
{ label: "Jobs PDF", value: "—", icon: "pi pi-file-pdf", bg: "#a20000", hint: "Archivo", severity: "danger" },
|
||||
{ label: "Activos", value: "—", icon: "pi pi-users", bg: "#0f2744", hint: "Alta", severity: "success" as const, to: "/padron" },
|
||||
{ label: "Obras", value: "—", icon: "pi pi-building", bg: "#1c4a7a", hint: "Proyectos", severity: "info" as const, to: "/proyectos" },
|
||||
{ label: "A pagar (semana)", value: "—", icon: "pi pi-wallet", bg: "#194ca8", hint: "Nómina", severity: "info" as const, to: "/nomina" },
|
||||
{ label: "Saldo préstamos", value: "—", icon: "pi pi-replay", bg: "#008514", hint: "Por cobrar", severity: "success" as const, to: "/nomina" },
|
||||
{ label: "Listos gafete", value: "—", icon: "pi pi-id-card", bg: "#c9a227", hint: "Imprimir", severity: "warn" as const, to: "/gafetes" },
|
||||
{ label: "En obra sin IMSS", value: "—", icon: "pi pi-exclamation-triangle", bg: "#a20000", hint: "Prioridad", severity: "danger" as const, to: "/padron" },
|
||||
]);
|
||||
const meter = ref<{ label: string; color: string; value: number }[]>([]);
|
||||
|
||||
const { tableQ, matches } = useTableSearch();
|
||||
const listedReady = computed(() =>
|
||||
workers.value
|
||||
.filter((w) => w.pipeline_status === "listo_gafete")
|
||||
.filter((w) => matches(w.first_name, w.last_name_p, w.position, w.risk_code)),
|
||||
.filter((w) => matches(w.first_name, w.last_name_p, w.last_name_m, w.middle_name, w.position, w.risk_code)),
|
||||
);
|
||||
|
||||
const activos = computed(() =>
|
||||
workers.value.filter((w) => w.status === "activo" && w.pipeline_status !== "baja"),
|
||||
);
|
||||
|
||||
const themeKey = computed(() => (isDark.value ? "dark" : "light"));
|
||||
const ink = computed(() => (isDark.value ? "#e2e8f0" : "#0f2744"));
|
||||
const grid = computed(() => (isDark.value ? "rgba(148,163,184,0.16)" : "rgba(15,39,68,0.08)"));
|
||||
|
||||
function money(n: number) {
|
||||
return (Number(n) || 0).toLocaleString("es-MX", { style: "currency", currency: "MXN", maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
function hasProject(worker: Worker) {
|
||||
return String(worker.project_ids || "")
|
||||
.split(",")
|
||||
|
|
@ -110,6 +202,162 @@ function hasProject(worker: Worker) {
|
|||
.some((id) => id > 0);
|
||||
}
|
||||
|
||||
function hasValues(chart: { datasets?: { data?: unknown[] }[] }) {
|
||||
return (chart.datasets ?? []).some((set) => (set.data ?? []).some((n) => Number(n) > 0));
|
||||
}
|
||||
|
||||
function doughnut(labels: string[], data: number[], colors: string[]): ChartData<"doughnut"> {
|
||||
const packed = labels
|
||||
.map((label, i) => ({ label, value: Number(data[i] || 0), color: colors[i] }))
|
||||
.filter((row) => row.value > 0);
|
||||
return {
|
||||
labels: packed.map((row) => row.label),
|
||||
datasets: [{
|
||||
data: packed.map((row) => row.value),
|
||||
backgroundColor: packed.map((row) => row.color),
|
||||
borderWidth: 0,
|
||||
hoverOffset: 4,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
const pipelineChart = computed(() => {
|
||||
const order = ["incompleto", "listo_gafete", "impreso", "activo", "baja"];
|
||||
const colors = ["#6b7785", "#1c4a7a", "#c9a227", "#008514", "#a20000"];
|
||||
const counts = order.map((key) => workers.value.filter((w) => w.pipeline_status === key).length);
|
||||
return doughnut(order.map((key) => pipelineLabel(key)), counts, colors);
|
||||
});
|
||||
|
||||
const imssChart = computed(() => {
|
||||
const list = activos.value;
|
||||
const alta = list.filter((w) => w.imss_status === "alta").length;
|
||||
const obra = list.filter((w) => w.in_project_without_imss).length;
|
||||
const resto = Math.max(0, list.length - alta - obra);
|
||||
return doughnut(
|
||||
["Alta IMSS", "En obra sin alta", "Sin alta"],
|
||||
[alta, obra, resto],
|
||||
["#008514", "#a20000", "#c9a227"],
|
||||
);
|
||||
});
|
||||
|
||||
const riskChart = computed(() => {
|
||||
const list = activos.value;
|
||||
return doughnut(
|
||||
["Alto", "Medio", "Bajo"],
|
||||
[
|
||||
list.filter((w) => riskLevel(w.risk_code) === "alto").length,
|
||||
list.filter((w) => riskLevel(w.risk_code) === "medio").length,
|
||||
list.filter((w) => riskLevel(w.risk_code) === "bajo").length,
|
||||
],
|
||||
["#a20000", "#c9a227", "#008514"],
|
||||
);
|
||||
});
|
||||
|
||||
const workChart = computed(() => {
|
||||
const list = activos.value;
|
||||
return doughnut(
|
||||
["Jornal", "Destajo"],
|
||||
[list.filter((w) => w.work_type !== "D").length, list.filter((w) => w.work_type === "D").length],
|
||||
["#1c4a7a", "#2f6fe4"],
|
||||
);
|
||||
});
|
||||
|
||||
const projectChart = computed((): ChartData<"bar"> => {
|
||||
const rows = [...projects.value]
|
||||
.filter((p) => p.status === "activo" || Number(p.active_count) > 0)
|
||||
.sort((a, b) => Number(b.active_count || 0) - Number(a.active_count || 0))
|
||||
.slice(0, 8);
|
||||
const unassigned = activos.value.filter((w) => !hasProject(w)).length;
|
||||
const labels = [...rows.map((p) => String(p.name || p.code || "Obra")), ...(unassigned ? ["Sin asignar"] : [])];
|
||||
const data = [...rows.map((p) => Number(p.active_count || 0)), ...(unassigned ? [unassigned] : [])];
|
||||
const colors = [...rows.map(() => "#2f6fe4"), ...(unassigned ? ["#c9a227"] : [])];
|
||||
return {
|
||||
labels,
|
||||
datasets: [{ label: "Personas", data, backgroundColor: colors, borderRadius: 6, maxBarThickness: 28 }],
|
||||
};
|
||||
});
|
||||
|
||||
const payrollTitle = computed(() => {
|
||||
const week = openWeek.value;
|
||||
if (!week?.week_start) return "Nómina de la semana";
|
||||
return `Nómina ${week.week_start} → ${week.week_end || ""}`.trim();
|
||||
});
|
||||
|
||||
const payrollChart = computed((): ChartData<"bar"> => {
|
||||
const week = openWeek.value;
|
||||
return {
|
||||
labels: ["Requerida", "A pagar", "Préstamos"],
|
||||
datasets: [{
|
||||
label: "MXN",
|
||||
data: [
|
||||
Number(week?.required_net || 0),
|
||||
Number(week?.payable_net || 0),
|
||||
Number(week?.loan_recovery || 0),
|
||||
],
|
||||
backgroundColor: ["#0f2744", "#2f6fe4", "#008514"],
|
||||
borderRadius: 6,
|
||||
maxBarThickness: 48,
|
||||
}],
|
||||
};
|
||||
});
|
||||
|
||||
const pieOptions = computed((): ChartOptions<"doughnut"> => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: "62%",
|
||||
plugins: {
|
||||
legend: {
|
||||
position: "bottom",
|
||||
labels: { color: ink.value, boxWidth: 10, padding: 12, font: { size: 11 } },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const barOptions = computed((): ChartOptions<"bar"> => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: "y",
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
ticks: { color: ink.value, precision: 0 },
|
||||
grid: { color: grid.value },
|
||||
},
|
||||
y: {
|
||||
ticks: { color: ink.value },
|
||||
grid: { display: false },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const moneyBarOptions = computed((): ChartOptions<"bar"> => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => money(Number(ctx.parsed.y ?? 0)),
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: ink.value },
|
||||
grid: { display: false },
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: ink.value,
|
||||
callback: (value) => money(Number(value)),
|
||||
},
|
||||
grid: { color: grid.value },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
function buildHomeAlerts(input: {
|
||||
workers: Worker[];
|
||||
projects: ProjectRow[];
|
||||
|
|
@ -263,6 +511,15 @@ function buildHomeAlerts(input: {
|
|||
return messages;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
desk.configure({
|
||||
tab: "Inicio",
|
||||
|
|
@ -284,17 +541,25 @@ onMounted(async () => {
|
|||
api<{ workers: Worker[]; imss_stats?: { imss_ready_count?: number } }>("/v1/workers"),
|
||||
api<{ projects: ProjectRow[] }>("/v1/projects?status=activo,pausado"),
|
||||
api<{ jobs: JobRow[] }>("/v1/badge-jobs"),
|
||||
api<{ weeks: { status?: string }[] }>("/v1/payroll/weeks/open"),
|
||||
api<{ weeks: WeekRow[] }>("/v1/payroll/weeks/open"),
|
||||
]);
|
||||
workers.value = w.workers;
|
||||
const activos = w.workers.filter((x) => x.status === "activo" && x.pipeline_status !== "baja").length;
|
||||
projects.value = p.projects;
|
||||
const currentStart = mondayOf(new Date());
|
||||
openWeek.value = pe.weeks.find((week) => week.week_start === currentStart) || pe.weeks[0] || null;
|
||||
const activeList = w.workers.filter((x) => x.status === "activo" && x.pipeline_status !== "baja");
|
||||
const activosN = activeList.length;
|
||||
const listos = w.workers.filter((x) => x.pipeline_status === "listo_gafete").length;
|
||||
const activosProyectos = p.projects.filter((x) => x.status === "activo").length;
|
||||
const loanSaldo = activeList.reduce((sum, row) => sum + Number(row.loan_balance || 0), 0);
|
||||
const sinImssObra = activeList.filter((x) => x.in_project_without_imss).length;
|
||||
stats.value = [
|
||||
{ label: "Activos", value: String(activos), icon: "pi pi-users", bg: "#0f2744", hint: "Alta", severity: "success" },
|
||||
{ label: "Proyectos", value: String(activosProyectos), icon: "pi pi-building", bg: "#1c4a7a", hint: "Proyectos", severity: "info" },
|
||||
{ label: "Listos gafete", value: String(listos), icon: "pi pi-id-card", bg: "#c9a227", hint: "Imprimir", severity: "warn" },
|
||||
{ label: "Jobs PDF", value: String(j.jobs.length), icon: "pi pi-file-pdf", bg: "#a20000", hint: "Archivo", severity: "danger" },
|
||||
{ label: "Activos", value: String(activosN), icon: "pi pi-users", bg: "#0f2744", hint: "Alta", severity: "success", to: "/padron" },
|
||||
{ label: "Obras", value: String(activosProyectos), icon: "pi pi-building", bg: "#1c4a7a", hint: "Proyectos", severity: "info", to: "/proyectos" },
|
||||
{ label: "A pagar (semana)", value: openWeek.value ? money(Number(openWeek.value.payable_net || 0)) : "—", icon: "pi pi-wallet", bg: "#194ca8", hint: "Nómina", severity: "info", to: "/nomina" },
|
||||
{ label: "Saldo préstamos", value: money(loanSaldo), icon: "pi pi-replay", bg: "#008514", hint: "Por cobrar", severity: "success", to: "/nomina" },
|
||||
{ label: "Listos gafete", value: String(listos), icon: "pi pi-id-card", bg: "#c9a227", hint: "Imprimir", severity: "warn", to: "/gafetes" },
|
||||
{ label: "En obra sin IMSS", value: String(sinImssObra), icon: "pi pi-exclamation-triangle", bg: "#a20000", hint: "Prioridad", severity: "danger", to: "/padron" },
|
||||
];
|
||||
desk.setStatusMessages(
|
||||
buildHomeAlerts({
|
||||
|
|
@ -305,16 +570,6 @@ onMounted(async () => {
|
|||
imssReady: w.imss_stats?.imss_ready_count || 0,
|
||||
}),
|
||||
);
|
||||
const counts: Record<string, number> = {};
|
||||
for (const row of w.workers) counts[row.pipeline_status] = (counts[row.pipeline_status] || 0) + 1;
|
||||
const total = w.workers.length || 1;
|
||||
meter.value = [
|
||||
{ label: "Incompleto", color: "#6b7785", value: Math.round(((counts.incompleto || 0) / total) * 100) },
|
||||
{ label: "Listo", color: "#1c4a7a", value: Math.round(((counts.listo_gafete || 0) / total) * 100) },
|
||||
{ label: "Impreso", color: "#c9a227", value: Math.round(((counts.impreso || 0) / total) * 100) },
|
||||
{ label: "Activo", color: "#008514", value: Math.round(((counts.activo || 0) / total) * 100) },
|
||||
{ label: "Baja", color: "#a20000", value: Math.round(((counts.baja || 0) / total) * 100) },
|
||||
];
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
|
|
|||
|
|
@ -15,8 +15,14 @@
|
|||
</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>
|
||||
<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>
|
||||
|
|
@ -40,11 +46,15 @@
|
|||
<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).
|
||||
Lun–sáb. 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" empty-message="Sin personal de jornal en esta obra">
|
||||
<Column header="Persona" frozen>
|
||||
<template #body="{ data }">{{ data.last_name_p }} {{ data.first_name }}</template>
|
||||
<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"
|
||||
|
|
@ -74,8 +84,9 @@
|
|||
<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>
|
||||
<button type="button" class="person-link loan-link" @click="openLoanFor(data.worker_id, personLabel(data))">
|
||||
{{ data.loan_label || "Registrar" }}
|
||||
</button>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
|
@ -88,7 +99,11 @@
|
|||
<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>
|
||||
<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" />
|
||||
|
|
@ -141,7 +156,11 @@
|
|||
<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>
|
||||
<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 }">
|
||||
|
|
@ -206,12 +225,18 @@
|
|||
</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 Line = {
|
||||
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;
|
||||
first_name: string;
|
||||
last_name_p: string;
|
||||
days: number;
|
||||
daily_wage: number;
|
||||
payable_net: number;
|
||||
|
|
@ -220,11 +245,10 @@ type Line = {
|
|||
cells?: Cell[];
|
||||
};
|
||||
type Sheet = { id: number; kind: string; project_id: number | null; project_name?: string; lines: Line[] };
|
||||
type Cut = {
|
||||
type Cut = NameParts & {
|
||||
id: number;
|
||||
worker_id: number;
|
||||
project_name: string;
|
||||
first_name: string;
|
||||
last_name_p: string;
|
||||
concepto: string;
|
||||
unit_code: string;
|
||||
qty_total_estimated: number;
|
||||
|
|
@ -250,10 +274,14 @@ const destajoPeriod = ref<{ period_start: string; period_end: string } | null>(n
|
|||
const sheets = ref<Sheet[]>([]);
|
||||
const destajoCuts = ref<Cut[]>([]);
|
||||
const days = ref<string[]>([]);
|
||||
const board = ref({ required_net: 0, payable_net: 0, delta: 0 });
|
||||
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,
|
||||
|
|
@ -325,22 +353,24 @@ function cell(line: Line, idx: number) {
|
|||
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<{ 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; delta: number };
|
||||
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: `${x.last_name_p} ${x.first_name}`,
|
||||
label: workerFullName(x),
|
||||
work_type: x.work_type,
|
||||
}));
|
||||
week.value = bundle.week;
|
||||
|
|
@ -348,19 +378,40 @@ async function load() {
|
|||
sheets.value = bundle.sheets;
|
||||
destajoCuts.value = bundle.destajo_cuts || [];
|
||||
days.value = bundle.days || [];
|
||||
board.value = bundle.board;
|
||||
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" : 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.",
|
||||
severity: st === "paid" ? "info" : short > 0 ? "warn" : "info",
|
||||
title,
|
||||
detail,
|
||||
route: "/nomina",
|
||||
},
|
||||
]);
|
||||
|
|
@ -368,7 +419,7 @@ function syncChrome() {
|
|||
tab: "Nómina",
|
||||
treeTitle: "Obras",
|
||||
showTree: true,
|
||||
showDetail: false,
|
||||
showDetail: true,
|
||||
treeKey: projectId.value ? `p:${projectId.value}` : "",
|
||||
countLabel: weekLabel.value,
|
||||
onTreeSelect: (key) => {
|
||||
|
|
@ -381,7 +432,16 @@ function syncChrome() {
|
|||
key: "acciones",
|
||||
label: "Nómina",
|
||||
items: [
|
||||
{ key: "assemble", label: "Armar jueves", icon: "pi pi-box", disabled: paid.value || st === "assembled", command: assemble },
|
||||
{
|
||||
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 },
|
||||
],
|
||||
|
|
@ -393,6 +453,14 @@ function syncChrome() {
|
|||
{ 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -409,6 +477,44 @@ function shiftWeek(n: number) {
|
|||
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 {
|
||||
|
|
@ -425,9 +531,18 @@ async function toggleDay(line: Line, date: string, present: boolean) {
|
|||
|
||||
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() {
|
||||
|
|
@ -508,6 +623,9 @@ async function addJornal() {
|
|||
}
|
||||
|
||||
watch(projectId, syncChrome);
|
||||
watch(() => desk.detailRev.value, () => {
|
||||
if (week.value) load();
|
||||
});
|
||||
onMounted(load);
|
||||
onBeforeUnmount(() => desk.clearStatusMessages());
|
||||
</script>
|
||||
|
|
@ -515,7 +633,7 @@ onBeforeUnmount(() => desk.clearStatusMessages());
|
|||
<style scoped>
|
||||
.nomina-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
|
@ -528,7 +646,22 @@ onBeforeUnmount(() => desk.clearStatusMessages());
|
|||
.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; }
|
||||
.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) {
|
||||
|
|
|
|||
|
|
@ -218,6 +218,11 @@ function openWorker(worker: Worker) {
|
|||
selected.value = worker;
|
||||
desk.openWorker(worker.id);
|
||||
}
|
||||
function openLoan() {
|
||||
if (!selected.value) return;
|
||||
desk.openWorker(selected.value.id, undefined, "nomina");
|
||||
desk.openLoan(selected.value.id, workerFullName(selected.value));
|
||||
}
|
||||
async function save(form: WorkerForm) {
|
||||
saving.value = true;
|
||||
formError.value = "";
|
||||
|
|
@ -388,6 +393,7 @@ function syncChrome() {
|
|||
items: [
|
||||
{ key: "alta", label: "Nuevo", icon: "pi pi-plus", command: openNew },
|
||||
{ key: "edit", label: "Editar", icon: "pi pi-pencil", disabled: !selected.value, command: () => selected.value && editWorker(selected.value) },
|
||||
{ key: "loan", label: "Préstamo", icon: "pi pi-wallet", disabled: !selected.value || selected.value.status === "baja", command: openLoan },
|
||||
{ key: "baja", label: "Baja", icon: "pi pi-user-minus", disabled: !selected.value || selected.value.status === "baja", command: () => selected.value && baja(selected.value) },
|
||||
{
|
||||
key: "reactivate",
|
||||
|
|
@ -429,10 +435,20 @@ onMounted(async () => {
|
|||
const route = useRoute();
|
||||
if (route.query.nuevo) openNew();
|
||||
if (route.query.import) showImport.value = true;
|
||||
if (desk.detail.value.kind === "worker" && desk.detail.value.id) {
|
||||
const queryWorkerId = Number(route.query.worker || 0);
|
||||
if (queryWorkerId) {
|
||||
const worker = workers.value.find((item) => item.id === queryWorkerId);
|
||||
if (worker) {
|
||||
selected.value = worker;
|
||||
desk.openWorker(worker.id, undefined, route.query.prestamo ? "nomina" : undefined);
|
||||
}
|
||||
} else if (desk.detail.value.kind === "worker" && desk.detail.value.id) {
|
||||
const worker = workers.value.find((item) => item.id === desk.detail.value.id);
|
||||
if (worker) selected.value = worker;
|
||||
}
|
||||
if (route.query.prestamo && selected.value) {
|
||||
desk.openLoan(selected.value.id, workerFullName(selected.value));
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
|
|
|||
7
web-panel/plugins/chart.client.ts
Normal file
7
web-panel/plugins/chart.client.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { ArcElement, BarElement, CategoryScale, Chart, Legend, LinearScale, Tooltip } from "chart.js";
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
Chart.register(ArcElement, BarElement, CategoryScale, LinearScale, Tooltip, Legend);
|
||||
Chart.defaults.font.family = '"Segoe UI", Inter, system-ui, sans-serif';
|
||||
Chart.defaults.font.size = 12;
|
||||
});
|
||||
|
|
@ -217,7 +217,12 @@ export function companyLabel(company: { code: string; name: string; kind?: strin
|
|||
return company.code;
|
||||
}
|
||||
|
||||
export function workerFullName(worker: Worker): string {
|
||||
export function workerFullName(worker: {
|
||||
last_name_p?: string | null;
|
||||
last_name_m?: string | null;
|
||||
first_name?: string | null;
|
||||
middle_name?: string | null;
|
||||
}): string {
|
||||
return [worker.last_name_p, worker.last_name_m, worker.first_name, worker.middle_name]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
|
|
|||
Loading…
Reference in a new issue