import { db } from "../db"; import type { ClosureStatus, Payroll, PayrollStatus } from "../types"; import { findClosure, getClosureSummary, lockClosure } from "./closure"; import { listEmployees } from "./employees"; import { todayIso } from "../util/date"; export interface PayrollAdjustments { tips_amount: number; bonus_amount: number; other_amount: number; } export function findPayroll(employeeId: number, period: string): Payroll | undefined { return db .prepare<[number, string], Payroll>("SELECT * FROM payroll WHERE employee_id = ? AND period = ?") .get(employeeId, period); } /** Manager-side draft (tips/bonus/other) prepared before the month is locked. Frozen once locked. */ export function saveDraftAdjustments(employeeId: number, period: string, adjustments: PayrollAdjustments): Payroll { const closure = findClosure(employeeId, period); if (!closure || closure.status === "waiting_employee") { throw new Error("Odměny lze zadat až po potvrzení docházky zaměstnancem."); } const existing = findPayroll(employeeId, period); if (existing && existing.status !== "draft") { throw new Error("Mzdový podklad už byl uzamčen a nelze jej tímto způsobem měnit."); } for (const value of Object.values(adjustments)) { if (!Number.isFinite(value)) { throw new Error("Částka musí být číslo."); } } const summary = getClosureSummary(employeeId, period); const finalAmount = summary.earned_estimate + adjustments.tips_amount + adjustments.bonus_amount + adjustments.other_amount; upsertPayroll(employeeId, period, { worked_minutes: summary.worked_minutes, base_amount: summary.earned_estimate, tips_amount: adjustments.tips_amount, bonus_amount: adjustments.bonus_amount, other_amount: adjustments.other_amount, final_amount: finalAmount, status: "draft", }); return findPayroll(employeeId, period)!; } /** Locks the month closure and freezes the payroll numbers (whatever draft adjustments existed, or zero). */ export function lockAndFinalizePayroll(employeeId: number, period: string): Payroll { lockClosure(employeeId, period); const summary = getClosureSummary(employeeId, period); const draft = findPayroll(employeeId, period); const tips = draft?.tips_amount ?? 0; const bonus = draft?.bonus_amount ?? 0; const other = draft?.other_amount ?? 0; const finalAmount = summary.earned_estimate + tips + bonus + other; upsertPayroll(employeeId, period, { worked_minutes: summary.worked_minutes, base_amount: summary.earned_estimate, tips_amount: tips, bonus_amount: bonus, other_amount: other, final_amount: finalAmount, status: "ready", }); return findPayroll(employeeId, period)!; } /** Undoes a lock that turned out to be premature (e.g. the rate wasn't set yet) — back to an editable draft. */ export function reopenPayroll(employeeId: number, period: string): Payroll { const closure = findClosure(employeeId, period); const payroll = findPayroll(employeeId, period); if (!closure || closure.status !== "locked" || !payroll || payroll.status !== "ready") { throw new Error("Lze odemknout jen uzamčenou a dosud nevyplacenou mzdu."); } db.prepare("UPDATE month_closures SET status = 'confirmed', locked_at = NULL WHERE id = ?").run(closure.id); const summary = getClosureSummary(employeeId, period); const finalAmount = summary.earned_estimate + payroll.tips_amount + payroll.bonus_amount + payroll.other_amount; upsertPayroll(employeeId, period, { worked_minutes: summary.worked_minutes, base_amount: summary.earned_estimate, tips_amount: payroll.tips_amount, bonus_amount: payroll.bonus_amount, other_amount: payroll.other_amount, final_amount: finalAmount, status: "draft", }); return findPayroll(employeeId, period)!; } export function markPaid(employeeId: number, period: string): Payroll { const payroll = findPayroll(employeeId, period); if (!payroll || payroll.status !== "ready") { throw new Error("Mzda musí být nejdřív uzamčena."); } db.prepare( "UPDATE payroll SET status = 'paid', payment_date = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?" ).run(todayIso(), payroll.id); return findPayroll(employeeId, period)!; } export interface PayrollOverviewRow { employee_id: number; employee_name: string; closure_status: ClosureStatus; worked_minutes: number; base_amount: number; tips_amount: number; bonus_amount: number; other_amount: number; final_amount: number; payroll_status: PayrollStatus | null; payment_date: string | null; } export function getPeriodOverview(period: string): PayrollOverviewRow[] { return listEmployees() .filter((employee) => employee.active === 1) .map((employee) => { const closure = findClosure(employee.id, period); const payroll = findPayroll(employee.id, period); const summary = payroll ? null : getClosureSummary(employee.id, period); return { employee_id: employee.id, employee_name: employee.name ?? employee.email, closure_status: closure?.status ?? "waiting_employee", worked_minutes: payroll?.worked_minutes ?? summary!.worked_minutes, base_amount: payroll?.base_amount ?? summary!.earned_estimate, tips_amount: payroll?.tips_amount ?? 0, bonus_amount: payroll?.bonus_amount ?? 0, other_amount: payroll?.other_amount ?? 0, final_amount: payroll?.final_amount ?? summary!.earned_estimate, payroll_status: payroll?.status ?? null, payment_date: payroll?.payment_date ?? null, }; }); } function upsertPayroll( employeeId: number, period: string, data: { worked_minutes: number; base_amount: number; tips_amount: number; bonus_amount: number; other_amount: number; final_amount: number; status: PayrollStatus; } ): void { db.prepare( `INSERT INTO payroll (employee_id, period, worked_minutes, base_amount, tips_amount, bonus_amount, other_amount, final_amount, status, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) ON CONFLICT (employee_id, period) DO UPDATE SET worked_minutes = excluded.worked_minutes, base_amount = excluded.base_amount, tips_amount = excluded.tips_amount, bonus_amount = excluded.bonus_amount, other_amount = excluded.other_amount, final_amount = excluded.final_amount, status = excluded.status, updated_at = excluded.updated_at` ).run( employeeId, period, data.worked_minutes, data.base_amount, data.tips_amount, data.bonus_amount, data.other_amount, data.final_amount, data.status ); }