Ajustes para nomina y dashboard

This commit is contained in:
Alberto Martinez 2026-08-26 19:02:41 -05:00
parent 085366c287
commit 97c66282bf
20 changed files with 918 additions and 203 deletions

View file

@ -83,6 +83,14 @@ export function weekDays(weekStart: string): string[] {
return [0, 1, 2, 3, 4, 5].map((n) => addDays(weekStart, n)); 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 { export function tenantKey(tenantId: number | null | undefined): number {
return tenantId ?? 0; return tenantId ?? 0;
} }
@ -207,8 +215,8 @@ export function createLoan(
const grantIso = body.grantIso ?? todayIso(); const grantIso = body.grantIso ?? todayIso();
const week = weekContaining(grantIso); const week = weekContaining(grantIso);
const current = db.prepare( const current = db.prepare(
"SELECT status FROM payroll_weeks WHERE tenant_id=? AND week_start=?", "SELECT id, status FROM payroll_weeks WHERE tenant_id=? AND week_start=?",
).get(tenantId, week.weekStart) as { status: string } | undefined; ).get(tenantId, week.weekStart) as { id: number; status: string } | undefined;
const assembled = current?.status === "assembled" || current?.status === "paid"; const assembled = current?.status === "assembled" || current?.status === "paid";
const firstDue = firstDueForLoan({ const firstDue = firstDueForLoan({
delivered, delivered,
@ -235,7 +243,9 @@ export function createLoan(
installments, installments,
firstDue, firstDue,
); );
return { id: lid(db) }; const id = lid(db);
if (current && current.status !== "paid") recalcWeek(db, current.id);
return { id };
} }
export function presentOnOtherProject( 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 { const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
id: number; id: number;
week_start: string; week_start: string;
@ -605,6 +615,12 @@ export function assembleWeek(db: Database, weekId: number) {
} | undefined; } | undefined;
if (!week) throw Object.assign(new Error("Semana no encontrada"), { status: 404 }); 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 (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( const sheets = db.prepare(
"SELECT * FROM payroll_sheets WHERE week_id=? AND kind='obra'", "SELECT * FROM payroll_sheets WHERE week_id=? AND kind='obra'",
).all(weekId) as { id: number; project_id: number | null }[]; ).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 sheets = sheetsRaw.map((sheet) => {
const lines = db.prepare( 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 FROM payroll_week_lines l
JOIN workers w ON w.id=l.worker_id JOIN workers w ON w.id=l.worker_id
WHERE l.sheet_id=? WHERE l.sheet_id=?
@ -913,7 +929,7 @@ export function getWeekBundle(db: Database, tenantId: number, weekStart: string)
}); });
const jobs = db.prepare( 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 FROM destajo_jobs j
JOIN workers w ON w.id=j.worker_id JOIN workers w ON w.id=j.worker_id
JOIN projects p ON p.id=j.project_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 const cuts = period
? db.prepare( ? db.prepare(
`SELECT c.*, j.concepto, j.unit_code, j.unit_price, j.qty_total_estimated, j.worker_id, j.project_id, `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 FROM destajo_cut_lines c
JOIN destajo_jobs j ON j.id=c.job_id JOIN destajo_jobs j ON j.id=c.job_id
JOIN workers w ON w.id=j.worker_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 units = listUnits(db, tenantId);
const required = Number(week.required_net) || 0; const required = Number(week.required_net) || 0;
const payable = Number(week.payable_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 { return {
week, week,
destajo_period: period, destajo_period: period,
@ -949,8 +973,12 @@ export function getWeekBundle(db: Database, tenantId: number, weekStart: string)
board: { board: {
required_net: required, required_net: required,
payable_net: payable, payable_net: payable,
loan_recovery: loanRecovery,
faltante,
delta: roundMoney(required - payable), 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) { export function listOpenWeeks(db: Database, tenantId: number) {
return db.prepare( 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); ).all(tenantId);
} }

View file

@ -3,6 +3,7 @@ import type { AuthUser } from "./auth.ts";
import { tenantScope, requireAuth } from "./auth.ts"; import { tenantScope, requireAuth } from "./auth.ts";
import { getDb, lastInsertId, projectById, projectMustBe } from "./db.ts"; import { getDb, lastInsertId, projectById, projectMustBe } from "./db.ts";
import { generateLoanReceiptPdf } from "./pdf.ts"; import { generateLoanReceiptPdf } from "./pdf.ts";
import { fullName } from "./mx.ts";
import { import {
addAdminLine, addAdminLine,
addJornalWorker, addJornalWorker,
@ -157,7 +158,8 @@ export function registerPayrollRoutes(app: App) {
const id = Number(c.req.param("id")); const id = Number(c.req.param("id"));
const db = await getDb(); const db = await getDb();
const loan = db.prepare( 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 { ).get(id) as {
delivered: number; delivered: number;
commission_pct: number; commission_pct: number;
@ -169,14 +171,16 @@ export function registerPayrollRoutes(app: App) {
first_due: string; first_due: string;
created_at: string; created_at: string;
first_name: string; first_name: string;
middle_name: string | null;
last_name_p: string; last_name_p: string;
last_name_m: string;
note: string | null; note: string | null;
} | undefined; } | undefined;
if (!loan) return c.json({ error: "Préstamo no encontrado" }, 404); if (!loan) return c.json({ error: "Préstamo no encontrado" }, 404);
const bytes = await generateLoanReceiptPdf({ const bytes = await generateLoanReceiptPdf({
title: "Recibo de aceptación de préstamo", title: "Recibo de aceptación de préstamo",
subtitle: `Registrado ${String(loan.created_at).slice(0, 10)}`, subtitle: `Registrado ${String(loan.created_at).slice(0, 10)}`,
person: `${loan.last_name_p} ${loan.first_name}`, person: fullName(loan),
lines: [ lines: [
{ label: "Entregado", value: moneyLabel(loan.delivered) }, { label: "Entregado", value: moneyLabel(loan.delivered) },
{ {
@ -202,7 +206,7 @@ export function registerPayrollRoutes(app: App) {
const id = Number(c.req.param("id")); const id = Number(c.req.param("id"));
const db = await getDb(); const db = await getDb();
const pay = db.prepare( 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 FROM loan_payments p
JOIN loans l ON l.id=p.loan_id JOIN loans l ON l.id=p.loan_id
JOIN workers w ON w.id=l.worker_id JOIN workers w ON w.id=l.worker_id
@ -215,14 +219,16 @@ export function registerPayrollRoutes(app: App) {
delivered: number; delivered: number;
commission_pct: number; commission_pct: number;
first_name: string; first_name: string;
middle_name: string | null;
last_name_p: string; last_name_p: string;
last_name_m: string;
week_end: string; week_end: string;
} | undefined; } | undefined;
if (!pay) return c.json({ error: "Recibo no encontrado" }, 404); if (!pay) return c.json({ error: "Recibo no encontrado" }, 404);
const bytes = await generateLoanReceiptPdf({ const bytes = await generateLoanReceiptPdf({
title: "Recibo de pago de préstamo", title: "Recibo de pago de préstamo",
subtitle: `Descuento del sábado ${pay.week_end}`, subtitle: `Descuento del sábado ${pay.week_end}`,
person: `${pay.last_name_p} ${pay.first_name}`, person: fullName(pay),
lines: [ lines: [
{ label: "Descuento", value: moneyLabel(pay.amount) }, { label: "Descuento", value: moneyLabel(pay.amount) },
{ label: "Exhibición", value: String(pay.installment_n) }, { label: "Exhibición", value: String(pay.installment_n) },

View file

@ -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 { Database } from "@db/sqlite";
import { import {
addDays, addDays,
assembleWeek, assembleWeek,
canAssembleWeek,
commissionAmount, commissionAmount,
createDestajoJob, createDestajoJob,
createLoan, createLoan,
@ -17,6 +18,7 @@ import {
roundMoney, roundMoney,
setAttendance, setAttendance,
thisSaturday, thisSaturday,
weekAssembleDate,
weekContaining, weekContaining,
} from "./payroll.ts"; } from "./payroll.ts";
@ -117,6 +119,27 @@ Deno.test("semana lun-sáb y corte destajo jue-jue", () => {
assertEquals(d.periodStart, "2026-08-20"); 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", () => { Deno.test("comisión suma al saldo", () => {
assertEquals(commissionAmount(500, 10), 50); assertEquals(commissionAmount(500, 10), 50);
assertEquals(roundMoney(500 + 50), 550); 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-24', 1)").run();
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 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"); 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 { const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
status: string; status: string;
required_net: number; 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-24', 1)").run();
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 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"); 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 }); 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); assertEquals("ok" in r && r.ok, true);
const week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { 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(); 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" }); createLoan(db, 1, { worker_id: 1, delivered: 2000, commission_pct: 10, grantIso: "2026-08-24" });
const weekId = ensureWeek(db, 1, "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 }; const before = db.prepare("SELECT balance FROM loans WHERE worker_id=1").get() as { balance: number };
assertEquals(before.balance, 2200); assertEquals(before.balance, 2200);
const week = db.prepare("SELECT required_net FROM payroll_weeks WHERE id=?").get(weekId) as { required_net: number }; 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); 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", () => { Deno.test("un día no se marca en dos obras", () => {
const db = mem(); const db = mem();
db.prepare("INSERT INTO projects (id, code, name, status, tenant_id) VALUES (2, 'PRY-0002', 'Obra Sur', 'activo', 1)").run(); 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(); 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 }); setAttendance(db, 1, { project_id: 2, worker_id: 1, work_date: "2026-08-27", present: true });
const weekId = ensureWeek(db, 1, "2026-08-24"); const weekId = ensureWeek(db, 1, "2026-08-24");
assembleWeek(db, weekId); assembleWeek(db, weekId, "2026-08-27");
const onA = db.prepare( const onA = db.prepare(
"SELECT present FROM attendance WHERE worker_id=1 AND project_id=1 AND work_date='2026-08-27'", "SELECT present FROM attendance WHERE worker_id=1 AND project_id=1 AND work_date='2026-08-27'",
).get() as { present: number } | undefined; ).get() as { present: number } | undefined;
@ -251,7 +294,7 @@ Deno.test("destajo: estimado del corte, realizado menor, remanente y extra viern
unit_price: 100, unit_price: 100,
qty_planned: 80, 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 { let week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
required_net: number; required_net: number;
payable_net: number; payable_net: number;

View file

@ -220,7 +220,7 @@ html, body, #__nuxt {
} }
.kpi-grid { .kpi-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(11.5rem, 1fr));
gap: 12px; gap: 12px;
} }
.kpi-value { .kpi-value {
@ -229,7 +229,56 @@ html, body, #__nuxt {
color: #0f2744; color: #0f2744;
line-height: 1.1; line-height: 1.1;
} }
.home-split {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 12px;
}
.home-split .list-page { min-height: 0; } .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 { .form-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@ -313,7 +362,8 @@ html, body, #__nuxt {
min-width: 0; min-width: 0;
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.kpi-grid, .home-split, .form-grid { .kpi-grid, .home-split, .form-grid, .dash-charts {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.dash-span-2 { grid-column: auto; }
} }

View file

@ -1865,9 +1865,13 @@ html.dark .document-check.complete {
background: rgba(34, 197, 94, 0.12); background: rgba(34, 197, 94, 0.12);
} }
html.dark .kpi-value { html.dark .kpi-value,
html.dark .dash-chart-label {
color: #f1f5f9; color: #f1f5f9;
} }
html.dark .dash-kpi.clickable:hover {
outline-color: #3b82f6;
}
/* Avisos de inicio (statusbar popup) */ /* Avisos de inicio (statusbar popup) */
html.dark .desk-status-messages-inner { html.dark .desk-status-messages-inner {

View file

@ -10,6 +10,7 @@
:key="item.key" :key="item.key"
:icon="item.icon" :icon="item.icon"
:label="item.label" :label="item.label"
:title="item.title"
:class="['ribbon-action', `ribbon-action--${item.key}`]" :class="['ribbon-action', `ribbon-action--${item.key}`]"
icon-pos="top" icon-pos="top"
text text

View file

@ -41,6 +41,7 @@
<div class="worker-imss-head"> <div class="worker-imss-head">
<div class="worker-imss-title">IMSS</div> <div class="worker-imss-title">IMSS</div>
<div class="worker-imss-actions"> <div class="worker-imss-actions">
<template v-if="canEdit">
<Button <Button
v-if="data.worker.imss_status !== 'alta'" v-if="data.worker.imss_status !== 'alta'"
label="Registrar alta" label="Registrar alta"
@ -65,6 +66,7 @@
@click="openImss('baja')" @click="openImss('baja')"
/> />
</template> </template>
</template>
<Button <Button
label="Historial" label="Historial"
icon="pi pi-history" icon="pi pi-history"
@ -95,7 +97,14 @@
</div> </div>
</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 class="worker-mini-stats">
<div><strong>{{ requiredDone }}/{{ requiredItems.length }}</strong><span>Documentos</span></div> <div><strong>{{ requiredDone }}/{{ requiredItems.length }}</strong><span>Documentos</span></div>
@ -117,6 +126,7 @@
:checklist="data.checklist" :checklist="data.checklist"
:companies="companies" :companies="companies"
:initial-type="doc" :initial-type="doc"
:readonly="!canEdit"
:freshness-required="data.freshness_required || data.worker.freshness_required" :freshness-required="data.freshness_required || data.worker.freshness_required"
:imss-ready="data.worker.imss_ready" :imss-ready="data.worker.imss_ready"
:in-project-without-imss="data.worker.in_project_without_imss" :in-project-without-imss="data.worker.in_project_without_imss"
@ -129,35 +139,22 @@
:worker-id="workerId" :worker-id="workerId"
:assignments="data.assignments" :assignments="data.assignments"
:projects="projects" :projects="projects"
:readonly="!canEdit"
@changed="refreshRelated" @changed="refreshRelated"
/> />
</TabPanel> </TabPanel>
<TabPanel value="nomina"> <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> <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"> <Message severity="secondary" :closable="false" class="mt-2">
<FormField label="Monto a entregar"> <template v-if="canEdit">Use <strong>Préstamo</strong> en la barra para registrar. Esta ficha no edita los datos de la persona.</template>
<InputNumber v-model="loanAmount" mode="currency" currency="MXN" locale="es-MX" class="w-full" /> <template v-else>La ficha es solo consulta. El préstamo se registra aquí; para editar datos de la persona, abra Padrón.</template>
</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> </Message>
<template v-else> <Button
<FormField label="Plan" class="mt-2"> label="Registrar préstamo"
<Select v-model="loanPlan" :options="planOpts" option-label="label" option-value="value" class="w-full" /> icon="pi pi-wallet"
</FormField> class="w-full mt-2"
<FormField v-if="loanPlan === 'installments'" label="Cuotas" class="mt-2"> @click="registerLoan"
<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>
<DataTable :value="data.loans" size="small" class="mt-3" empty-message="Sin préstamos"> <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="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> <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 WorkerDocuments from "~/components/padron/WorkerDocuments.vue";
import WorkerAssignments from "~/components/padron/WorkerAssignments.vue"; import WorkerAssignments from "~/components/padron/WorkerAssignments.vue";
import AltaImssDialog from "~/components/padron/AltaImssDialog.vue"; import AltaImssDialog from "~/components/padron/AltaImssDialog.vue";
import FormField from "~/components/padron/FormField.vue";
import type { ChecklistItem, CompanyOption, ProjectOption, Worker } from "~/types/padron"; import type { ChecklistItem, CompanyOption, ProjectOption, Worker } from "~/types/padron";
type DetailData = { type DetailData = {
@ -214,12 +210,14 @@ type DetailData = {
freshness_required?: boolean; freshness_required?: boolean;
}; };
const props = defineProps<{ workerId?: number; doc?: string }>(); const props = defineProps<{ workerId?: number; doc?: string; tab?: string }>();
const emit = defineEmits<{ edit: [] }>(); const emit = defineEmits<{ edit: []; changed: [] }>();
const { pipelineLabel, pipelineSeverity, initials, riskLevelLabel, riskLevelSeverity, hireSeverity } = useUi(); const { pipelineLabel, pipelineSeverity, initials, riskLevelLabel, riskLevelSeverity, hireSeverity } = useUi();
const { api, download } = useApi(); const { api, download } = useApi();
const toast = useToast(); const toast = useToast();
const desk = useDesktop(); const desk = useDesktop();
const route = useRoute();
const canEdit = computed(() => route.path.startsWith("/padron"));
const data = ref<DetailData | null>(null); const data = ref<DetailData | null>(null);
const projects = ref<ProjectOption[]>([]); const projects = ref<ProjectOption[]>([]);
@ -229,17 +227,6 @@ const tab = ref("docs");
const photoOk = ref(true); const photoOk = ref(true);
const showAltaImss = ref(false); const showAltaImss = ref(false);
const imssDialogTab = ref<"alta" | "baja" | "historial">("alta"); 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 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 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}`); 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; } if (!props.workerId) { data.value = null; return; }
loading.value = true; loading.value = true;
try { try {
const [detail, projectResponse, companyResponse, payrollSettings] = await Promise.all([ const [detail, projectResponse, companyResponse] = await Promise.all([
api<DetailData>(`/v1/workers/${props.workerId}`), api<DetailData>(`/v1/workers/${props.workerId}`),
api<{ projects: ProjectOption[] }>("/v1/projects?status=activo"), api<{ projects: ProjectOption[] }>("/v1/projects?status=activo"),
api<{ companies: CompanyOption[] }>("/v1/companies"), 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; data.value = detail;
projects.value = projectResponse.projects; projects.value = projectResponse.projects;
companies.value = companyResponse.companies.filter((c) => c.status === "activo"); companies.value = companyResponse.companies.filter((c) => c.status === "activo");
photoOk.value = true; 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 { } finally {
loading.value = false; loading.value = false;
} }
@ -327,40 +310,22 @@ function onImssSaved(kind: "alta" | "baja") {
}); });
refreshRelated(); refreshRelated();
} }
async function addLoan() { function goPadron() {
if (!props.workerId || loanAmount.value <= 0) return; if (!props.workerId) return;
const created = await api<{ id: number }>("/v1/loans", { navigateTo(`/padron?worker=${props.workerId}`);
method: "POST", }
json: { function registerLoan() {
worker_id: props.workerId, if (!props.workerId) return;
delivered: loanAmount.value, desk.openLoan(props.workerId, title.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 downloadLoan(id: number) { function downloadLoan(id: number) {
download(`/v1/loans/${id}/recibo`, `prestamo-aceptacion-${id}.pdf`); download(`/v1/loans/${id}/recibo`, `prestamo-aceptacion-${id}.pdf`);
} }
watch(() => [props.workerId, props.doc, desk.detailRev.value] as const, ([, doc]) => { watch(() => [props.workerId, props.tab] as const, ([, fichaTab]) => {
if (doc) tab.value = "docs"; tab.value = fichaTab || "docs";
});
watch(() => [props.workerId, desk.detailRev.value] as const, () => {
load(); load();
}, { immediate: true }); }, { immediate: true });
</script> </script>
<style scoped>
.loan-switch {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
font-size: 0.9rem;
}
</style>

View 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>

View file

@ -2,7 +2,7 @@
<div class="detail-section"> <div class="detail-section">
<div class="section-title">Asignaciones a proyectos</div> <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="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" /> <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" /> <Button label="Asignar" icon="pi pi-plus" :disabled="!projectId" @click="assignProject" />
</div> </div>
@ -17,7 +17,7 @@
<Column header="Estado"> <Column header="Estado">
<template #body="{ data }"><Tag :value="data.active ? 'Activa' : 'Finalizada'" :severity="data.active ? 'success' : 'secondary'" /></template> <template #body="{ data }"><Tag :value="data.active ? 'Activa' : 'Finalizada'" :severity="data.active ? 'success' : 'secondary'" /></template>
</Column> </Column>
<Column header=""> <Column v-if="!readonly" header="">
<template #body="{ data }"> <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-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)" /> <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"; 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 }; 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 emit = defineEmits<{ changed: [] }>();
const { api } = useApi(); const { api } = useApi();
const confirm = useConfirm(); const confirm = useConfirm();

View file

@ -5,11 +5,11 @@
</Message> </Message>
<Message v-else-if="imssReady" severity="info" :closable="false" class="mb-3"> <Message v-else-if="imssReady" severity="info" :closable="false" class="mb-3">
Expediente listo para alta IMSS. 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>
<Message v-else-if="inProjectWithoutImss" severity="error" :closable="false" class="mb-3"> <Message v-else-if="inProjectWithoutImss" severity="error" :closable="false" class="mb-3">
Está en obra sin alta IMSS. 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> </Message>
<div class="document-progress"> <div class="document-progress">
@ -48,7 +48,7 @@
</div> </div>
</Panel> </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"> <FormField label="Tipo de documento">
<Select <Select
v-model="documentType" v-model="documentType"
@ -157,6 +157,7 @@ const props = defineProps<{
checklist: ChecklistItem[]; checklist: ChecklistItem[];
companies?: CompanyOption[]; companies?: CompanyOption[];
initialType?: string; initialType?: string;
readonly?: boolean;
freshnessRequired?: boolean; freshnessRequired?: boolean;
imssReady?: boolean; imssReady?: boolean;
inProjectWithoutImss?: boolean; inProjectWithoutImss?: boolean;

View file

@ -5,6 +5,7 @@ export type RibbonItem = {
label: string; label: string;
icon: string; icon: string;
disabled?: boolean; disabled?: boolean;
title?: string;
command?: () => void; command?: () => void;
}; };
@ -18,6 +19,7 @@ export type DeskDetail = {
kind: "none" | "worker" | "project" | "budget"; kind: "none" | "worker" | "project" | "budget";
id?: number; id?: number;
doc?: string; doc?: string;
tab?: string;
project?: Record<string, unknown>; project?: Record<string, unknown>;
budgetItem?: Record<string, unknown>; budgetItem?: Record<string, unknown>;
}; };
@ -97,8 +99,8 @@ export function useDesktop() {
onTreeSelect.value?.(key); onTreeSelect.value?.(key);
} }
function openWorker(id: number, doc?: string) { function openWorker(id: number, doc?: string, tab?: string) {
detail.value = { kind: "worker", id, doc }; detail.value = { kind: "worker", id, doc, tab };
showDetail.value = true; showDetail.value = true;
} }
@ -120,6 +122,18 @@ export function useDesktop() {
detailRev.value += 1; 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 { return {
tabLabel, tabLabel,
ribbon, ribbon,
@ -143,5 +157,8 @@ export function useDesktop() {
openBudgetItem, openBudgetItem,
clearDetail, clearDetail,
bumpDetail, bumpDetail,
loanPrompt,
openLoan,
closeLoan,
}; };
} }

View file

@ -67,6 +67,7 @@
v-if="detail.kind === 'worker'" v-if="detail.kind === 'worker'"
:worker-id="detail.id" :worker-id="detail.id"
:doc="detail.doc" :doc="detail.doc"
:tab="detail.tab"
@edit="editFromFicha" @edit="editFromFicha"
/> />
<ProjectFicha <ProjectFicha
@ -92,10 +93,19 @@
</div> </div>
<AppStatusbar /> <AppStatusbar />
<LoanDialog
:visible="loanPrompt.visible"
:worker-id="loanPrompt.workerId"
:person-name="loanPrompt.personName"
@update:visible="setLoanVisible"
@saved="onLoanSaved"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import LoanDialog from "~/components/padron/LoanDialog.vue";
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { initials } = useUi(); const { initials } = useUi();
const route = useRoute(); const route = useRoute();
@ -103,17 +113,28 @@ const desk = useDesktop();
const toast = useToast(); const toast = useToast();
const { isDark, toggle: toggleThemeMode } = useTheme(); const { isDark, toggle: toggleThemeMode } = useTheme();
const profileMenu = ref(); const profileMenu = ref();
const { tabLabel, showTree, showDetail, detail, globalQ } = desk; const { tabLabel, showTree, showDetail, detail, globalQ, loanPrompt } = desk;
const profileItems = computed(() => [ const profileItems = computed(() => [
{ label: user.value?.display_name || user.value?.username || "Usuario", disabled: true }, { label: user.value?.display_name || user.value?.username || "Usuario", disabled: true },
{ label: user.value?.company_name || user.value?.company_code || "Empresa", disabled: true }, { label: user.value?.company_name || user.value?.company_code || "Empresa", disabled: true },
{ separator: 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", label: isDark.value ? "Tema claro" : "Tema oscuro",
icon: isDark.value ? "pi pi-sun" : "pi pi-moon", icon: isDark.value ? "pi pi-sun" : "pi pi-moon",
command: toggleTheme, command: toggleTheme,
}, },
{ separator: true },
{ label: "Cerrar sesión", icon: "pi pi-sign-out", command: logout }, { label: "Cerrar sesión", icon: "pi pi-sign-out", command: logout },
]); ]);
@ -121,13 +142,12 @@ const brand = computed(() => "PANELS");
const menu = [ const menu = [
{ label: "Inicio", icon: "pi pi-home", to: "/", command: () => navigateTo("/") }, { 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: "Proyectos", icon: "pi pi-building", to: "/proyectos", command: () => navigateTo("/proyectos") },
{ label: "Presupuesto", icon: "pi pi-calculator", to: "/presupuesto", command: () => navigateTo("/presupuesto") }, { 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: "Kanban", icon: "pi pi-th-large", to: "/kanban", command: () => navigateTo("/kanban") },
{ label: "Gafetes", icon: "pi pi-id-card", to: "/gafetes", command: () => navigateTo("/gafetes") }, { 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: "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 }, { label: "Ayuda", icon: "pi pi-question-circle", command: showHelp },
]; ];
@ -168,10 +188,19 @@ function editFromFicha() {
edit?.command?.(); edit?.command?.();
} }
function onLoanSaved() {
desk.bumpDetail();
}
function setLoanVisible(open: boolean) {
loanPrompt.value.visible = open;
}
watch( watch(
() => route.path, () => route.path,
(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("/proyectos") && desk.detail.value.kind === "project") desk.clearDetail();
if (!path.startsWith("/presupuesto") && desk.detail.value.kind === "budget") desk.clearDetail(); if (!path.startsWith("/presupuesto") && desk.detail.value.kind === "budget") desk.clearDetail();
}, },

View file

@ -1,13 +1,14 @@
{ {
"name": "panel-obra-web", "name": "panel-obra-web-panel",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "panel-obra-web", "name": "panel-obra-web-panel",
"dependencies": { "dependencies": {
"@primeuix/themes": "^1.2.3", "@primeuix/themes": "^1.2.3",
"@primevue/nuxt-module": "^4.3.7", "@primevue/nuxt-module": "^4.3.7",
"chart.js": "^4.5.1",
"nuxt": "^3.16.2", "nuxt": "^3.16.2",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primevue": "^4.3.7", "primevue": "^4.3.7",
@ -1122,6 +1123,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@kwsites/file-exists": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
@ -4402,6 +4409,18 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/chokidar": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",

View file

@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@primeuix/themes": "^1.2.3", "@primeuix/themes": "^1.2.3",
"@primevue/nuxt-module": "^4.3.7", "@primevue/nuxt-module": "^4.3.7",
"chart.js": "^4.5.1",
"nuxt": "^3.16.2", "nuxt": "^3.16.2",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primevue": "^4.3.7", "primevue": "^4.3.7",

View file

@ -17,8 +17,9 @@
<TabPanels> <TabPanels>
<TabPanel value="empresas"> <TabPanel value="empresas">
<Message severity="info" :closable="false"> <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>. 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> </Message>
<DataTable :value="listedCompanies" class="mt-3" data-key="id" paginator :rows="15" empty-message="No hay empresas registradas."> <DataTable :value="listedCompanies" class="mt-3" data-key="id" paginator :rows="15" empty-message="No hay empresas registradas.">
<Column field="code" header="Código" /> <Column field="code" header="Código" />
@ -245,6 +246,7 @@ const { riskSeverity, riskLevelLabel, riskLevelSeverity } = useUi();
const desk = useDesktop(); const desk = useDesktop();
const { api } = useApi(); const { api } = useApi();
const toast = useToast(); const toast = useToast();
const route = useRoute();
const tab = ref("empresas"); const tab = ref("empresas");
const risks = ref<Record<string, string>[]>([]); const risks = ref<Record<string, string>[]>([]);
const themes = ref<Record<string, string>[]>([]); const themes = ref<Record<string, string>[]>([]);
@ -497,6 +499,13 @@ function syncChrome() {
}); });
} }
onMounted(load); 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) => { watch(tab, (v) => {
desk.treeKey.value = v; desk.treeKey.value = v;
syncChrome(); syncChrome();

View file

@ -1,9 +1,15 @@
<template> <template>
<div> <div class="dash">
<div class="kpi-grid"> <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> <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' }" /> <Avatar :icon="s.icon" shape="circle" :style="{ background: s.bg, color: '#fff' }" />
<Tag :value="s.hint" :severity="s.severity" /> <Tag :value="s.hint" :severity="s.severity" />
</div> </div>
@ -13,9 +19,74 @@
</Card> </Card>
</div> </div>
<Panel header="Flujo de expediente" class="mt-3" toggleable> <div class="dash-charts">
<MeterGroup :value="meter" label-position="start" /> <Card>
</Panel> <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="home-split">
<div class="list-page"> <div class="list-page">
@ -27,7 +98,7 @@
<template #body="{ data }"> <template #body="{ data }">
<div class="person-cell"> <div class="person-cell">
<Avatar :label="initials(data.first_name, data.last_name_p)" shape="circle" size="small" /> <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> </div>
</template> </template>
</Column> </Column>
@ -39,31 +110,33 @@
</Column> </Column>
</DataTable> </DataTable>
</div> </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>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ChartData, ChartOptions } from "chart.js";
import type { DeskStatusMessage } from "~/composables/useDesktop"; 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 desk = useDesktop();
const { api } = useApi();
const { tableQ, matches } = useTableSearch();
type Worker = { type Worker = {
id?: number;
first_name: string; first_name: string;
middle_name?: string | null;
last_name_p: string; last_name_p: string;
last_name_m?: string | null;
status: string; status: string;
pipeline_status: string; pipeline_status: string;
position: string; position: string;
risk_code: string; risk_code: string;
risk_color: string; risk_color: string;
work_type?: string;
imss_status?: string; imss_status?: string;
imss_ready?: boolean; imss_ready?: boolean;
in_project_without_imss?: boolean; in_project_without_imss?: boolean;
@ -74,35 +147,54 @@ type Worker = {
}; };
type ProjectRow = { type ProjectRow = {
id?: number;
name?: string;
code?: string;
status: string; status: string;
active_count?: number;
missing_docs?: number; missing_docs?: number;
}; };
type JobRow = { type JobRow = { delivered?: number | boolean };
delivered?: number | boolean;
};
type WeekRow = { type WeekRow = {
status?: string; 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 workers = ref<Worker[]>([]);
const projects = ref<ProjectRow[]>([]);
const openWeek = ref<WeekRow | null>(null);
const stats = ref([ const stats = ref([
{ label: "Activos", value: "—", icon: "pi pi-users", bg: "#0f2744", hint: "Alta", severity: "success" }, { label: "Activos", value: "—", icon: "pi pi-users", bg: "#0f2744", hint: "Alta", severity: "success" as const, to: "/padron" },
{ label: "Proyectos", value: "—", icon: "pi pi-building", bg: "#1c4a7a", hint: "Proyectos", severity: "info" }, { label: "Obras", value: "—", icon: "pi pi-building", bg: "#1c4a7a", hint: "Proyectos", severity: "info" as const, to: "/proyectos" },
{ label: "Listos gafete", value: "—", icon: "pi pi-id-card", bg: "#c9a227", hint: "Imprimir", severity: "warn" }, { label: "A pagar (semana)", value: "—", icon: "pi pi-wallet", bg: "#194ca8", hint: "Nómina", severity: "info" as const, to: "/nomina" },
{ label: "Jobs PDF", value: "—", icon: "pi pi-file-pdf", bg: "#a20000", hint: "Archivo", severity: "danger" }, { 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(() => const listedReady = computed(() =>
workers.value workers.value
.filter((w) => w.pipeline_status === "listo_gafete") .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) { function hasProject(worker: Worker) {
return String(worker.project_ids || "") return String(worker.project_ids || "")
.split(",") .split(",")
@ -110,6 +202,162 @@ function hasProject(worker: Worker) {
.some((id) => id > 0); .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: { function buildHomeAlerts(input: {
workers: Worker[]; workers: Worker[];
projects: ProjectRow[]; projects: ProjectRow[];
@ -263,6 +511,15 @@ function buildHomeAlerts(input: {
return messages; 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 () => { onMounted(async () => {
desk.configure({ desk.configure({
tab: "Inicio", tab: "Inicio",
@ -284,17 +541,25 @@ onMounted(async () => {
api<{ workers: Worker[]; imss_stats?: { imss_ready_count?: number } }>("/v1/workers"), api<{ workers: Worker[]; imss_stats?: { imss_ready_count?: number } }>("/v1/workers"),
api<{ projects: ProjectRow[] }>("/v1/projects?status=activo,pausado"), api<{ projects: ProjectRow[] }>("/v1/projects?status=activo,pausado"),
api<{ jobs: JobRow[] }>("/v1/badge-jobs"), 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; 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 listos = w.workers.filter((x) => x.pipeline_status === "listo_gafete").length;
const activosProyectos = p.projects.filter((x) => x.status === "activo").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 = [ stats.value = [
{ label: "Activos", value: String(activos), icon: "pi pi-users", bg: "#0f2744", hint: "Alta", severity: "success" }, { label: "Activos", value: String(activosN), icon: "pi pi-users", bg: "#0f2744", hint: "Alta", severity: "success", to: "/padron" },
{ label: "Proyectos", value: String(activosProyectos), icon: "pi pi-building", bg: "#1c4a7a", hint: "Proyectos", severity: "info" }, { label: "Obras", value: String(activosProyectos), icon: "pi pi-building", bg: "#1c4a7a", hint: "Proyectos", severity: "info", to: "/proyectos" },
{ label: "Listos gafete", value: String(listos), icon: "pi pi-id-card", bg: "#c9a227", hint: "Imprimir", severity: "warn" }, { 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: "Jobs PDF", value: String(j.jobs.length), icon: "pi pi-file-pdf", bg: "#a20000", hint: "Archivo", severity: "danger" }, { 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( desk.setStatusMessages(
buildHomeAlerts({ buildHomeAlerts({
@ -305,16 +570,6 @@ onMounted(async () => {
imssReady: w.imss_stats?.imss_ready_count || 0, 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(() => { onBeforeUnmount(() => {

View file

@ -15,8 +15,14 @@
</Card> </Card>
<Card> <Card>
<template #content> <template #content>
<div class="muted">{{ board.delta >= 0 ? "Sobrante" : "Faltante" }}</div> <div class="muted">Recuperación de préstamos</div>
<div class="nomina-kpi" :class="board.delta >= 0 ? 'ok' : 'warn'">{{ money(Math.abs(board.delta)) }}</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> </template>
</Card> </Card>
</div> </div>
@ -40,11 +46,15 @@
<TabPanels> <TabPanels>
<TabPanel value="obra"> <TabPanel value="obra">
<Message severity="secondary" :closable="false"> <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> </Message>
<DataTable :value="obraLines" scrollable scroll-height="flex" empty-message="Sin personal de jornal en esta obra"> <DataTable :value="obraLines" scrollable scroll-height="flex" empty-message="Sin personal de jornal en esta obra">
<Column header="Persona" frozen> <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>
<Column <Column
v-for="(day, idx) in days" 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="A pagar"><template #body="{ data }">{{ money(Number(data.payable_net)) }}</template></Column>
<Column header="Préstamo"> <Column header="Préstamo">
<template #body="{ data }"> <template #body="{ data }">
<span v-if="data.loan_label" class="loan-label">{{ data.loan_label }}</span> <button type="button" class="person-link loan-link" @click="openLoanFor(data.worker_id, personLabel(data))">
<span v-else class="muted">—</span> {{ data.loan_label || "Registrar" }}
</button>
</template> </template>
</Column> </Column>
</DataTable> </DataTable>
@ -88,7 +99,11 @@
<DataTable :value="destajoCuts" empty-message="Sin destajo en este corte"> <DataTable :value="destajoCuts" empty-message="Sin destajo en este corte">
<Column header="Obra" field="project_name" /> <Column header="Obra" field="project_name" />
<Column header="Persona"> <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>
<Column header="Concepto" field="concepto" /> <Column header="Concepto" field="concepto" />
<Column header="Unidad" field="unit_code" /> <Column header="Unidad" field="unit_code" />
@ -141,7 +156,11 @@
<Message severity="secondary" :closable="false">Lista aparte, sueldo semanal, sin asistencia. Proyecto opcional.</Message> <Message severity="secondary" :closable="false">Lista aparte, sueldo semanal, sin asistencia. Proyecto opcional.</Message>
<DataTable :value="adminLines" empty-message="Sin personal administrativo"> <DataTable :value="adminLines" empty-message="Sin personal administrativo">
<Column header="Persona"> <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>
<Column header="Sueldo"> <Column header="Sueldo">
<template #body="{ data }"> <template #body="{ data }">
@ -206,12 +225,18 @@
</template> </template>
<script setup lang="ts"> <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 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; id: number;
worker_id: number; worker_id: number;
first_name: string;
last_name_p: string;
days: number; days: number;
daily_wage: number; daily_wage: number;
payable_net: number; payable_net: number;
@ -220,11 +245,10 @@ type Line = {
cells?: Cell[]; cells?: Cell[];
}; };
type Sheet = { id: number; kind: string; project_id: number | null; project_name?: string; lines: Line[] }; type Sheet = { id: number; kind: string; project_id: number | null; project_name?: string; lines: Line[] };
type Cut = { type Cut = NameParts & {
id: number; id: number;
worker_id: number;
project_name: string; project_name: string;
first_name: string;
last_name_p: string;
concepto: string; concepto: string;
unit_code: string; unit_code: string;
qty_total_estimated: number; qty_total_estimated: number;
@ -250,10 +274,14 @@ const destajoPeriod = ref<{ period_start: string; period_end: string } | null>(n
const sheets = ref<Sheet[]>([]); const sheets = ref<Sheet[]>([]);
const destajoCuts = ref<Cut[]>([]); const destajoCuts = ref<Cut[]>([]);
const days = ref<string[]>([]); 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 showDestajo = ref(false);
const showAdmin = ref(false); const showAdmin = ref(false);
const showAddJornal = ref(false); const showAddJornal = ref(false);
const selectedWorkerId = ref<number | null>(null);
const selectedWorkerName = ref("");
const jornalWorkerId = ref<number | null>(null); const jornalWorkerId = ref<number | null>(null);
const jobForm = reactive({ const jobForm = reactive({
project_id: null as number | null, project_id: null as number | null,
@ -325,22 +353,24 @@ function cell(line: Line, idx: number) {
async function load() { async function load() {
const [p, w, bundle] = await Promise.all([ const [p, w, bundle] = await Promise.all([
api<{ projects: { id: number; name: string }[] }>("/v1/projects?status=activo,pausado"), 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<{ api<{
week: Record<string, unknown>; week: Record<string, unknown>;
destajo_period: { period_start: string; period_end: string } | null; destajo_period: { period_start: string; period_end: string } | null;
sheets: Sheet[]; sheets: Sheet[];
destajo_cuts: Cut[]; destajo_cuts: Cut[];
days: string[]; 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 }[]; units: { code: string; label: string }[];
can_assemble: boolean;
assemble_from: string;
}>(`/v1/payroll/weeks?week_start=${weekStart.value}`), }>(`/v1/payroll/weeks?week_start=${weekStart.value}`),
]); ]);
projects.value = p.projects; projects.value = p.projects;
if (!projectId.value && p.projects[0]) projectId.value = p.projects[0].id; if (!projectId.value && p.projects[0]) projectId.value = p.projects[0].id;
allWorkers.value = w.workers.map((x) => ({ allWorkers.value = w.workers.map((x) => ({
id: x.id, id: x.id,
label: `${x.last_name_p} ${x.first_name}`, label: workerFullName(x),
work_type: x.work_type, work_type: x.work_type,
})); }));
week.value = bundle.week; week.value = bundle.week;
@ -348,19 +378,40 @@ async function load() {
sheets.value = bundle.sheets; sheets.value = bundle.sheets;
destajoCuts.value = bundle.destajo_cuts || []; destajoCuts.value = bundle.destajo_cuts || [];
days.value = bundle.days || []; 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 || []; units.value = bundle.units || [];
canAssemble.value = Boolean(bundle.can_assemble);
assembleFrom.value = bundle.assemble_from || "";
syncChrome(); syncChrome();
} }
function syncChrome() { function syncChrome() {
const st = String(week.value?.status || "draft"); 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([ desk.setStatusMessages([
{ {
key: "nom_week", key: "nom_week",
severity: st === "paid" ? "info" : board.value.delta < 0 ? "warn" : "info", severity: st === "paid" ? "info" : short > 0 ? "warn" : "info",
title: st === "paid" ? "Semana pagada" : `Caja ${money(board.value.required_net)} · a pagar ${money(board.value.payable_net)}`, title,
detail: board.value.delta < 0 ? "Hay faltante respecto a lo armado el jueves." : "Jueves se arma, sábado se paga.", detail,
route: "/nomina", route: "/nomina",
}, },
]); ]);
@ -368,7 +419,7 @@ function syncChrome() {
tab: "Nómina", tab: "Nómina",
treeTitle: "Obras", treeTitle: "Obras",
showTree: true, showTree: true,
showDetail: false, showDetail: true,
treeKey: projectId.value ? `p:${projectId.value}` : "", treeKey: projectId.value ? `p:${projectId.value}` : "",
countLabel: weekLabel.value, countLabel: weekLabel.value,
onTreeSelect: (key) => { onTreeSelect: (key) => {
@ -381,7 +432,16 @@ function syncChrome() {
key: "acciones", key: "acciones",
label: "Nómina", label: "Nómina",
items: [ 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: "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: "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: "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: "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: "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(); 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) { async function toggleDay(line: Line, date: string, present: boolean) {
if (!obraSheet.value?.project_id || paid.value) return; if (!obraSheet.value?.project_id || paid.value) return;
try { try {
@ -425,9 +531,18 @@ async function toggleDay(line: Line, date: string, present: boolean) {
async function assemble() { async function assemble() {
if (!week.value) return; if (!week.value) return;
try {
await api(`/v1/payroll/weeks/${week.value.id}/assemble`, { method: "POST" }); await api(`/v1/payroll/weeks/${week.value.id}/assemble`, { method: "POST" });
toast.add({ severity: "success", summary: "Nómina armada", detail: "Requerida congelada", life: 2200 }); toast.add({ severity: "success", summary: "Nómina armada", detail: "Requerida congelada", life: 2200 });
await load(); 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() { async function pay() {
@ -508,6 +623,9 @@ async function addJornal() {
} }
watch(projectId, syncChrome); watch(projectId, syncChrome);
watch(() => desk.detailRev.value, () => {
if (week.value) load();
});
onMounted(load); onMounted(load);
onBeforeUnmount(() => desk.clearStatusMessages()); onBeforeUnmount(() => desk.clearStatusMessages());
</script> </script>
@ -515,7 +633,7 @@ onBeforeUnmount(() => desk.clearStatusMessages());
<style scoped> <style scoped>
.nomina-board { .nomina-board {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: 12px; gap: 12px;
margin-bottom: 12px; margin-bottom: 12px;
} }
@ -528,7 +646,22 @@ onBeforeUnmount(() => desk.clearStatusMessages());
.nomina-kpi.warn { color: var(--p-orange-600); } .nomina-kpi.warn { color: var(--p-orange-600); }
.destajo-cut { margin-left: 10px; } .destajo-cut { margin-left: 10px; }
.staff-loan-tag { max-width: 5rem; overflow: hidden; text-overflow: ellipsis; } .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; } .cut-input { width: 7.5rem; }
.loan-form { display: flex; flex-direction: column; gap: 10px; } .loan-form { display: flex; flex-direction: column; gap: 10px; }
@media (max-width: 800px) { @media (max-width: 800px) {

View file

@ -218,6 +218,11 @@ function openWorker(worker: Worker) {
selected.value = worker; selected.value = worker;
desk.openWorker(worker.id); 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) { async function save(form: WorkerForm) {
saving.value = true; saving.value = true;
formError.value = ""; formError.value = "";
@ -388,6 +393,7 @@ function syncChrome() {
items: [ items: [
{ key: "alta", label: "Nuevo", icon: "pi pi-plus", command: openNew }, { 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: "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: "baja", label: "Baja", icon: "pi pi-user-minus", disabled: !selected.value || selected.value.status === "baja", command: () => selected.value && baja(selected.value) },
{ {
key: "reactivate", key: "reactivate",
@ -429,10 +435,20 @@ onMounted(async () => {
const route = useRoute(); const route = useRoute();
if (route.query.nuevo) openNew(); if (route.query.nuevo) openNew();
if (route.query.import) showImport.value = true; 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); const worker = workers.value.find((item) => item.id === desk.detail.value.id);
if (worker) selected.value = worker; if (worker) selected.value = worker;
} }
if (route.query.prestamo && selected.value) {
desk.openLoan(selected.value.id, workerFullName(selected.value));
}
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {

View 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;
});

View file

@ -217,7 +217,12 @@ export function companyLabel(company: { code: string; name: string; kind?: strin
return company.code; 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] return [worker.last_name_p, worker.last_name_m, worker.first_name, worker.middle_name]
.filter(Boolean) .filter(Boolean)
.join(" "); .join(" ");