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

@@ -1,4 +1,4 @@
import { Router } from "express";
import { Router, type Request } from "express";
import { z } from "zod";
import { requireAdmin, requireAuth } from "../auth/middleware";
import {
@@ -9,13 +9,52 @@ import {
} from "../services/employees";
import { eventsInRange, summarize } from "../services/attendance";
import { parseRange } from "../util/dateRange";
import { sessionsToCsv } from "../util/csv";
import { payrollToCsv, sessionsToCsv } from "../util/csv";
import {
assignEmployeeToSlot,
createSlot,
listAdminSlotsForPeriod,
removeEmployeeFromSlot,
updateSlot,
} from "../services/shiftPlanning";
import { getCurrentRate, setRate } from "../services/payRates";
import {
getPeriodOverview,
lockAndFinalizePayroll,
markPaid,
reopenPayroll,
saveDraftAdjustments,
} from "../services/payroll";
export const adminRouter = Router();
adminRouter.use(requireAuth, requireAdmin);
adminRouter.get("/employees", (_req, res) => {
res.json({ employees: listEmployees() });
const employees = listEmployees().map((employee) => ({
...employee,
hourly_rate: getCurrentRate(employee.id),
}));
res.json({ employees });
});
const rateSchema = z.object({
hourly_rate: z.number().nonnegative(),
valid_from: z.string().optional(),
});
adminRouter.post("/employees/:id/rate", (req, res) => {
const id = Number(req.params.id);
const parsed = rateSchema.safeParse(req.body);
if (!Number.isInteger(id) || !parsed.success) {
res.status(400).json({ error: "Neplatná data." });
return;
}
try {
setRate(id, parsed.data.hourly_rate, parsed.data.valid_from);
res.status(201).json({ hourly_rate: getCurrentRate(id) });
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : "Uložení se nezdařilo" });
}
});
const addEmployeeSchema = z.object({
@@ -107,3 +146,171 @@ adminRouter.get("/stats/:id/export", (req, res) => {
res.setHeader("Content-Disposition", `attachment; filename="${safeName}_${period}.csv"`);
res.send("" + csv); // BOM so Excel opens the Czech diacritics as UTF-8
});
/* ---------- shift planning ---------- */
function periodParam(req: Request): string {
const period = req.query.period;
if (typeof period === "string" && /^\d{4}-\d{2}$/.test(period)) return period;
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
adminRouter.get("/shifts", (req, res) => {
res.json({ slots: listAdminSlotsForPeriod(periodParam(req)) });
});
const slotSchema = z.object({
date: z.string(),
start_time: z.string(),
end_time: z.string(),
capacity: z.number(),
note: z.string().optional(),
});
adminRouter.post("/shifts", (req, res) => {
const parsed = slotSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Neplatná data směny." });
return;
}
try {
const id = createSlot(parsed.data);
res.status(201).json({ id });
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : "Vytvoření se nezdařilo" });
}
});
adminRouter.patch("/shifts/:slotId", (req, res) => {
const slotId = Number(req.params.slotId);
const parsed = slotSchema.safeParse(req.body);
if (!Number.isInteger(slotId) || !parsed.success) {
res.status(400).json({ error: "Neplatná data směny." });
return;
}
try {
updateSlot(slotId, parsed.data);
res.json({ ok: true });
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : "Úprava se nezdařila" });
}
});
adminRouter.post("/shifts/:slotId/assign", (req, res) => {
const slotId = Number(req.params.slotId);
const employeeId = Number(req.body?.employeeId);
if (!Number.isInteger(slotId) || !Number.isInteger(employeeId)) {
res.status(400).json({ error: "Neplatný požadavek." });
return;
}
try {
assignEmployeeToSlot(slotId, employeeId);
res.status(201).json({ ok: true });
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : "Přiřazení se nezdařilo" });
}
});
adminRouter.delete("/shifts/:slotId/assign/:employeeId", (req, res) => {
const slotId = Number(req.params.slotId);
const employeeId = Number(req.params.employeeId);
if (!Number.isInteger(slotId) || !Number.isInteger(employeeId)) {
res.status(400).json({ error: "Neplatný požadavek." });
return;
}
try {
removeEmployeeFromSlot(slotId, employeeId, req.user!.email);
res.status(204).end();
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : "Odebrání se nezdařilo" });
}
});
/* ---------- payroll ---------- */
adminRouter.get("/payroll", (req, res) => {
res.json({ rows: getPeriodOverview(periodParam(req)) });
});
const adjustmentsSchema = z.object({
period: z.string().regex(/^\d{4}-\d{2}$/),
tips_amount: z.number(),
bonus_amount: z.number(),
other_amount: z.number(),
});
adminRouter.post("/payroll/:employeeId/adjustments", (req, res) => {
const employeeId = Number(req.params.employeeId);
const parsed = adjustmentsSchema.safeParse(req.body);
if (!Number.isInteger(employeeId) || !parsed.success) {
res.status(400).json({ error: "Neplatná data." });
return;
}
try {
const payroll = saveDraftAdjustments(employeeId, parsed.data.period, {
tips_amount: parsed.data.tips_amount,
bonus_amount: parsed.data.bonus_amount,
other_amount: parsed.data.other_amount,
});
res.json({ payroll });
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : "Uložení se nezdařilo" });
}
});
const periodBodySchema = z.object({ period: z.string().regex(/^\d{4}-\d{2}$/) });
adminRouter.post("/payroll/:employeeId/lock", (req, res) => {
const employeeId = Number(req.params.employeeId);
const parsed = periodBodySchema.safeParse(req.body);
if (!Number.isInteger(employeeId) || !parsed.success) {
res.status(400).json({ error: "Neplatná data." });
return;
}
try {
const payroll = lockAndFinalizePayroll(employeeId, parsed.data.period);
res.json({ payroll });
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : "Uzamčení se nezdařilo" });
}
});
adminRouter.post("/payroll/:employeeId/reopen", (req, res) => {
const employeeId = Number(req.params.employeeId);
const parsed = periodBodySchema.safeParse(req.body);
if (!Number.isInteger(employeeId) || !parsed.success) {
res.status(400).json({ error: "Neplatná data." });
return;
}
try {
const payroll = reopenPayroll(employeeId, parsed.data.period);
res.json({ payroll });
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : "Odemknutí se nezdařilo" });
}
});
adminRouter.post("/payroll/:employeeId/paid", (req, res) => {
const employeeId = Number(req.params.employeeId);
const parsed = periodBodySchema.safeParse(req.body);
if (!Number.isInteger(employeeId) || !parsed.success) {
res.status(400).json({ error: "Neplatná data." });
return;
}
try {
const payroll = markPaid(employeeId, parsed.data.period);
res.json({ payroll });
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : "Označení se nezdařilo" });
}
});
adminRouter.get("/payroll/export", (req, res) => {
const period = periodParam(req);
const csv = payrollToCsv(getPeriodOverview(period));
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="mzdy_${period}.csv"`);
res.send("" + csv);
});

View File

@@ -0,0 +1,37 @@
import { Router, type Request } from "express";
import { requireAuth, requireEmployee } from "../auth/middleware";
import { confirmClosure, getClosureSummary, getOrCreateClosure } from "../services/closure";
export const closureRouter = Router();
closureRouter.use(requireAuth, requireEmployee);
function periodParam(req: Request): string {
const period = req.query.period;
if (typeof period === "string" && /^\d{4}-\d{2}$/.test(period)) return period;
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
closureRouter.get("/", (req, res) => {
const employeeId = req.user!.employeeId!;
const period = periodParam(req);
res.json({
closure: getOrCreateClosure(employeeId, period),
summary: getClosureSummary(employeeId, period),
});
});
closureRouter.post("/confirm", (req, res) => {
const employeeId = req.user!.employeeId!;
const period = req.body?.period;
if (typeof period !== "string" || !/^\d{4}-\d{2}$/.test(period)) {
res.status(400).json({ error: "Neplatné období." });
return;
}
try {
confirmClosure(employeeId, period);
res.json({ ok: true });
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : "Potvrzení se nezdařilo" });
}
});

View File

@@ -0,0 +1,51 @@
import { Router, type Request } from "express";
import { requireAuth, requireEmployee } from "../auth/middleware";
import { cancelMySignup, getAvailableSlotsForEmployee, getMySlotsForEmployee, signupForSlot } from "../services/shiftPlanning";
export const shiftsRouter = Router();
shiftsRouter.use(requireAuth, requireEmployee);
function periodParam(req: Request): string {
const period = req.query.period;
if (typeof period === "string" && /^\d{4}-\d{2}$/.test(period)) return period;
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
shiftsRouter.get("/available", (req, res) => {
const employeeId = req.user!.employeeId!;
res.json({ slots: getAvailableSlotsForEmployee(employeeId, periodParam(req)) });
});
shiftsRouter.get("/mine", (req, res) => {
const employeeId = req.user!.employeeId!;
res.json({ slots: getMySlotsForEmployee(employeeId, periodParam(req)) });
});
shiftsRouter.post("/:slotId/signup", (req, res) => {
const slotId = Number(req.params.slotId);
if (!Number.isInteger(slotId)) {
res.status(400).json({ error: "Invalid slot id" });
return;
}
try {
signupForSlot(req.user!.employeeId!, slotId);
res.status(201).json({ ok: true });
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : "Přihlášení se nezdařilo" });
}
});
shiftsRouter.delete("/:slotId/signup", (req, res) => {
const slotId = Number(req.params.slotId);
if (!Number.isInteger(slotId)) {
res.status(400).json({ error: "Invalid slot id" });
return;
}
try {
cancelMySignup(req.user!.employeeId!, slotId, req.user!.email);
res.status(204).end();
} catch (err) {
res.status(400).json({ error: err instanceof Error ? err.message : "Zrušení se nezdařilo" });
}
});