Add shift planning, month closure + payroll, and a dark modern redesign

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.
This commit is contained in:
Michal Pemcak
2026-08-16 18:27:50 +02:00
parent fffcb73ea4
commit f917ed06a8
64 changed files with 46554 additions and 553 deletions

View File

@@ -0,0 +1,188 @@
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
);
}