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:
@@ -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);
|
||||
});
|
||||
|
||||
37
backend/src/routes/closure.ts
Normal file
37
backend/src/routes/closure.ts
Normal 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" });
|
||||
}
|
||||
});
|
||||
51
backend/src/routes/shifts.ts
Normal file
51
backend/src/routes/shifts.ts
Normal 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" });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user