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:
6
backend/jest.config.js
Normal file
6
backend/jest.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
testMatch: ["<rootDir>/src/**/*.test.ts"],
|
||||
};
|
||||
@@ -8,7 +8,8 @@
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc --noEmit && node esbuild.config.js",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.9.1",
|
||||
@@ -25,9 +26,12 @@
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/node": "^22.10.2",
|
||||
"esbuild": "^0.28.2",
|
||||
"jest": "^30.4.2",
|
||||
"ts-jest": "^29.4.12",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
|
||||
@@ -27,4 +27,74 @@ db.exec(`
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attendance_employee_ts
|
||||
ON attendance_events (employee_id, ts);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shift_slots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
start_time TEXT NOT NULL,
|
||||
end_time TEXT NOT NULL,
|
||||
capacity INTEGER NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('open', 'full', 'closed')) DEFAULT 'open',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
generated INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_shift_slots_key
|
||||
ON shift_slots (date, start_time, end_time);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shift_signups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slot_id INTEGER NOT NULL REFERENCES shift_slots(id),
|
||||
employee_id INTEGER NOT NULL REFERENCES employees(id),
|
||||
status TEXT NOT NULL CHECK (status IN ('approved', 'cancelled')) DEFAULT 'approved',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
cancelled_at TEXT,
|
||||
cancelled_by TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_shift_signups_slot
|
||||
ON shift_signups (slot_id, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_shift_signups_employee
|
||||
ON shift_signups (employee_id, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pay_rates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
employee_id INTEGER NOT NULL REFERENCES employees(id),
|
||||
hourly_rate REAL NOT NULL,
|
||||
valid_from TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
UNIQUE (employee_id, valid_from)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_pay_rates_employee
|
||||
ON pay_rates (employee_id, valid_from);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS month_closures (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
employee_id INTEGER NOT NULL REFERENCES employees(id),
|
||||
period TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('waiting_employee', 'confirmed', 'locked')) DEFAULT 'waiting_employee',
|
||||
employee_confirmed_at TEXT,
|
||||
locked_at TEXT,
|
||||
UNIQUE (employee_id, period)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payroll (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
employee_id INTEGER NOT NULL REFERENCES employees(id),
|
||||
period TEXT NOT NULL,
|
||||
worked_minutes INTEGER NOT NULL DEFAULT 0,
|
||||
base_amount REAL NOT NULL DEFAULT 0,
|
||||
tips_amount REAL NOT NULL DEFAULT 0,
|
||||
bonus_amount REAL NOT NULL DEFAULT 0,
|
||||
other_amount REAL NOT NULL DEFAULT 0,
|
||||
final_amount REAL NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL CHECK (status IN ('draft', 'ready', 'paid')) DEFAULT 'draft',
|
||||
payment_date TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
UNIQUE (employee_id, period)
|
||||
);
|
||||
`);
|
||||
|
||||
@@ -8,6 +8,8 @@ import { env } from "./env";
|
||||
import { authRouter } from "./routes/auth";
|
||||
import { attendanceRouter } from "./routes/attendance";
|
||||
import { adminRouter } from "./routes/admin";
|
||||
import { shiftsRouter } from "./routes/shifts";
|
||||
import { closureRouter } from "./routes/closure";
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -19,6 +21,8 @@ app.get("/api/health", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
app.use("/api/auth", authRouter);
|
||||
app.use("/api/attendance", attendanceRouter);
|
||||
app.use("/api/shifts", shiftsRouter);
|
||||
app.use("/api/closure", closureRouter);
|
||||
app.use("/api/admin", adminRouter);
|
||||
|
||||
// In the production Docker image the built frontend is copied next to this
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
});
|
||||
74
backend/src/services/attendance.test.ts
Normal file
74
backend/src/services/attendance.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
// Env + a throwaway DB must be in place before ../db is first imported (it
|
||||
// creates the schema on import), so this runs ahead of the other imports.
|
||||
const dbDir = fs.mkdtempSync(path.join(os.tmpdir(), "eatme-attendance-test-"));
|
||||
process.env.DB_PATH = path.join(dbDir, "test.db");
|
||||
process.env.GOOGLE_CLIENT_ID = "test-client-id";
|
||||
process.env.ADMIN_EMAILS = "admin@example.com";
|
||||
process.env.JWT_SECRET = "test-secret";
|
||||
|
||||
import { db } from "../db";
|
||||
import { eventsInRange, getLiveStatus, summarize } from "./attendance";
|
||||
|
||||
function makeEmployee(email: string): number {
|
||||
const result = db.prepare("INSERT INTO employees (email, name) VALUES (?, ?)").run(email, email);
|
||||
return Number(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
function insertEvent(employeeId: number, type: string, ts: string): void {
|
||||
db.prepare("INSERT INTO attendance_events (employee_id, type, ts) VALUES (?, ?, ?)").run(employeeId, type, ts);
|
||||
}
|
||||
|
||||
describe("forgotten clock-out is capped automatically at 12h", () => {
|
||||
test("a shift open more than 12h is auto-closed at clock_in + 12h", () => {
|
||||
const alice = makeEmployee("alice@example.com");
|
||||
const clockIn = new Date(Date.now() - 20 * 60 * 60 * 1000).toISOString(); // 20h ago
|
||||
insertEvent(alice, "clock_in", clockIn);
|
||||
|
||||
expect(getLiveStatus(alice)).toBe("clocked_out");
|
||||
|
||||
const events = db
|
||||
.prepare("SELECT * FROM attendance_events WHERE employee_id = ? ORDER BY ts ASC")
|
||||
.all(alice) as { type: string; ts: string }[];
|
||||
expect(events.map((e) => e.type)).toEqual(["clock_in", "clock_out"]);
|
||||
expect(new Date(events[1].ts).getTime() - new Date(clockIn).getTime()).toBe(12 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
test("a shift left open on a break is also closed cleanly (break_end inserted first)", () => {
|
||||
const bob = makeEmployee("bob@example.com");
|
||||
const clockIn = new Date(Date.now() - 15 * 60 * 60 * 1000).toISOString();
|
||||
const breakStart = new Date(Date.now() - 14 * 60 * 60 * 1000).toISOString();
|
||||
insertEvent(bob, "clock_in", clockIn);
|
||||
insertEvent(bob, "break_start", breakStart);
|
||||
|
||||
expect(getLiveStatus(bob)).toBe("clocked_out");
|
||||
|
||||
const events = db
|
||||
.prepare("SELECT * FROM attendance_events WHERE employee_id = ? ORDER BY ts ASC")
|
||||
.all(bob) as { type: string }[];
|
||||
expect(events.map((e) => e.type)).toEqual(["clock_in", "break_start", "break_end", "clock_out"]);
|
||||
});
|
||||
|
||||
test("a shift within 12h is left untouched", () => {
|
||||
const carol = makeEmployee("carol@example.com");
|
||||
const clockIn = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
|
||||
insertEvent(carol, "clock_in", clockIn);
|
||||
|
||||
expect(getLiveStatus(carol)).toBe("working");
|
||||
});
|
||||
|
||||
test("closure/stats reads (eventsInRange) also self-heal a stale shift", () => {
|
||||
const dave = makeEmployee("dave@example.com");
|
||||
const clockIn = new Date(Date.now() - 30 * 60 * 60 * 1000).toISOString(); // 30h ago
|
||||
insertEvent(dave, "clock_in", clockIn);
|
||||
|
||||
const events = eventsInRange(dave, "2000-01-01T00:00:00.000Z", "2100-01-01T00:00:00.000Z");
|
||||
const { sessions } = summarize(events, new Date());
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].open).toBe(false);
|
||||
expect(sessions[0].workedMs).toBe(12 * 60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,8 @@ function statusAfter(lastType: EventType | null): LiveStatus {
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_SHIFT_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
export function getLastEvent(employeeId: number): AttendanceEvent | undefined {
|
||||
return db
|
||||
.prepare<[number], AttendanceEvent>(
|
||||
@@ -31,10 +33,42 @@ export function getLastEvent(employeeId: number): AttendanceEvent | undefined {
|
||||
}
|
||||
|
||||
export function getLiveStatus(employeeId: number): LiveStatus {
|
||||
autoCloseStaleShift(employeeId);
|
||||
const last = getLastEvent(employeeId);
|
||||
return statusAfter(last?.type ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* A shift nobody clocked out of (forgotten, phone died, whatever) is capped at 12h — closed
|
||||
* at clock_in + 12h rather than growing forever. Runs lazily on every read instead of a
|
||||
* background job, so it self-heals without needing a scheduler.
|
||||
*/
|
||||
function autoCloseStaleShift(employeeId: number): void {
|
||||
const last = getLastEvent(employeeId);
|
||||
const status = statusAfter(last?.type ?? null);
|
||||
if (status === "clocked_out") return;
|
||||
|
||||
const clockIn = db
|
||||
.prepare<[number], AttendanceEvent>(
|
||||
"SELECT * FROM attendance_events WHERE employee_id = ? AND type = 'clock_in' ORDER BY ts DESC, id DESC LIMIT 1"
|
||||
)
|
||||
.get(employeeId)!;
|
||||
|
||||
if (Date.now() - new Date(clockIn.ts).getTime() <= MAX_SHIFT_MS) return;
|
||||
|
||||
const cutoff = new Date(new Date(clockIn.ts).getTime() + MAX_SHIFT_MS).toISOString();
|
||||
if (status === "on_break") {
|
||||
db.prepare("INSERT INTO attendance_events (employee_id, type, ts) VALUES (?, 'break_end', ?)").run(
|
||||
employeeId,
|
||||
cutoff
|
||||
);
|
||||
}
|
||||
db.prepare("INSERT INTO attendance_events (employee_id, type, ts) VALUES (?, 'clock_out', ?)").run(
|
||||
employeeId,
|
||||
cutoff
|
||||
);
|
||||
}
|
||||
|
||||
export class InvalidTransitionError extends Error {
|
||||
constructor(public readonly current: LiveStatus, public readonly attempted: EventType) {
|
||||
super(`Cannot record "${attempted}" while status is "${current}"`);
|
||||
@@ -55,6 +89,7 @@ export function recordEvent(employeeId: number, type: EventType): AttendanceEven
|
||||
}
|
||||
|
||||
export function eventsInRange(employeeId: number, fromIso: string, toIso: string): AttendanceEvent[] {
|
||||
autoCloseStaleShift(employeeId);
|
||||
return db
|
||||
.prepare<[number, string, string], AttendanceEvent>(
|
||||
`SELECT * FROM attendance_events
|
||||
|
||||
88
backend/src/services/closure.ts
Normal file
88
backend/src/services/closure.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { db } from "../db";
|
||||
import type { MonthClosure } from "../types";
|
||||
import { eventsInRange, summarize } from "./attendance";
|
||||
import { getRateAt } from "./payRates";
|
||||
import { parseRange } from "../util/dateRange";
|
||||
|
||||
export interface ClosureSummary {
|
||||
worked_minutes: number;
|
||||
shift_count: number;
|
||||
earned_estimate: number;
|
||||
}
|
||||
|
||||
export function getClosureSummary(employeeId: number, period: string): ClosureSummary {
|
||||
const { fromIso, toIso } = parseRange({ month: period });
|
||||
const events = eventsInRange(employeeId, fromIso, toIso);
|
||||
const { sessions } = summarize(events, new Date());
|
||||
|
||||
let workedMs = 0;
|
||||
let earned = 0;
|
||||
let shiftCount = 0;
|
||||
|
||||
for (const session of sessions) {
|
||||
workedMs += session.workedMs;
|
||||
if (session.workedMs > 0) shiftCount++;
|
||||
const rate = getRateAt(employeeId, session.clockIn.slice(0, 10));
|
||||
earned += (session.workedMs / 3_600_000) * rate;
|
||||
}
|
||||
|
||||
return {
|
||||
worked_minutes: Math.round(workedMs / 60_000),
|
||||
shift_count: shiftCount,
|
||||
earned_estimate: Math.round(earned),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasOpenSessionInPeriod(employeeId: number, period: string): boolean {
|
||||
const { fromIso, toIso } = parseRange({ month: period });
|
||||
const events = eventsInRange(employeeId, fromIso, toIso);
|
||||
const { sessions } = summarize(events, new Date());
|
||||
return sessions.some((s) => s.open);
|
||||
}
|
||||
|
||||
export function findClosure(employeeId: number, period: string): MonthClosure | undefined {
|
||||
return db
|
||||
.prepare<[number, string], MonthClosure>("SELECT * FROM month_closures WHERE employee_id = ? AND period = ?")
|
||||
.get(employeeId, period);
|
||||
}
|
||||
|
||||
export function getOrCreateClosure(employeeId: number, period: string): MonthClosure {
|
||||
const existing = findClosure(employeeId, period);
|
||||
if (existing) return existing;
|
||||
db.prepare("INSERT INTO month_closures (employee_id, period, status) VALUES (?, ?, 'waiting_employee')").run(
|
||||
employeeId,
|
||||
period
|
||||
);
|
||||
return findClosure(employeeId, period)!;
|
||||
}
|
||||
|
||||
export function confirmClosure(employeeId: number, period: string): void {
|
||||
const closure = getOrCreateClosure(employeeId, period);
|
||||
if (closure.status === "locked") {
|
||||
throw new Error("Docházka je už uzamčena.");
|
||||
}
|
||||
if (closure.status === "confirmed") {
|
||||
return;
|
||||
}
|
||||
if (hasOpenSessionInPeriod(employeeId, period)) {
|
||||
throw new Error("V tomto měsíci máš stále otevřenou směnu.");
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
"UPDATE month_closures SET status = 'confirmed', employee_confirmed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
|
||||
).run(closure.id);
|
||||
}
|
||||
|
||||
export function lockClosure(employeeId: number, period: string): void {
|
||||
const closure = findClosure(employeeId, period);
|
||||
if (!closure || closure.status !== "confirmed") {
|
||||
throw new Error("Docházku zatím nepotvrdil zaměstnanec.");
|
||||
}
|
||||
if (hasOpenSessionInPeriod(employeeId, period)) {
|
||||
throw new Error("Zaměstnanec má stále otevřenou směnu.");
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
"UPDATE month_closures SET status = 'locked', locked_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
|
||||
).run(closure.id);
|
||||
}
|
||||
52
backend/src/services/payRates.ts
Normal file
52
backend/src/services/payRates.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { db } from "../db";
|
||||
import type { PayRate } from "../types";
|
||||
import { todayIso } from "../util/date";
|
||||
|
||||
/** The rate is the most recent one with valid_from <= the given date — a new rate implicitly ends the previous one. */
|
||||
export function getRateAt(employeeId: number, dateOnly: string): number {
|
||||
const row = db
|
||||
.prepare<[number, string], { hourly_rate: number }>(
|
||||
"SELECT hourly_rate FROM pay_rates WHERE employee_id = ? AND valid_from <= ? ORDER BY valid_from DESC LIMIT 1"
|
||||
)
|
||||
.get(employeeId, dateOnly);
|
||||
return row?.hourly_rate ?? 0;
|
||||
}
|
||||
|
||||
export function getCurrentRate(employeeId: number): number {
|
||||
return getRateAt(employeeId, todayIso());
|
||||
}
|
||||
|
||||
export function setRate(employeeId: number, hourlyRate: number, validFrom?: string): PayRate {
|
||||
if (!Number.isFinite(hourlyRate) || hourlyRate < 0) {
|
||||
throw new Error("Sazba musí být nezáporné číslo.");
|
||||
}
|
||||
|
||||
const from = validFrom && /^\d{4}-\d{2}-\d{2}$/.test(validFrom) ? validFrom : defaultValidFrom(employeeId);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO pay_rates (employee_id, hourly_rate, valid_from) VALUES (?, ?, ?)
|
||||
ON CONFLICT (employee_id, valid_from) DO UPDATE SET hourly_rate = excluded.hourly_rate`
|
||||
).run(employeeId, hourlyRate, from);
|
||||
|
||||
return db
|
||||
.prepare<[number, string], PayRate>("SELECT * FROM pay_rates WHERE employee_id = ? AND valid_from = ?")
|
||||
.get(employeeId, from)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first rate ever set for an employee applies retroactively to their whole history
|
||||
* (back to when they were added), so hours worked before the admin got around to setting
|
||||
* a rate still get paid out. Later raises only take effect from today.
|
||||
*/
|
||||
function defaultValidFrom(employeeId: number): string {
|
||||
const hasExisting = db
|
||||
.prepare<[number], { n: number }>("SELECT COUNT(*) as n FROM pay_rates WHERE employee_id = ?")
|
||||
.get(employeeId)!.n;
|
||||
if (hasExisting > 0) {
|
||||
return todayIso();
|
||||
}
|
||||
const employee = db
|
||||
.prepare<[number], { created_at: string }>("SELECT created_at FROM employees WHERE id = ?")
|
||||
.get(employeeId);
|
||||
return employee ? employee.created_at.slice(0, 10) : todayIso();
|
||||
}
|
||||
157
backend/src/services/payroll.test.ts
Normal file
157
backend/src/services/payroll.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
// Env + a throwaway DB must be in place before ../db is first imported (it
|
||||
// creates the schema on import), so this runs ahead of the other imports.
|
||||
const dbDir = fs.mkdtempSync(path.join(os.tmpdir(), "eatme-payroll-test-"));
|
||||
process.env.DB_PATH = path.join(dbDir, "test.db");
|
||||
process.env.GOOGLE_CLIENT_ID = "test-client-id";
|
||||
process.env.ADMIN_EMAILS = "admin@example.com";
|
||||
process.env.JWT_SECRET = "test-secret";
|
||||
|
||||
import { db } from "../db";
|
||||
import { setRate } from "./payRates";
|
||||
import { confirmClosure, findClosure, getClosureSummary } from "./closure";
|
||||
import {
|
||||
findPayroll,
|
||||
getPeriodOverview,
|
||||
lockAndFinalizePayroll,
|
||||
markPaid,
|
||||
reopenPayroll,
|
||||
saveDraftAdjustments,
|
||||
} from "./payroll";
|
||||
|
||||
const PERIOD = "2026-06";
|
||||
|
||||
function makeEmployee(email: string): number {
|
||||
const result = db.prepare("INSERT INTO employees (email, name) VALUES (?, ?)").run(email, email);
|
||||
return Number(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
/** created_at needs to predate the shifts in these tests, to simulate "employee existed before the rate was ever set". */
|
||||
function makeEmployeeWithCreatedAt(email: string, createdAt: string): number {
|
||||
const result = db
|
||||
.prepare("INSERT INTO employees (email, name, created_at) VALUES (?, ?, ?)")
|
||||
.run(email, email, createdAt);
|
||||
return Number(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
function insertEvent(employeeId: number, type: "clock_in" | "clock_out", ts: string): void {
|
||||
db.prepare("INSERT INTO attendance_events (employee_id, type, ts) VALUES (?, ?, ?)").run(employeeId, type, ts);
|
||||
}
|
||||
|
||||
describe("closure + payroll workflow", () => {
|
||||
test("summary is computed from worked sessions using the rate active on the shift's date", () => {
|
||||
const eve = makeEmployee("eve@example.com");
|
||||
setRate(eve, 200, "2026-01-01");
|
||||
|
||||
insertEvent(eve, "clock_in", "2026-06-05T08:00:00.000Z");
|
||||
insertEvent(eve, "clock_out", "2026-06-05T12:00:00.000Z"); // 4h shift
|
||||
|
||||
const summary = getClosureSummary(eve, PERIOD);
|
||||
expect(summary.worked_minutes).toBe(240);
|
||||
expect(summary.shift_count).toBe(1);
|
||||
expect(summary.earned_estimate).toBe(800); // 4h * 200 Kč
|
||||
});
|
||||
|
||||
test("employee cannot confirm the month while a shift is still open, and confirming is idempotent", () => {
|
||||
// Uses the real current month/time (not the fixed PERIOD) because a genuinely "still open"
|
||||
// shift only makes sense within the last 12h — anything older gets auto-closed (see
|
||||
// attendance.test.ts), which would make this scenario impossible to set up otherwise.
|
||||
const currentPeriod = new Date().toISOString().slice(0, 7);
|
||||
const frank = makeEmployee("frank@example.com");
|
||||
setRate(frank, 150, "2000-01-01");
|
||||
insertEvent(frank, "clock_in", new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString()); // still open
|
||||
|
||||
expect(() => confirmClosure(frank, currentPeriod)).toThrow("otevřenou směnu");
|
||||
|
||||
insertEvent(frank, "clock_out", new Date().toISOString());
|
||||
confirmClosure(frank, currentPeriod);
|
||||
expect(findClosure(frank, currentPeriod)!.status).toBe("confirmed");
|
||||
|
||||
confirmClosure(frank, currentPeriod); // already confirmed -> no-op, not an error
|
||||
expect(findClosure(frank, currentPeriod)!.status).toBe("confirmed");
|
||||
});
|
||||
|
||||
test("admin can only adjust tips/bonus/other after employee confirmation, and locking freezes the numbers", () => {
|
||||
const gina = makeEmployee("gina@example.com");
|
||||
setRate(gina, 200, "2026-01-01");
|
||||
insertEvent(gina, "clock_in", "2026-06-01T08:00:00.000Z");
|
||||
insertEvent(gina, "clock_out", "2026-06-01T16:00:00.000Z"); // 8h -> base 1600
|
||||
|
||||
expect(() =>
|
||||
saveDraftAdjustments(gina, PERIOD, { tips_amount: 100, bonus_amount: 0, other_amount: 0 })
|
||||
).toThrow("po potvrzení docházky");
|
||||
|
||||
confirmClosure(gina, PERIOD);
|
||||
|
||||
const draft = saveDraftAdjustments(gina, PERIOD, { tips_amount: 300, bonus_amount: 200, other_amount: -50 });
|
||||
expect(draft.status).toBe("draft");
|
||||
expect(draft.final_amount).toBe(1600 + 300 + 200 - 50);
|
||||
|
||||
const locked = lockAndFinalizePayroll(gina, PERIOD);
|
||||
expect(locked.status).toBe("ready");
|
||||
expect(locked.final_amount).toBe(draft.final_amount);
|
||||
expect(findClosure(gina, PERIOD)!.status).toBe("locked");
|
||||
|
||||
expect(() =>
|
||||
saveDraftAdjustments(gina, PERIOD, { tips_amount: 999, bonus_amount: 0, other_amount: 0 })
|
||||
).toThrow("uzamčen");
|
||||
expect(() => lockAndFinalizePayroll(gina, PERIOD)).toThrow("nepotvrdil zaměstnanec");
|
||||
|
||||
const paid = markPaid(gina, PERIOD);
|
||||
expect(paid.status).toBe("paid");
|
||||
expect(paid.payment_date).toBeTruthy();
|
||||
|
||||
expect(() => markPaid(gina, PERIOD)).toThrow("nejdřív uzamčena");
|
||||
});
|
||||
|
||||
test("the first rate set for an employee applies retroactively; later raises only apply from today", () => {
|
||||
const iris = makeEmployeeWithCreatedAt("iris@example.com", "2026-05-01T00:00:00.000Z");
|
||||
insertEvent(iris, "clock_in", "2026-06-03T08:00:00.000Z");
|
||||
insertEvent(iris, "clock_out", "2026-06-03T12:00:00.000Z"); // 4h shift, before any rate exists
|
||||
|
||||
expect(getClosureSummary(iris, PERIOD).earned_estimate).toBe(0);
|
||||
|
||||
setRate(iris, 200); // no valid_from given -> must cover the already-worked June shift
|
||||
expect(getClosureSummary(iris, PERIOD).earned_estimate).toBe(800); // 4h * 200
|
||||
|
||||
setRate(iris, 300); // a raise -> only from today, not retroactive
|
||||
expect(getClosureSummary(iris, PERIOD).earned_estimate).toBe(800); // June shift unaffected
|
||||
});
|
||||
|
||||
test("reopen undoes a premature lock so the numbers can be corrected, but not once already paid", () => {
|
||||
const jack = makeEmployeeWithCreatedAt("jack@example.com", "2026-05-01T00:00:00.000Z");
|
||||
insertEvent(jack, "clock_in", "2026-06-04T08:00:00.000Z");
|
||||
insertEvent(jack, "clock_out", "2026-06-04T12:00:00.000Z"); // 4h shift
|
||||
|
||||
confirmClosure(jack, PERIOD);
|
||||
const locked = lockAndFinalizePayroll(jack, PERIOD); // locked before any rate was set
|
||||
expect(locked.base_amount).toBe(0);
|
||||
|
||||
reopenPayroll(jack, PERIOD);
|
||||
expect(findClosure(jack, PERIOD)!.status).toBe("confirmed");
|
||||
expect(findPayroll(jack, PERIOD)!.status).toBe("draft");
|
||||
|
||||
setRate(jack, 150);
|
||||
const relocked = lockAndFinalizePayroll(jack, PERIOD);
|
||||
expect(relocked.base_amount).toBe(600); // 4h * 150, now correct
|
||||
|
||||
markPaid(jack, PERIOD);
|
||||
expect(() => reopenPayroll(jack, PERIOD)).toThrow("Lze odemknout");
|
||||
});
|
||||
|
||||
test("admin overview shows live status for employees who haven't touched their closure yet", () => {
|
||||
const henry = makeEmployee("henry@example.com");
|
||||
setRate(henry, 100, "2026-01-01");
|
||||
insertEvent(henry, "clock_in", "2026-06-02T08:00:00.000Z");
|
||||
insertEvent(henry, "clock_out", "2026-06-02T10:00:00.000Z"); // 2h -> base 200
|
||||
|
||||
const row = getPeriodOverview(PERIOD).find((r) => r.employee_id === henry)!;
|
||||
expect(row.closure_status).toBe("waiting_employee");
|
||||
expect(row.payroll_status).toBeNull();
|
||||
expect(row.worked_minutes).toBe(120);
|
||||
expect(row.base_amount).toBe(200);
|
||||
});
|
||||
});
|
||||
188
backend/src/services/payroll.ts
Normal file
188
backend/src/services/payroll.ts
Normal 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
|
||||
);
|
||||
}
|
||||
121
backend/src/services/shiftPlanning.test.ts
Normal file
121
backend/src/services/shiftPlanning.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
// Env + a throwaway DB must be in place before ../db is first imported (it
|
||||
// creates the schema on import), so this runs ahead of the other imports.
|
||||
const dbDir = fs.mkdtempSync(path.join(os.tmpdir(), "eatme-shift-test-"));
|
||||
process.env.DB_PATH = path.join(dbDir, "test.db");
|
||||
process.env.GOOGLE_CLIENT_ID = "test-client-id";
|
||||
process.env.ADMIN_EMAILS = "admin@example.com";
|
||||
process.env.JWT_SECRET = "test-secret";
|
||||
|
||||
import { db } from "../db";
|
||||
import {
|
||||
assignEmployeeToSlot,
|
||||
cancelMySignup,
|
||||
createSlot,
|
||||
ensureSlotsForPeriod,
|
||||
getAvailableSlotsForEmployee,
|
||||
getMySlotsForEmployee,
|
||||
listAdminSlotsForPeriod,
|
||||
removeEmployeeFromSlot,
|
||||
signupForSlot,
|
||||
updateSlot,
|
||||
} from "./shiftPlanning";
|
||||
|
||||
const PERIOD = "2026-09";
|
||||
|
||||
function makeEmployee(email: string): number {
|
||||
const result = db.prepare("INSERT INTO employees (email, name) VALUES (?, ?)").run(email, email);
|
||||
return Number(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
/** Sun-Thu = 1 generated slot/day, Fri-Sat = 2 (mirrors the template in shiftPlanning.ts). */
|
||||
function expectedTemplateCount(period: string): number {
|
||||
const [year, month] = period.split("-").map(Number);
|
||||
const days = new Date(year, month, 0).getDate();
|
||||
let count = 0;
|
||||
for (let day = 1; day <= days; day++) {
|
||||
const dow = new Date(year, month - 1, day).getDay();
|
||||
count += dow === 5 || dow === 6 ? 2 : 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
describe("shift planning", () => {
|
||||
test("generates the weekly template idempotently", () => {
|
||||
ensureSlotsForPeriod(PERIOD);
|
||||
const first = listAdminSlotsForPeriod(PERIOD);
|
||||
expect(first.length).toBe(expectedTemplateCount(PERIOD));
|
||||
|
||||
ensureSlotsForPeriod(PERIOD);
|
||||
const second = listAdminSlotsForPeriod(PERIOD);
|
||||
expect(second.length).toBe(first.length);
|
||||
});
|
||||
|
||||
test("employee sign-up fills capacity; duplicates and oversubscription are rejected; cancelling frees the slot", () => {
|
||||
const alice = makeEmployee("alice@example.com");
|
||||
const bob = makeEmployee("bob@example.com");
|
||||
|
||||
const single = listAdminSlotsForPeriod(PERIOD).find((s) => s.capacity === 1)!;
|
||||
|
||||
signupForSlot(alice, single.id);
|
||||
expect(getMySlotsForEmployee(alice, PERIOD).some((s) => s.slot_id === single.id)).toBe(true);
|
||||
expect(getAvailableSlotsForEmployee(alice, PERIOD).some((s) => s.slot_id === single.id)).toBe(false);
|
||||
|
||||
expect(() => signupForSlot(alice, single.id)).toThrow("Na této směně už jsi přihlášen.");
|
||||
expect(() => signupForSlot(bob, single.id)).toThrow("Směna je už obsazená.");
|
||||
|
||||
expect(listAdminSlotsForPeriod(PERIOD).find((s) => s.id === single.id)!.status).toBe("full");
|
||||
|
||||
cancelMySignup(alice, single.id, "alice@example.com");
|
||||
|
||||
const afterCancel = listAdminSlotsForPeriod(PERIOD).find((s) => s.id === single.id)!;
|
||||
expect(afterCancel.status).toBe("open");
|
||||
expect(getAvailableSlotsForEmployee(alice, PERIOD).some((s) => s.slot_id === single.id)).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects signing up for two shifts that overlap in time", () => {
|
||||
const carol = makeEmployee("carol@example.com");
|
||||
|
||||
// The Fri/Sat template deliberately overlaps: 18:00-02:00 and 20:00-00:00.
|
||||
const byDate = new Map<string, ReturnType<typeof listAdminSlotsForPeriod>>();
|
||||
for (const slot of listAdminSlotsForPeriod(PERIOD)) {
|
||||
byDate.set(slot.date, [...(byDate.get(slot.date) ?? []), slot]);
|
||||
}
|
||||
const [firstSlot, secondSlot] = [...byDate.values()].find((group) => group.length === 2)!;
|
||||
|
||||
signupForSlot(carol, firstSlot.id);
|
||||
expect(() => signupForSlot(carol, secondSlot.id)).toThrow(/překrývá/);
|
||||
});
|
||||
|
||||
test("admin can create a custom slot, assign/remove employees, and capacity can't drop below occupancy", () => {
|
||||
const dave = makeEmployee("dave@example.com");
|
||||
|
||||
const slotId = createSlot({
|
||||
date: "2026-09-01",
|
||||
start_time: "10:00",
|
||||
end_time: "14:00",
|
||||
capacity: 2,
|
||||
note: "brunch",
|
||||
});
|
||||
expect(() =>
|
||||
createSlot({ date: "2026-09-01", start_time: "10:00", end_time: "14:00", capacity: 2 })
|
||||
).toThrow("Stejná směna už existuje.");
|
||||
|
||||
assignEmployeeToSlot(slotId, dave);
|
||||
let slot = listAdminSlotsForPeriod(PERIOD).find((s) => s.id === slotId)!;
|
||||
expect(slot.employees.map((e) => e.employee_id)).toContain(dave);
|
||||
expect(slot.occupied).toBe(1);
|
||||
|
||||
expect(() =>
|
||||
updateSlot(slotId, { date: slot.date, start_time: slot.start_time, end_time: slot.end_time, capacity: 0 })
|
||||
).toThrow("Kapacitu nelze snížit pod počet již přihlášených zaměstnanců.");
|
||||
|
||||
removeEmployeeFromSlot(slotId, dave, "admin@example.com");
|
||||
slot = listAdminSlotsForPeriod(PERIOD).find((s) => s.id === slotId)!;
|
||||
expect(slot.occupied).toBe(0);
|
||||
expect(slot.status).toBe("open");
|
||||
});
|
||||
});
|
||||
362
backend/src/services/shiftPlanning.ts
Normal file
362
backend/src/services/shiftPlanning.ts
Normal file
@@ -0,0 +1,362 @@
|
||||
import { db } from "../db";
|
||||
import type { ShiftSlot, ShiftSlotStatus } from "../types";
|
||||
import { formatDateOnly } from "../util/date";
|
||||
|
||||
/**
|
||||
* Default weekly shift template. Slots for a whole calendar month are
|
||||
* generated lazily (idempotently, via a UNIQUE index on date+start+end) the
|
||||
* first time anyone asks for that period, rather than on a cron schedule —
|
||||
* there's no scheduler in this app and this is simpler and just as reliable.
|
||||
*/
|
||||
function templatesForDow(dow: number): { start: string; end: string; capacity: number }[] {
|
||||
if (dow === 5 || dow === 6) {
|
||||
// Friday / Saturday
|
||||
return [
|
||||
{ start: "18:00", end: "02:00", capacity: 1 },
|
||||
{ start: "20:00", end: "00:00", capacity: 1 },
|
||||
];
|
||||
}
|
||||
// Sunday - Thursday
|
||||
return [{ start: "18:00", end: "23:00", capacity: 1 }];
|
||||
}
|
||||
|
||||
export function ensureSlotsForPeriod(period: string): void {
|
||||
const [year, monthIndex] = parsePeriod(period);
|
||||
const days = new Date(year, monthIndex + 1, 0).getDate();
|
||||
|
||||
const insert = db.prepare(
|
||||
`INSERT OR IGNORE INTO shift_slots (date, start_time, end_time, capacity, status, generated)
|
||||
VALUES (?, ?, ?, ?, 'open', 1)`
|
||||
);
|
||||
|
||||
const generate = db.transaction(() => {
|
||||
for (let day = 1; day <= days; day++) {
|
||||
const date = new Date(year, monthIndex, day);
|
||||
const dateText = formatDateOnly(date);
|
||||
for (const template of templatesForDow(date.getDay())) {
|
||||
insert.run(dateText, template.start, template.end, template.capacity);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
generate();
|
||||
}
|
||||
|
||||
export interface AdminShiftSlotView {
|
||||
id: number;
|
||||
date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
capacity: number;
|
||||
status: ShiftSlotStatus;
|
||||
note: string;
|
||||
generated: boolean;
|
||||
occupied: number;
|
||||
employees: { employee_id: number; name: string }[];
|
||||
}
|
||||
|
||||
export function listAdminSlotsForPeriod(period: string): AdminShiftSlotView[] {
|
||||
ensureSlotsForPeriod(period);
|
||||
|
||||
const slots = db
|
||||
.prepare<[string], ShiftSlot>("SELECT * FROM shift_slots WHERE date LIKE ? ORDER BY date, start_time")
|
||||
.all(`${period}-%`);
|
||||
|
||||
const signupsForSlot = db.prepare<[number], { employee_id: number; email: string; name: string | null }>(
|
||||
`SELECT ss.employee_id, e.email, e.name
|
||||
FROM shift_signups ss
|
||||
JOIN employees e ON e.id = ss.employee_id
|
||||
WHERE ss.slot_id = ? AND ss.status = 'approved'`
|
||||
);
|
||||
|
||||
return slots.map((slot) => {
|
||||
const employees = signupsForSlot
|
||||
.all(slot.id)
|
||||
.map((row) => ({ employee_id: row.employee_id, name: row.name ?? row.email }));
|
||||
|
||||
return {
|
||||
id: slot.id,
|
||||
date: slot.date,
|
||||
start_time: slot.start_time,
|
||||
end_time: slot.end_time,
|
||||
capacity: slot.capacity,
|
||||
status: slot.status,
|
||||
note: slot.note,
|
||||
generated: slot.generated === 1,
|
||||
occupied: employees.length,
|
||||
employees,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface SlotInput {
|
||||
date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
capacity: number;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export function createSlot(data: SlotInput): number {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(data.date)) {
|
||||
throw new Error("Neplatné datum.");
|
||||
}
|
||||
const startTime = normalizeTime(data.start_time);
|
||||
const endTime = normalizeTime(data.end_time);
|
||||
if (!Number.isInteger(data.capacity) || data.capacity < 1) {
|
||||
throw new Error("Kapacita musí být alespoň 1.");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = db
|
||||
.prepare(
|
||||
`INSERT INTO shift_slots (date, start_time, end_time, capacity, status, note, generated)
|
||||
VALUES (?, ?, ?, ?, 'open', ?, 0)`
|
||||
)
|
||||
.run(data.date, startTime, endTime, data.capacity, data.note ?? "");
|
||||
return Number(result.lastInsertRowid);
|
||||
} catch (err) {
|
||||
if (String(err).includes("UNIQUE")) {
|
||||
throw new Error("Stejná směna už existuje.");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateSlot(slotId: number, data: SlotInput): void {
|
||||
const slot = getSlot(slotId);
|
||||
if (!slot) {
|
||||
throw new Error("Směna nebyla nalezena.");
|
||||
}
|
||||
if (!Number.isInteger(data.capacity) || data.capacity < 0) {
|
||||
throw new Error("Kapacita musí být celé číslo 0 nebo vyšší.");
|
||||
}
|
||||
|
||||
const occupied = countApproved(slotId);
|
||||
if (data.capacity < occupied) {
|
||||
throw new Error("Kapacitu nelze snížit pod počet již přihlášených zaměstnanců.");
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE shift_slots
|
||||
SET date = ?, start_time = ?, end_time = ?, capacity = ?, status = ?, note = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
data.date || slot.date,
|
||||
normalizeTime(data.start_time),
|
||||
normalizeTime(data.end_time),
|
||||
data.capacity,
|
||||
computeStatus(data.capacity, occupied),
|
||||
data.note ?? "",
|
||||
slotId
|
||||
);
|
||||
}
|
||||
|
||||
export function assignEmployeeToSlot(slotId: number, employeeId: number): void {
|
||||
signupInternal(slotId, employeeId);
|
||||
}
|
||||
|
||||
export function removeEmployeeFromSlot(slotId: number, employeeId: number, actorEmail: string): void {
|
||||
cancelInternal(slotId, employeeId, actorEmail);
|
||||
}
|
||||
|
||||
export interface AvailableSlotView {
|
||||
slot_id: number;
|
||||
date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
capacity: number;
|
||||
occupied: number;
|
||||
free_places: number;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export function getAvailableSlotsForEmployee(employeeId: number, period: string): AvailableSlotView[] {
|
||||
ensureSlotsForPeriod(period);
|
||||
|
||||
return db
|
||||
.prepare<[string], ShiftSlot>(
|
||||
"SELECT * FROM shift_slots WHERE date LIKE ? AND status = 'open' ORDER BY date, start_time"
|
||||
)
|
||||
.all(`${period}-%`)
|
||||
.filter((slot) => !hasApprovedSignup(slot.id, employeeId))
|
||||
.map((slot) => {
|
||||
const occupied = countApproved(slot.id);
|
||||
return {
|
||||
slot_id: slot.id,
|
||||
date: slot.date,
|
||||
start_time: slot.start_time,
|
||||
end_time: slot.end_time,
|
||||
capacity: slot.capacity,
|
||||
occupied,
|
||||
free_places: Math.max(0, slot.capacity - occupied),
|
||||
note: slot.note,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface MySlotView {
|
||||
signup_id: number;
|
||||
slot_id: number;
|
||||
date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export function getMySlotsForEmployee(employeeId: number, period: string): MySlotView[] {
|
||||
ensureSlotsForPeriod(period);
|
||||
|
||||
return db
|
||||
.prepare<[number, string], MySlotView>(
|
||||
`SELECT ss.id as signup_id, sl.id as slot_id, sl.date, sl.start_time, sl.end_time, sl.note
|
||||
FROM shift_signups ss
|
||||
JOIN shift_slots sl ON sl.id = ss.slot_id
|
||||
WHERE ss.employee_id = ? AND ss.status = 'approved' AND sl.date LIKE ?
|
||||
ORDER BY sl.date, sl.start_time`
|
||||
)
|
||||
.all(employeeId, `${period}-%`);
|
||||
}
|
||||
|
||||
export function signupForSlot(employeeId: number, slotId: number): void {
|
||||
signupInternal(slotId, employeeId);
|
||||
}
|
||||
|
||||
export function cancelMySignup(employeeId: number, slotId: number, actorEmail: string): void {
|
||||
cancelInternal(slotId, employeeId, actorEmail);
|
||||
}
|
||||
|
||||
/* ---------- internals ---------- */
|
||||
|
||||
function signupInternal(slotId: number, employeeId: number): void {
|
||||
const slot = getSlot(slotId);
|
||||
if (!slot) {
|
||||
throw new Error("Směna nebyla nalezena.");
|
||||
}
|
||||
if (slot.status === "closed") {
|
||||
throw new Error("Směna je uzavřená.");
|
||||
}
|
||||
if (hasApprovedSignup(slotId, employeeId)) {
|
||||
throw new Error("Na této směně už jsi přihlášen.");
|
||||
}
|
||||
|
||||
assertNoCollision(employeeId, slot);
|
||||
|
||||
const occupied = countApproved(slotId);
|
||||
if (occupied >= slot.capacity) {
|
||||
throw new Error("Směna je už obsazená.");
|
||||
}
|
||||
|
||||
db.prepare("INSERT INTO shift_signups (slot_id, employee_id, status) VALUES (?, ?, 'approved')").run(
|
||||
slotId,
|
||||
employeeId
|
||||
);
|
||||
|
||||
refreshSlotStatus(slotId);
|
||||
}
|
||||
|
||||
function cancelInternal(slotId: number, employeeId: number, actorEmail: string): void {
|
||||
const signup = db
|
||||
.prepare<[number, number], { id: number }>(
|
||||
"SELECT id FROM shift_signups WHERE slot_id = ? AND employee_id = ? AND status = 'approved'"
|
||||
)
|
||||
.get(slotId, employeeId);
|
||||
|
||||
if (!signup) {
|
||||
throw new Error("Přihlášení na směnu nebylo nalezeno.");
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE shift_signups
|
||||
SET status = 'cancelled', cancelled_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), cancelled_by = ?
|
||||
WHERE id = ?`
|
||||
).run(actorEmail, signup.id);
|
||||
|
||||
refreshSlotStatus(slotId);
|
||||
}
|
||||
|
||||
function assertNoCollision(employeeId: number, targetSlot: ShiftSlot): void {
|
||||
const mySlots = db
|
||||
.prepare<[number], ShiftSlot>(
|
||||
`SELECT sl.* FROM shift_slots sl
|
||||
JOIN shift_signups ss ON ss.slot_id = sl.id
|
||||
WHERE ss.employee_id = ? AND ss.status = 'approved'`
|
||||
)
|
||||
.all(employeeId);
|
||||
|
||||
const targetStart = slotStart(targetSlot);
|
||||
const targetEnd = slotEnd(targetSlot);
|
||||
|
||||
for (const slot of mySlots) {
|
||||
if (targetStart < slotEnd(slot) && targetEnd > slotStart(slot)) {
|
||||
throw new Error("Tato směna se překrývá s jinou směnou, na kterou už jsi přihlášen.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSlotStatus(slotId: number): void {
|
||||
const slot = getSlot(slotId);
|
||||
if (!slot) return;
|
||||
const occupied = countApproved(slotId);
|
||||
db.prepare(
|
||||
`UPDATE shift_slots SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`
|
||||
).run(computeStatus(slot.capacity, occupied), slotId);
|
||||
}
|
||||
|
||||
function computeStatus(capacity: number, occupied: number): ShiftSlotStatus {
|
||||
if (capacity <= 0) return "closed";
|
||||
return occupied >= capacity ? "full" : "open";
|
||||
}
|
||||
|
||||
function countApproved(slotId: number): number {
|
||||
const row = db
|
||||
.prepare<[number], { n: number }>("SELECT COUNT(*) as n FROM shift_signups WHERE slot_id = ? AND status = 'approved'")
|
||||
.get(slotId)!;
|
||||
return row.n;
|
||||
}
|
||||
|
||||
function hasApprovedSignup(slotId: number, employeeId: number): boolean {
|
||||
return !!db
|
||||
.prepare("SELECT 1 FROM shift_signups WHERE slot_id = ? AND employee_id = ? AND status = 'approved'")
|
||||
.get(slotId, employeeId);
|
||||
}
|
||||
|
||||
function getSlot(id: number): ShiftSlot | undefined {
|
||||
return db.prepare<[number], ShiftSlot>("SELECT * FROM shift_slots WHERE id = ?").get(id);
|
||||
}
|
||||
|
||||
function slotStart(slot: Pick<ShiftSlot, "date" | "start_time">): Date {
|
||||
const [year, month, day] = slot.date.split("-").map(Number);
|
||||
const [hour, minute] = slot.start_time.split(":").map(Number);
|
||||
return new Date(year, month - 1, day, hour, minute);
|
||||
}
|
||||
|
||||
/** End time rolls over to the next day whenever it's not after the start time (e.g. 18:00-02:00). */
|
||||
function slotEnd(slot: Pick<ShiftSlot, "date" | "start_time" | "end_time">): Date {
|
||||
const start = slotStart(slot);
|
||||
const [hour, minute] = slot.end_time.split(":").map(Number);
|
||||
const end = new Date(start.getFullYear(), start.getMonth(), start.getDate(), hour, minute);
|
||||
if (end <= start) end.setDate(end.getDate() + 1);
|
||||
return end;
|
||||
}
|
||||
|
||||
function parsePeriod(period: string): [number, number] {
|
||||
if (!/^\d{4}-\d{2}$/.test(period)) {
|
||||
throw new Error("Neplatné období.");
|
||||
}
|
||||
return [Number(period.slice(0, 4)), Number(period.slice(5, 7)) - 1];
|
||||
}
|
||||
|
||||
function normalizeTime(value: string): string {
|
||||
const match = /^(\d{1,2}):(\d{2})$/.exec(String(value ?? "").trim());
|
||||
if (!match) {
|
||||
throw new Error("Neplatný čas.");
|
||||
}
|
||||
const hour = Number(match[1]);
|
||||
const minute = Number(match[2]);
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||
throw new Error("Neplatný čas.");
|
||||
}
|
||||
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||
}
|
||||
@@ -15,6 +15,69 @@ export interface AttendanceEvent {
|
||||
ts: string;
|
||||
}
|
||||
|
||||
export type ShiftSlotStatus = "open" | "full" | "closed";
|
||||
|
||||
export interface ShiftSlot {
|
||||
id: number;
|
||||
date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
capacity: number;
|
||||
status: ShiftSlotStatus;
|
||||
note: string;
|
||||
generated: 0 | 1;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type ShiftSignupStatus = "approved" | "cancelled";
|
||||
|
||||
export interface ShiftSignup {
|
||||
id: number;
|
||||
slot_id: number;
|
||||
employee_id: number;
|
||||
status: ShiftSignupStatus;
|
||||
created_at: string;
|
||||
cancelled_at: string | null;
|
||||
cancelled_by: string | null;
|
||||
}
|
||||
|
||||
export interface PayRate {
|
||||
id: number;
|
||||
employee_id: number;
|
||||
hourly_rate: number;
|
||||
valid_from: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type ClosureStatus = "waiting_employee" | "confirmed" | "locked";
|
||||
|
||||
export interface MonthClosure {
|
||||
id: number;
|
||||
employee_id: number;
|
||||
period: string;
|
||||
status: ClosureStatus;
|
||||
employee_confirmed_at: string | null;
|
||||
locked_at: string | null;
|
||||
}
|
||||
|
||||
export type PayrollStatus = "draft" | "ready" | "paid";
|
||||
|
||||
export interface Payroll {
|
||||
id: number;
|
||||
employee_id: number;
|
||||
period: string;
|
||||
worked_minutes: number;
|
||||
base_amount: number;
|
||||
tips_amount: number;
|
||||
bonus_amount: number;
|
||||
other_amount: number;
|
||||
final_amount: number;
|
||||
status: PayrollStatus;
|
||||
payment_date: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type Role = "admin" | "employee";
|
||||
|
||||
export interface SessionUser {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Session } from "../services/attendance";
|
||||
import type { PayrollOverviewRow } from "../services/payroll";
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("cs-CZ");
|
||||
@@ -33,3 +34,38 @@ export function sessionsToCsv(sessions: Session[]): string {
|
||||
|
||||
return [header, ...rows].map((row) => row.map(escapeCsvField).join(",")).join("\r\n");
|
||||
}
|
||||
|
||||
function payrollStatusLabel(row: PayrollOverviewRow): string {
|
||||
if (row.payroll_status === "paid") return "vyplaceno";
|
||||
if (row.payroll_status === "ready") return "připraveno k výplatě";
|
||||
if (row.closure_status === "confirmed") return "potvrzeno zaměstnancem";
|
||||
if (row.closure_status === "locked") return "uzamčeno";
|
||||
return "čeká na zaměstnance";
|
||||
}
|
||||
|
||||
export function payrollToCsv(rows: PayrollOverviewRow[]): string {
|
||||
const header = [
|
||||
"Zaměstnanec",
|
||||
"Odpracováno (h:mm)",
|
||||
"Základ",
|
||||
"Spropitné",
|
||||
"Bonusy",
|
||||
"Ostatní",
|
||||
"Celkem",
|
||||
"Stav",
|
||||
"Datum výplaty",
|
||||
];
|
||||
const body = rows.map((r) => [
|
||||
r.employee_name,
|
||||
formatDuration(r.worked_minutes * 60_000),
|
||||
r.base_amount.toFixed(2),
|
||||
r.tips_amount.toFixed(2),
|
||||
r.bonus_amount.toFixed(2),
|
||||
r.other_amount.toFixed(2),
|
||||
r.final_amount.toFixed(2),
|
||||
payrollStatusLabel(r),
|
||||
r.payment_date ?? "",
|
||||
]);
|
||||
|
||||
return [header, ...body].map((row) => row.map(escapeCsvField).join(",")).join("\r\n");
|
||||
}
|
||||
|
||||
7
backend/src/util/date.ts
Normal file
7
backend/src/util/date.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function formatDateOnly(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function todayIso(): string {
|
||||
return formatDateOnly(new Date());
|
||||
}
|
||||
Reference in New Issue
Block a user