Ports two modules from the friend's Google Apps Script build (kept as reference in gscript/) onto the TypeScript stack, rewritten cleanly against this app's own data model rather than copied 1:1: - Shift planning: weekly template (Sun-Thu evening, Fri/Sat two slots), lazy idempotent generation per period (no cron needed), employee signup/cancel with collision + capacity checks, admin calendar view with slot editing and manual assignment. - Month closure + payroll: employee confirms the month (blocked while a shift is still open), admin locks and finalizes pay (base hours * rate + tips/bonus/other), reopen to undo a premature lock, mark paid. Pay rates are versioned by date, defaulting the first-ever rate to apply retroactively to the employee's whole history. - A shift left open more than 12h (forgotten clock-out) is auto-closed at clock_in + 12h, checked lazily on read instead of a background job. - Full dark, sharp-edged modern restyle (theme.css replaces tui.css) with an amber accent, keeping every existing class name so no component logic needed to change. Backend test coverage (jest) for all three workflows: shift planning, closure/payroll, and the forgotten-clock-out auto-close.
189 lines
6.6 KiB
TypeScript
189 lines
6.6 KiB
TypeScript
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
|
|
);
|
|
}
|