mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 12:43:18 +00:00
Ajustes para nomina y dashboard
This commit is contained in:
parent
085366c287
commit
97c66282bf
20 changed files with 918 additions and 203 deletions
|
|
@ -83,6 +83,14 @@ export function weekDays(weekStart: string): string[] {
|
||||||
return [0, 1, 2, 3, 4, 5].map((n) => addDays(weekStart, n));
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) },
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -41,29 +41,31 @@
|
||||||
<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">
|
||||||
<Button
|
<template v-if="canEdit">
|
||||||
v-if="data.worker.imss_status !== 'alta'"
|
|
||||||
label="Registrar alta"
|
|
||||||
icon="pi pi-file-pdf"
|
|
||||||
size="small"
|
|
||||||
@click="openImss('alta')"
|
|
||||||
/>
|
|
||||||
<template v-else>
|
|
||||||
<Button
|
<Button
|
||||||
label="Reemplazar alta"
|
v-if="data.worker.imss_status !== 'alta'"
|
||||||
icon="pi pi-upload"
|
label="Registrar alta"
|
||||||
|
icon="pi pi-file-pdf"
|
||||||
size="small"
|
size="small"
|
||||||
outlined
|
|
||||||
@click="openImss('alta')"
|
@click="openImss('alta')"
|
||||||
/>
|
/>
|
||||||
<Button
|
<template v-else>
|
||||||
label="Registrar baja"
|
<Button
|
||||||
icon="pi pi-times-circle"
|
label="Reemplazar alta"
|
||||||
size="small"
|
icon="pi pi-upload"
|
||||||
severity="warn"
|
size="small"
|
||||||
outlined
|
outlined
|
||||||
@click="openImss('baja')"
|
@click="openImss('alta')"
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
label="Registrar baja"
|
||||||
|
icon="pi pi-times-circle"
|
||||||
|
size="small"
|
||||||
|
severity="warn"
|
||||||
|
outlined
|
||||||
|
@click="openImss('baja')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<Button
|
<Button
|
||||||
label="Historial"
|
label="Historial"
|
||||||
|
|
@ -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>
|
</Message>
|
||||||
<div class="loan-switch">
|
<Button
|
||||||
<ToggleSwitch v-model="loanCommissionOn" />
|
label="Registrar préstamo"
|
||||||
<span>Comisión {{ loanCommissionPct }}%</span>
|
icon="pi pi-wallet"
|
||||||
</div>
|
class="w-full mt-2"
|
||||||
<FormField v-if="loanCommissionOn" label="Porcentaje" class="mt-2">
|
@click="registerLoan"
|
||||||
<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>
|
|
||||||
<template v-else>
|
|
||||||
<FormField label="Plan" class="mt-2">
|
|
||||||
<Select v-model="loanPlan" :options="planOpts" option-label="label" option-value="value" class="w-full" />
|
|
||||||
</FormField>
|
|
||||||
<FormField v-if="loanPlan === 'installments'" label="Cuotas" class="mt-2">
|
|
||||||
<InputNumber v-model="loanInstallments" :min="2" :max="24" class="w-full" />
|
|
||||||
</FormField>
|
|
||||||
</template>
|
|
||||||
<Button label="Registrar préstamo" icon="pi pi-plus" class="w-full mt-2" :disabled="loanAmount <= 0" @click="addLoan" />
|
|
||||||
</Fieldset>
|
|
||||||
<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>
|
|
||||||
|
|
|
||||||
119
web-panel/components/padron/LoanDialog.vue
Normal file
119
web-panel/components/padron/LoanDialog.vue
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
:visible="visible"
|
||||||
|
header="Registrar préstamo"
|
||||||
|
modal
|
||||||
|
:style="{ width: '28rem' }"
|
||||||
|
@update:visible="emit('update:visible', $event)"
|
||||||
|
>
|
||||||
|
<p v-if="personName" class="muted mb-3">{{ personName }}</p>
|
||||||
|
<FormField label="Monto a entregar" required>
|
||||||
|
<InputNumber v-model="loanAmount" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
<div class="loan-switch">
|
||||||
|
<ToggleSwitch v-model="loanCommissionOn" />
|
||||||
|
<span>Comisión {{ loanCommissionPct }}%</span>
|
||||||
|
</div>
|
||||||
|
<FormField v-if="loanCommissionOn" label="Porcentaje" class="mt-2">
|
||||||
|
<InputNumber v-model="loanCommissionPct" suffix=" %" :min="0" :max="100" :max-fraction-digits="2" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
<Message v-if="loanIsSmall" severity="secondary" :closable="false" class="mt-3">
|
||||||
|
Préstamo chico (tope {{ money(loanSmallMax) }}): 1 exhibición el sábado de la semana siguiente.
|
||||||
|
Recibe {{ money(loanAmount) }}; se descuenta {{ money(loanAmount + (loanCommissionOn ? loanAmount * loanCommissionPct / 100 : 0)) }}.
|
||||||
|
</Message>
|
||||||
|
<template v-else>
|
||||||
|
<FormField label="Plan" class="mt-3">
|
||||||
|
<Select v-model="loanPlan" :options="planOpts" option-label="label" option-value="value" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
<FormField v-if="loanPlan === 'installments'" label="Cuotas" class="mt-2">
|
||||||
|
<InputNumber v-model="loanInstallments" :min="2" :max="24" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Cancelar" text @click="emit('update:visible', false)" />
|
||||||
|
<Button label="Registrar" icon="pi pi-check" :disabled="loanAmount <= 0 || saving || !workerId" :loading="saving" @click="save" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import FormField from "~/components/padron/FormField.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean;
|
||||||
|
workerId?: number;
|
||||||
|
personName?: string;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{ "update:visible": [boolean]; saved: [number] }>();
|
||||||
|
const { api, download } = useApi();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const loanAmount = ref(0);
|
||||||
|
const loanCommissionOn = ref(true);
|
||||||
|
const loanCommissionPct = ref(10);
|
||||||
|
const loanSmallMax = ref(500);
|
||||||
|
const loanPlan = ref<"single" | "installments">("single");
|
||||||
|
const loanInstallments = ref(4);
|
||||||
|
const saving = ref(false);
|
||||||
|
const planOpts = [
|
||||||
|
{ label: "1 exhibición", value: "single" },
|
||||||
|
{ label: "Varias cuotas", value: "installments" },
|
||||||
|
];
|
||||||
|
const loanIsSmall = computed(() => loanAmount.value > 0 && loanAmount.value <= loanSmallMax.value);
|
||||||
|
|
||||||
|
function money(value: number) {
|
||||||
|
return value.toLocaleString("es-MX", { style: "currency", currency: "MXN", maximumFractionDigits: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
const payrollSettings = await api<{
|
||||||
|
settings: { loan_commission_enabled: number; loan_commission_pct: number; loan_small_max: number };
|
||||||
|
}>("/v1/payroll/settings");
|
||||||
|
loanCommissionOn.value = Boolean(payrollSettings.settings.loan_commission_enabled);
|
||||||
|
loanCommissionPct.value = Number(payrollSettings.settings.loan_commission_pct);
|
||||||
|
loanSmallMax.value = Number(payrollSettings.settings.loan_small_max);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!props.workerId || loanAmount.value <= 0) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const created = await api<{ id: number }>("/v1/loans", {
|
||||||
|
method: "POST",
|
||||||
|
json: {
|
||||||
|
worker_id: props.workerId,
|
||||||
|
delivered: loanAmount.value,
|
||||||
|
commission_enabled: loanCommissionOn.value,
|
||||||
|
commission_pct: loanCommissionPct.value,
|
||||||
|
plan: loanIsSmall.value ? "single" : loanPlan.value,
|
||||||
|
installments_n: loanIsSmall.value ? 1 : loanInstallments.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
loanAmount.value = 0;
|
||||||
|
toast.add({ severity: "success", summary: "Préstamo registrado", life: 1800 });
|
||||||
|
emit("update:visible", false);
|
||||||
|
emit("saved", created.id);
|
||||||
|
if (created.id) download(`/v1/loans/${created.id}/recibo`, `prestamo-aceptacion-${created.id}.pdf`);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.visible, async (open) => {
|
||||||
|
if (!open) return;
|
||||||
|
loanAmount.value = 0;
|
||||||
|
loanPlan.value = "single";
|
||||||
|
loanInstallments.value = 4;
|
||||||
|
await loadSettings();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.loan-switch {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<div class="detail-section">
|
<div class="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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
23
web-panel/package-lock.json
generated
23
web-panel/package-lock.json
generated
|
|
@ -1,13 +1,14 @@
|
||||||
{
|
{
|
||||||
"name": "panel-obra-web",
|
"name": "panel-obra-web-panel",
|
||||||
"lockfileVersion": 3,
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
await api(`/v1/payroll/weeks/${week.value.id}/assemble`, { method: "POST" });
|
try {
|
||||||
toast.add({ severity: "success", summary: "Nómina armada", detail: "Requerida congelada", life: 2200 });
|
await api(`/v1/payroll/weeks/${week.value.id}/assemble`, { method: "POST" });
|
||||||
await load();
|
toast.add({ severity: "success", summary: "Nómina armada", detail: "Requerida congelada", life: 2200 });
|
||||||
|
await load();
|
||||||
|
} catch (error) {
|
||||||
|
const detail = error as { data?: { error?: string } };
|
||||||
|
toast.add({
|
||||||
|
severity: "warn",
|
||||||
|
summary: detail.data?.error || "Aún no se puede armar esta semana",
|
||||||
|
life: 3200,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pay() {
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
7
web-panel/plugins/chart.client.ts
Normal file
7
web-panel/plugins/chart.client.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { ArcElement, BarElement, CategoryScale, Chart, Legend, LinearScale, Tooltip } from "chart.js";
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
Chart.register(ArcElement, BarElement, CategoryScale, LinearScale, Tooltip, Legend);
|
||||||
|
Chart.defaults.font.family = '"Segoe UI", Inter, system-ui, sans-serif';
|
||||||
|
Chart.defaults.font.size = 12;
|
||||||
|
});
|
||||||
|
|
@ -217,7 +217,12 @@ export function companyLabel(company: { code: string; name: string; kind?: strin
|
||||||
return company.code;
|
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(" ");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue