From 97c66282bfad2b0cf4d2eb3601926a8ef1e819e2 Mon Sep 17 00:00:00 2001 From: Alberto Martinez Date: Wed, 26 Aug 2026 19:02:41 -0500 Subject: [PATCH] Ajustes para nomina y dashboard --- api/payroll.ts | 51 ++- api/payroll_http.ts | 14 +- api/payroll_test.ts | 55 ++- web-panel/assets/css/app.css | 54 ++- web-panel/assets/css/desktop-v2.css | 6 +- web-panel/components/AppRibbon.vue | 1 + web-panel/components/WorkerFicha.vue | 149 +++----- web-panel/components/padron/LoanDialog.vue | 119 ++++++ .../components/padron/WorkerAssignments.vue | 6 +- .../components/padron/WorkerDocuments.vue | 7 +- web-panel/composables/useDesktop.ts | 21 +- web-panel/layouts/default.vue | 37 +- web-panel/package-lock.json | 23 +- web-panel/package.json | 1 + web-panel/pages/configuracion.vue | 11 +- web-panel/pages/index.vue | 343 +++++++++++++++--- web-panel/pages/nomina.vue | 191 ++++++++-- web-panel/pages/padron/index.vue | 18 +- web-panel/plugins/chart.client.ts | 7 + web-panel/types/padron.ts | 7 +- 20 files changed, 918 insertions(+), 203 deletions(-) create mode 100644 web-panel/components/padron/LoanDialog.vue create mode 100644 web-panel/plugins/chart.client.ts diff --git a/api/payroll.ts b/api/payroll.ts index 339d32e..6023931 100644 --- a/api/payroll.ts +++ b/api/payroll.ts @@ -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); } diff --git a/api/payroll_http.ts b/api/payroll_http.ts index 9b8bfb6..ac001f3 100644 --- a/api/payroll_http.ts +++ b/api/payroll_http.ts @@ -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) }, diff --git a/api/payroll_test.ts b/api/payroll_test.ts index a95f3d3..fb0d383 100644 --- a/api/payroll_test.ts +++ b/api/payroll_test.ts @@ -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; diff --git a/web-panel/assets/css/app.css b/web-panel/assets/css/app.css index a634f20..fd93069 100644 --- a/web-panel/assets/css/app.css +++ b/web-panel/assets/css/app.css @@ -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; } } diff --git a/web-panel/assets/css/desktop-v2.css b/web-panel/assets/css/desktop-v2.css index 160a28e..6f8b8e6 100644 --- a/web-panel/assets/css/desktop-v2.css +++ b/web-panel/assets/css/desktop-v2.css @@ -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 { diff --git a/web-panel/components/AppRibbon.vue b/web-panel/components/AppRibbon.vue index 7d71ec5..a4bc593 100644 --- a/web-panel/components/AppRibbon.vue +++ b/web-panel/components/AppRibbon.vue @@ -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 diff --git a/web-panel/components/WorkerFicha.vue b/web-panel/components/WorkerFicha.vue index 81f2ec7..8d8bf92 100644 --- a/web-panel/components/WorkerFicha.vue +++ b/web-panel/components/WorkerFicha.vue @@ -41,29 +41,31 @@
IMSS
-