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

6
backend/jest.config.js Normal file
View File

@@ -0,0 +1,6 @@
/** @type {import('jest').Config} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
testMatch: ["<rootDir>/src/**/*.test.ts"],
};

View File

@@ -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"
}

View File

@@ -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)
);
`);

View File

@@ -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

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" });
}
});

View 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);
});
});

View File

@@ -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

View 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);
}

View 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();
}

View 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);
});
});

View File

@@ -0,0 +1,188 @@
import { db } from "../db";
import type { ClosureStatus, Payroll, PayrollStatus } from "../types";
import { findClosure, getClosureSummary, lockClosure } from "./closure";
import { listEmployees } from "./employees";
import { todayIso } from "../util/date";
export interface PayrollAdjustments {
tips_amount: number;
bonus_amount: number;
other_amount: number;
}
export function findPayroll(employeeId: number, period: string): Payroll | undefined {
return db
.prepare<[number, string], Payroll>("SELECT * FROM payroll WHERE employee_id = ? AND period = ?")
.get(employeeId, period);
}
/** Manager-side draft (tips/bonus/other) prepared before the month is locked. Frozen once locked. */
export function saveDraftAdjustments(employeeId: number, period: string, adjustments: PayrollAdjustments): Payroll {
const closure = findClosure(employeeId, period);
if (!closure || closure.status === "waiting_employee") {
throw new Error("Odměny lze zadat až po potvrzení docházky zaměstnancem.");
}
const existing = findPayroll(employeeId, period);
if (existing && existing.status !== "draft") {
throw new Error("Mzdový podklad už byl uzamčen a nelze jej tímto způsobem měnit.");
}
for (const value of Object.values(adjustments)) {
if (!Number.isFinite(value)) {
throw new Error("Částka musí být číslo.");
}
}
const summary = getClosureSummary(employeeId, period);
const finalAmount =
summary.earned_estimate + adjustments.tips_amount + adjustments.bonus_amount + adjustments.other_amount;
upsertPayroll(employeeId, period, {
worked_minutes: summary.worked_minutes,
base_amount: summary.earned_estimate,
tips_amount: adjustments.tips_amount,
bonus_amount: adjustments.bonus_amount,
other_amount: adjustments.other_amount,
final_amount: finalAmount,
status: "draft",
});
return findPayroll(employeeId, period)!;
}
/** Locks the month closure and freezes the payroll numbers (whatever draft adjustments existed, or zero). */
export function lockAndFinalizePayroll(employeeId: number, period: string): Payroll {
lockClosure(employeeId, period);
const summary = getClosureSummary(employeeId, period);
const draft = findPayroll(employeeId, period);
const tips = draft?.tips_amount ?? 0;
const bonus = draft?.bonus_amount ?? 0;
const other = draft?.other_amount ?? 0;
const finalAmount = summary.earned_estimate + tips + bonus + other;
upsertPayroll(employeeId, period, {
worked_minutes: summary.worked_minutes,
base_amount: summary.earned_estimate,
tips_amount: tips,
bonus_amount: bonus,
other_amount: other,
final_amount: finalAmount,
status: "ready",
});
return findPayroll(employeeId, period)!;
}
/** Undoes a lock that turned out to be premature (e.g. the rate wasn't set yet) — back to an editable draft. */
export function reopenPayroll(employeeId: number, period: string): Payroll {
const closure = findClosure(employeeId, period);
const payroll = findPayroll(employeeId, period);
if (!closure || closure.status !== "locked" || !payroll || payroll.status !== "ready") {
throw new Error("Lze odemknout jen uzamčenou a dosud nevyplacenou mzdu.");
}
db.prepare("UPDATE month_closures SET status = 'confirmed', locked_at = NULL WHERE id = ?").run(closure.id);
const summary = getClosureSummary(employeeId, period);
const finalAmount = summary.earned_estimate + payroll.tips_amount + payroll.bonus_amount + payroll.other_amount;
upsertPayroll(employeeId, period, {
worked_minutes: summary.worked_minutes,
base_amount: summary.earned_estimate,
tips_amount: payroll.tips_amount,
bonus_amount: payroll.bonus_amount,
other_amount: payroll.other_amount,
final_amount: finalAmount,
status: "draft",
});
return findPayroll(employeeId, period)!;
}
export function markPaid(employeeId: number, period: string): Payroll {
const payroll = findPayroll(employeeId, period);
if (!payroll || payroll.status !== "ready") {
throw new Error("Mzda musí být nejdřív uzamčena.");
}
db.prepare(
"UPDATE payroll SET status = 'paid', payment_date = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
).run(todayIso(), payroll.id);
return findPayroll(employeeId, period)!;
}
export interface PayrollOverviewRow {
employee_id: number;
employee_name: string;
closure_status: ClosureStatus;
worked_minutes: number;
base_amount: number;
tips_amount: number;
bonus_amount: number;
other_amount: number;
final_amount: number;
payroll_status: PayrollStatus | null;
payment_date: string | null;
}
export function getPeriodOverview(period: string): PayrollOverviewRow[] {
return listEmployees()
.filter((employee) => employee.active === 1)
.map((employee) => {
const closure = findClosure(employee.id, period);
const payroll = findPayroll(employee.id, period);
const summary = payroll ? null : getClosureSummary(employee.id, period);
return {
employee_id: employee.id,
employee_name: employee.name ?? employee.email,
closure_status: closure?.status ?? "waiting_employee",
worked_minutes: payroll?.worked_minutes ?? summary!.worked_minutes,
base_amount: payroll?.base_amount ?? summary!.earned_estimate,
tips_amount: payroll?.tips_amount ?? 0,
bonus_amount: payroll?.bonus_amount ?? 0,
other_amount: payroll?.other_amount ?? 0,
final_amount: payroll?.final_amount ?? summary!.earned_estimate,
payroll_status: payroll?.status ?? null,
payment_date: payroll?.payment_date ?? null,
};
});
}
function upsertPayroll(
employeeId: number,
period: string,
data: {
worked_minutes: number;
base_amount: number;
tips_amount: number;
bonus_amount: number;
other_amount: number;
final_amount: number;
status: PayrollStatus;
}
): void {
db.prepare(
`INSERT INTO payroll (employee_id, period, worked_minutes, base_amount, tips_amount, bonus_amount, other_amount, final_amount, status, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT (employee_id, period) DO UPDATE SET
worked_minutes = excluded.worked_minutes,
base_amount = excluded.base_amount,
tips_amount = excluded.tips_amount,
bonus_amount = excluded.bonus_amount,
other_amount = excluded.other_amount,
final_amount = excluded.final_amount,
status = excluded.status,
updated_at = excluded.updated_at`
).run(
employeeId,
period,
data.worked_minutes,
data.base_amount,
data.tips_amount,
data.bonus_amount,
data.other_amount,
data.final_amount,
data.status
);
}

View 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");
});
});

View 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")}`;
}

View File

@@ -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 {

View File

@@ -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
View 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());
}

View File

@@ -5,7 +5,7 @@
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#000000" />
<meta name="theme-color" content="#0d0f13" />
<meta name="description" content="Evidence docházky zaměstnanců bistra EatMe" />
<title>EatMe — Docházka</title>
<script src="https://accounts.google.com/gsi/client" async defer></script>

View File

@@ -34,5 +34,7 @@ export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, data?: unknown) =>
request<T>(path, { method: 'POST', body: data ? JSON.stringify(data) : undefined }),
patch: <T>(path: string, data?: unknown) =>
request<T>(path, { method: 'PATCH', body: data ? JSON.stringify(data) : undefined }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
};

View File

@@ -53,3 +53,90 @@ export interface AdminStatsSummary {
totals: { totalWorkedMs: number; totalBreakMs: number; shiftCount: number };
employees: EmployeeStat[];
}
export type ShiftSlotStatus = 'open' | 'full' | 'closed';
export interface AvailableShiftSlot {
slot_id: number;
date: string;
start_time: string;
end_time: string;
capacity: number;
occupied: number;
free_places: number;
note: string;
}
export interface MyShiftSlot {
signup_id: number;
slot_id: number;
date: string;
start_time: string;
end_time: string;
note: string;
}
export interface AdminShiftSlot {
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 interface EmployeeWithRate extends Employee {
hourly_rate: number;
}
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 interface ClosureSummary {
worked_minutes: number;
shift_count: number;
earned_estimate: number;
}
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 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;
}

View File

@@ -0,0 +1,66 @@
import { useEffect } from 'react';
import useClosureStore from '../store/closureStore';
import { MonthNav } from './MonthNav';
import { formatDuration } from '../lib/format';
const STATUS_LABEL: Record<string, string> = {
waiting_employee: 'čeká na tvé potvrzení',
confirmed: 'potvrzeno, čeká na zpracování',
locked: 'uzamčeno',
};
export function ClosureCard() {
const period = useClosureStore((s) => s.period);
const closure = useClosureStore((s) => s.closure);
const summary = useClosureStore((s) => s.summary);
const busy = useClosureStore((s) => s.busy);
const error = useClosureStore((s) => s.error);
const load = useClosureStore((s) => s.load);
const prevPeriod = useClosureStore((s) => s.prevPeriod);
const nextPeriod = useClosureStore((s) => s.nextPeriod);
const confirm = useClosureStore((s) => s.confirm);
useEffect(() => {
load();
}, [load]);
return (
<div className="panel">
<p className="panel-title">Uzávěrka měsíce</p>
<MonthNav month={period} onPrev={prevPeriod} onNext={nextPeriod} />
{error && <div className="login-error">{error}</div>}
{!closure || !summary ? (
<div className="empty-line">&gt; načítám</div>
) : (
<>
<div className="stat-grid" style={{ marginTop: '1rem' }}>
<div className="stat-tile">
<div className="stat-value">{formatDuration(summary.worked_minutes * 60_000)}</div>
<div className="stat-label">odpracováno</div>
</div>
<div className="stat-tile">
<div className="stat-value">{summary.shift_count}</div>
<div className="stat-label">směn</div>
</div>
<div className="stat-tile">
<div className="stat-value">{summary.earned_estimate} </div>
<div className="stat-label">odhad výdělku</div>
</div>
</div>
<div style={{ marginTop: '1rem', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<span className={`badge ${closure.status === 'waiting_employee' ? 'badge-dashed' : 'badge-solid'}`}>
{STATUS_LABEL[closure.status]}
</span>
{closure.status === 'waiting_employee' && (
<button className="btn" disabled={busy} onClick={confirm}>
Potvrdit docházku
</button>
)}
</div>
</>
)}
</div>
);
}

View File

@@ -9,6 +9,7 @@ export function EmployeeManager() {
const loadEmployees = useAdminStore((s) => s.loadEmployees);
const addEmployee = useAdminStore((s) => s.addEmployee);
const removeEmployee = useAdminStore((s) => s.removeEmployee);
const setRate = useAdminStore((s) => s.setRate);
const [email, setEmail] = useState('');
@@ -46,16 +47,57 @@ export function EmployeeManager() {
{employees === null && <div className="empty-line">&gt; načítám</div>}
{employees?.length === 0 && <div className="empty-line">· zatím žádní zaměstnanci</div>}
{employees?.map((emp) => (
<div className="row" key={emp.id}>
<EmployeeRow key={emp.id} employee={emp} busy={busy} onRemove={removeEmployee} onSetRate={setRate} />
))}
</div>
</div>
);
}
function EmployeeRow({
employee,
busy,
onRemove,
onSetRate,
}: {
employee: { id: number; email: string; name: string | null; active: 0 | 1; created_at: string; hourly_rate: number };
busy: boolean;
onRemove: (id: number) => void;
onSetRate: (id: number, hourlyRate: number) => void;
}) {
const [rate, setRate] = useState(String(employee.hourly_rate));
const dirty = Number(rate) !== employee.hourly_rate;
return (
<div className="row">
<div className="row-main">
<div className="row-title">{emp.name ?? emp.email}</div>
<div className="row-title">{employee.name ?? employee.email}</div>
<div className="row-meta">
{emp.email} &middot; od {formatDate(emp.created_at)}
{employee.email} &middot; od {formatDate(employee.created_at)}
</div>
</div>
<div className="row-action">
{emp.active ? (
<button className="btn" disabled={busy} onClick={() => removeEmployee(emp.id)}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<span className="row-meta" style={{ margin: 0 }}>
/h
</span>
<input
type="number"
min={0}
value={rate}
onChange={(e) => setRate(e.target.value)}
disabled={busy}
aria-label="Hodinová sazba v Kč"
style={{ width: '4rem' }}
/>
</label>
{dirty && (
<button className="btn" disabled={busy} onClick={() => onSetRate(employee.id, Number(rate))}>
Uložit sazbu
</button>
)}
{employee.active ? (
<button className="btn" disabled={busy} onClick={() => onRemove(employee.id)}>
Odebrat
</button>
) : (
@@ -65,8 +107,5 @@ export function EmployeeManager() {
)}
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import type { ReactNode } from 'react';
const WEEKDAY_LABELS = ['Po', 'Út', 'St', 'Čt', 'Pá', 'So', 'Ne'];
interface Props {
month: string; // "YYYY-MM"
renderDay: (dateStr: string, dayNumber: number) => ReactNode;
}
/** JS Date#getDay() is 0=Sun..6=Sat; the Czech week starts Monday. */
function mondayFirst(jsDay: number): number {
return (jsDay + 6) % 7;
}
export function MonthCalendar({ month, renderDay }: Props) {
const [yearStr, monthStr] = month.split('-');
const year = Number(yearStr);
const monthIndex = Number(monthStr) - 1;
const daysInMonth = new Date(year, monthIndex + 1, 0).getDate();
const leadingBlanks = mondayFirst(new Date(year, monthIndex, 1).getDay());
const totalCells = Math.ceil((leadingBlanks + daysInMonth) / 7) * 7;
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(
today.getDate()
).padStart(2, '0')}`;
const cells = Array.from({ length: totalCells }, (_, i) => {
const dayNumber = i - leadingBlanks + 1;
if (dayNumber < 1 || dayNumber > daysInMonth) return null;
return `${year}-${String(monthIndex + 1).padStart(2, '0')}-${String(dayNumber).padStart(2, '0')}`;
});
return (
<div className="month-calendar-scroll">
<div className="month-calendar">
<div className="month-calendar-head">
{WEEKDAY_LABELS.map((label) => (
<div key={label}>{label}</div>
))}
</div>
<div className="month-calendar-body">
{cells.map((dateStr, i) => (
<div
key={i}
className={`month-calendar-cell ${dateStr ? '' : 'is-outside'} ${dateStr === todayStr ? 'is-today' : ''}`}
>
{dateStr && (
<>
<div className="month-calendar-daynum">{Number(dateStr.slice(8, 10))}</div>
{renderDay(dateStr, Number(dateStr.slice(8, 10)))}
</>
)}
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,169 @@
import { useEffect, useState } from 'react';
import useAdminPayrollStore, { type Adjustments } from '../store/adminPayrollStore';
import type { PayrollOverviewRow } from '../api/types';
import { MonthNav } from './MonthNav';
import { formatDuration } from '../lib/format';
const CLOSURE_LABEL: Record<string, string> = {
waiting_employee: 'čeká na zaměstnance',
confirmed: 'potvrzeno',
locked: 'uzamčeno',
};
const PAYROLL_LABEL: Record<string, string> = {
draft: 'rozpracováno',
ready: 'připraveno k výplatě',
paid: 'vyplaceno',
};
export function PayrollManager() {
const period = useAdminPayrollStore((s) => s.period);
const rows = useAdminPayrollStore((s) => s.rows);
const busy = useAdminPayrollStore((s) => s.busy);
const error = useAdminPayrollStore((s) => s.error);
const load = useAdminPayrollStore((s) => s.load);
const prevPeriod = useAdminPayrollStore((s) => s.prevPeriod);
const nextPeriod = useAdminPayrollStore((s) => s.nextPeriod);
const saveAdjustments = useAdminPayrollStore((s) => s.saveAdjustments);
const lock = useAdminPayrollStore((s) => s.lock);
const reopen = useAdminPayrollStore((s) => s.reopen);
const markPaid = useAdminPayrollStore((s) => s.markPaid);
useEffect(() => {
load();
}, [load]);
return (
<div className="panel">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<p className="panel-title">Mzdy</p>
<a className="btn" href={`/api/admin/payroll/export?period=${period}`} download>
Export CSV
</a>
</div>
<MonthNav month={period} onPrev={prevPeriod} onNext={nextPeriod} />
{error && <div className="login-error">{error}</div>}
<div className="list" style={{ marginTop: '1rem' }}>
{rows === null && <div className="empty-line">&gt; načítám</div>}
{rows?.length === 0 && <div className="empty-line">· žádní zaměstnanci</div>}
{rows?.map((row) => (
<PayrollRow
key={row.employee_id}
row={row}
busy={busy}
onSave={(data) => saveAdjustments(row.employee_id, data)}
onLock={() => lock(row.employee_id)}
onReopen={() => reopen(row.employee_id)}
onMarkPaid={() => markPaid(row.employee_id)}
/>
))}
</div>
</div>
);
}
function PayrollRow({
row,
busy,
onSave,
onLock,
onReopen,
onMarkPaid,
}: {
row: PayrollOverviewRow;
busy: boolean;
onSave: (data: Adjustments) => void;
onLock: () => void;
onReopen: () => void;
onMarkPaid: () => void;
}) {
const [tips, setTips] = useState(String(row.tips_amount));
const [bonus, setBonus] = useState(String(row.bonus_amount));
const [other, setOther] = useState(String(row.other_amount));
const editable = row.closure_status !== 'waiting_employee' && row.payroll_status !== 'ready' && row.payroll_status !== 'paid';
const dirty = Number(tips) !== row.tips_amount || Number(bonus) !== row.bonus_amount || Number(other) !== row.other_amount;
return (
<div className="row" style={{ flexWrap: 'wrap' }}>
<div className="row-main">
<div className="row-title">
{row.employee_name} <span className="badge badge-dashed">{CLOSURE_LABEL[row.closure_status]}</span>
{row.payroll_status && <span className="badge badge-solid">{PAYROLL_LABEL[row.payroll_status]}</span>}
</div>
<div className="row-meta">
{formatDuration(row.worked_minutes * 60_000)} &middot; základ {row.base_amount} &middot; celkem{' '}
{row.final_amount}
</div>
{editable && (
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.4rem', flexWrap: 'wrap', alignItems: 'center' }}>
<AmountField label="spropitné" value={tips} onChange={setTips} busy={busy} />
<AmountField label="bonus" value={bonus} onChange={setBonus} busy={busy} />
<AmountField label="ostatní" value={other} onChange={setOther} busy={busy} />
{dirty && (
<button
className="btn"
disabled={busy}
onClick={() => onSave({ tips_amount: Number(tips), bonus_amount: Number(bonus), other_amount: Number(other) })}
>
Uložit
</button>
)}
{row.closure_status === 'confirmed' && (
<button className="btn" disabled={busy} onClick={onLock}>
Zamknout a předat k výplatě
</button>
)}
</div>
)}
{row.payroll_status === 'ready' && (
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.4rem' }}>
<button className="btn" disabled={busy} onClick={onMarkPaid}>
Označit jako vyplaceno
</button>
<button className="btn-ghost" disabled={busy} onClick={onReopen}>
Odemknout a upravit
</button>
</div>
)}
{row.payroll_status === 'paid' && (
<div className="row-meta" style={{ marginTop: '0.2rem' }}>
vyplaceno {row.payment_date}
</div>
)}
</div>
</div>
);
}
function AmountField({
label,
value,
onChange,
busy,
}: {
label: string;
value: string;
onChange: (value: string) => void;
busy: boolean;
}) {
return (
<label style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<span className="row-meta" style={{ margin: 0 }}>
{label}
</span>
<input
type="number"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={busy}
aria-label={label}
style={{ width: '5rem' }}
/>
</label>
);
}

View File

@@ -0,0 +1,274 @@
import { useEffect, useMemo, useState } from 'react';
import useAdminShiftStore from '../store/adminShiftStore';
import useAdminStore from '../store/adminStore';
import type { AdminShiftSlot } from '../api/types';
import { MonthNav } from './MonthNav';
import { MonthCalendar } from './MonthCalendar';
import { formatDateOnly } from '../lib/format';
export function ShiftPlanManager() {
const period = useAdminShiftStore((s) => s.period);
const slots = useAdminShiftStore((s) => s.slots);
const busy = useAdminShiftStore((s) => s.busy);
const error = useAdminShiftStore((s) => s.error);
const load = useAdminShiftStore((s) => s.load);
const prevPeriod = useAdminShiftStore((s) => s.prevPeriod);
const nextPeriod = useAdminShiftStore((s) => s.nextPeriod);
const createSlot = useAdminShiftStore((s) => s.createSlot);
const updateSlot = useAdminShiftStore((s) => s.updateSlot);
const assignEmployee = useAdminShiftStore((s) => s.assignEmployee);
const removeEmployee = useAdminShiftStore((s) => s.removeEmployee);
const employees = useAdminStore((s) => s.employees);
const loadEmployees = useAdminStore((s) => s.loadEmployees);
const [selectedDate, setSelectedDate] = useState<string | null>(null);
useEffect(() => {
load();
if (!employees) loadEmployees();
}, [load, loadEmployees, employees]);
const slotsByDate = useMemo(() => {
const map = new Map<string, AdminShiftSlot[]>();
slots?.forEach((slot) => {
const list = map.get(slot.date) ?? [];
list.push(slot);
map.set(slot.date, list);
});
return map;
}, [slots]);
function renderDay(dateStr: string) {
const daySlots = slotsByDate.get(dateStr) ?? [];
return (
<button
className={`month-calendar-day-btn ${selectedDate === dateStr ? 'is-selected' : ''}`}
onClick={() => setSelectedDate(dateStr)}
>
{daySlots.map((slot) => (
<span key={slot.id} className={`calendar-chip ${slot.status}`}>
{slot.start_time}{slot.end_time} {slot.occupied}/{slot.capacity}
</span>
))}
</button>
);
}
const daySlots = selectedDate ? slotsByDate.get(selectedDate) ?? [] : [];
return (
<div className="panel">
<p className="panel-title">Plán směn</p>
<MonthNav month={period} onPrev={prevPeriod} onNext={nextPeriod} />
{error && <div className="login-error">{error}</div>}
{slots === null ? (
<div className="empty-line">&gt; načítám</div>
) : (
<MonthCalendar month={period} renderDay={renderDay} />
)}
<div style={{ marginTop: '1rem' }}>
<p className="panel-title">{selectedDate ? formatDateOnly(selectedDate) : 'Vyber den v kalendáři'}</p>
{selectedDate && (
<>
<div className="list">
{daySlots.length === 0 && <div className="empty-line">· žádné směny</div>}
{daySlots.map((slot) => (
<SlotRow
key={slot.id}
slot={slot}
busy={busy}
employees={employees ?? []}
onSave={(data) => updateSlot(slot.id, data)}
onAssign={(employeeId) => assignEmployee(slot.id, employeeId)}
onRemove={(employeeId) => removeEmployee(slot.id, employeeId)}
/>
))}
</div>
<div style={{ marginTop: '0.75rem' }}>
<NewSlotForm key={selectedDate} busy={busy} defaultDate={selectedDate} onCreate={createSlot} />
</div>
</>
)}
</div>
</div>
);
}
function NewSlotForm({
busy,
defaultDate,
onCreate,
}: {
busy: boolean;
defaultDate: string;
onCreate: (data: { date: string; start_time: string; end_time: string; capacity: number; note?: string }) => Promise<void>;
}) {
const [date, setDate] = useState(defaultDate);
const [startTime, setStartTime] = useState('');
const [endTime, setEndTime] = useState('');
const [capacity, setCapacity] = useState('1');
const [note, setNote] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!date || !startTime || !endTime) return;
await onCreate({ date, start_time: startTime, end_time: endTime, capacity: Number(capacity), note: note || undefined });
setStartTime('');
setEndTime('');
setCapacity('1');
setNote('');
}
return (
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} disabled={busy} required />
<input
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
disabled={busy}
required
style={{ width: '6rem' }}
/>
<input
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
disabled={busy}
required
style={{ width: '6rem' }}
/>
<input
type="number"
min={1}
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
disabled={busy}
style={{ width: '4rem' }}
/>
<input
type="text"
placeholder="poznámka"
value={note}
onChange={(e) => setNote(e.target.value)}
disabled={busy}
style={{ flex: 1, minWidth: '8rem' }}
/>
<button className="btn" type="submit" disabled={busy}>
Přidat mimořádnou směnu
</button>
</form>
);
}
function SlotRow({
slot,
busy,
employees,
onSave,
onAssign,
onRemove,
}: {
slot: AdminShiftSlot;
busy: boolean;
employees: { id: number; email: string; name: string | null; active: 0 | 1 }[];
onSave: (data: { date: string; start_time: string; end_time: string; capacity: number; note?: string }) => Promise<void>;
onAssign: (employeeId: number) => Promise<void>;
onRemove: (employeeId: number) => Promise<void>;
}) {
const [capacity, setCapacity] = useState(String(slot.capacity));
const [note, setNote] = useState(slot.note);
const [pick, setPick] = useState('');
const dirty = Number(capacity) !== slot.capacity || note !== slot.note;
const assignedIds = new Set(slot.employees.map((e) => e.employee_id));
const candidates = employees.filter((e) => e.active === 1 && !assignedIds.has(e.id));
return (
<div className="row" style={{ flexWrap: 'wrap' }}>
<div className="row-main">
<div className="row-title">
{slot.start_time}{slot.end_time}{' '}
<span className={`badge ${slot.status === 'open' ? 'badge-dashed' : 'badge-solid'}`}>
{slot.status === 'open' ? 'volno' : slot.status === 'full' ? 'obsazeno' : 'uzavřeno'}
</span>
</div>
<div className="row-meta">
{slot.employees.length === 0 ? '· nikdo přihlášen' : slot.employees.map((e) => e.name).join(', ')}
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.4rem', flexWrap: 'wrap', alignItems: 'center' }}>
<input
type="number"
min={0}
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
disabled={busy}
style={{ width: '3.5rem' }}
/>
<input
type="text"
placeholder="poznámka"
value={note}
onChange={(e) => setNote(e.target.value)}
disabled={busy}
style={{ width: '10rem' }}
/>
{dirty && (
<button
className="btn"
disabled={busy}
onClick={() =>
onSave({
date: slot.date,
start_time: slot.start_time,
end_time: slot.end_time,
capacity: Number(capacity),
note: note || undefined,
})
}
>
Uložit
</button>
)}
{slot.employees.map((e) => (
<span key={e.employee_id} className="badge">
{e.name}{' '}
<button className="btn-ghost" disabled={busy} onClick={() => onRemove(e.employee_id)}>
x
</button>
</span>
))}
{candidates.length > 0 && (
<>
<select value={pick} onChange={(e) => setPick(e.target.value)} disabled={busy}>
<option value="">přiřadit</option>
{candidates.map((e) => (
<option key={e.id} value={e.id}>
{e.name ?? e.email}
</option>
))}
</select>
<button
className="btn"
disabled={busy || !pick}
onClick={() => {
onAssign(Number(pick));
setPick('');
}}
>
Přiřadit
</button>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { useEffect, useMemo } from 'react';
import useShiftPlanningStore from '../store/shiftPlanningStore';
import { MonthNav } from './MonthNav';
import { MonthCalendar } from './MonthCalendar';
import type { AvailableShiftSlot, MyShiftSlot } from '../api/types';
export function ShiftPlanning() {
const period = useShiftPlanningStore((s) => s.period);
const available = useShiftPlanningStore((s) => s.available);
const mine = useShiftPlanningStore((s) => s.mine);
const busy = useShiftPlanningStore((s) => s.busy);
const error = useShiftPlanningStore((s) => s.error);
const load = useShiftPlanningStore((s) => s.load);
const prevPeriod = useShiftPlanningStore((s) => s.prevPeriod);
const nextPeriod = useShiftPlanningStore((s) => s.nextPeriod);
const signup = useShiftPlanningStore((s) => s.signup);
const cancel = useShiftPlanningStore((s) => s.cancel);
useEffect(() => {
load();
}, [load]);
const byDate = useMemo(() => {
const map = new Map<string, { mine: MyShiftSlot[]; available: AvailableShiftSlot[] }>();
const entryFor = (date: string) => {
let entry = map.get(date);
if (!entry) {
entry = { mine: [], available: [] };
map.set(date, entry);
}
return entry;
};
mine?.forEach((slot) => entryFor(slot.date).mine.push(slot));
available?.forEach((slot) => entryFor(slot.date).available.push(slot));
return map;
}, [mine, available]);
function renderDay(dateStr: string) {
const entry = byDate.get(dateStr);
if (!entry) return null;
return (
<>
{entry.mine.map((slot) => (
<button
key={`mine-${slot.slot_id}`}
className="calendar-slot is-mine"
disabled={busy}
title="Zrušit přihlášení"
onClick={() => cancel(slot.slot_id)}
>
{slot.start_time}{slot.end_time}
</button>
))}
{entry.available.map((slot) => (
<button
key={`avail-${slot.slot_id}`}
className="calendar-slot"
disabled={busy}
title="Přihlásit se"
onClick={() => signup(slot.slot_id)}
>
{slot.start_time}{slot.end_time} ({slot.free_places}/{slot.capacity})
</button>
))}
</>
);
}
return (
<div className="panel">
<p className="panel-title">Plánování směn</p>
<MonthNav month={period} onPrev={prevPeriod} onNext={nextPeriod} />
{error && <div className="login-error">{error}</div>}
{available === null || mine === null ? (
<div className="empty-line">&gt; načítám</div>
) : (
<MonthCalendar month={period} renderDay={renderDay} />
)}
<p className="row-meta" style={{ marginTop: '0.5rem' }}>
Tučně = moje směna, klikni pro zrušení &middot; ostatní = volná směna, klikni pro přihlášení
</p>
</div>
);
}

View File

@@ -20,3 +20,13 @@ export function formatDate(iso: string): string {
export function formatDateTime(iso: string): string {
return `${formatDate(iso)} ${formatTime(iso)}`;
}
/** Formats a "YYYY-MM-DD" date-only string, parsed as a local date (not UTC) so it never shifts by a day. */
export function formatDateOnly(dateOnly: string): string {
const [year, month, day] = dateOnly.split('-').map(Number);
return new Date(year, month - 1, day).toLocaleDateString('cs-CZ', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
});
}

View File

@@ -1,6 +1,6 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './styles/tui.css';
import './styles/theme.css';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(

View File

@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react';
import { EmployeeManager } from '../components/EmployeeManager';
import { AdminStats } from '../components/AdminStats';
import { ShiftPlanManager } from '../components/ShiftPlanManager';
import { PayrollManager } from '../components/PayrollManager';
type Tab = 'employees' | 'stats';
type Tab = 'employees' | 'stats' | 'shifts' | 'payroll';
export function AdminApp() {
const [tab, setTab] = useState<Tab>('employees');
@@ -13,6 +15,8 @@ export function AdminApp() {
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
if (e.key === '1') setTab('employees');
if (e.key === '2') setTab('stats');
if (e.key === '3') setTab('shifts');
if (e.key === '4') setTab('payroll');
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
@@ -30,10 +34,19 @@ export function AdminApp() {
<button className={`tab ${tab === 'stats' ? 'active' : ''}`} onClick={() => setTab('stats')}>
[2] Statistiky
</button>
<button className={`tab ${tab === 'shifts' ? 'active' : ''}`} onClick={() => setTab('shifts')}>
[3] Směny
</button>
<button className={`tab ${tab === 'payroll' ? 'active' : ''}`} onClick={() => setTab('payroll')}>
[4] Mzdy
</button>
</div>
<div style={{ marginTop: '1rem' }}>
{tab === 'employees' ? <EmployeeManager /> : <AdminStats />}
{tab === 'employees' && <EmployeeManager />}
{tab === 'stats' && <AdminStats />}
{tab === 'shifts' && <ShiftPlanManager />}
{tab === 'payroll' && <PayrollManager />}
</div>
</>
);

View File

@@ -4,6 +4,8 @@ import { ClockControls } from '../components/ClockControls';
import { StatusBadge } from '../components/StatusBadge';
import { StatsSummary } from '../components/StatsSummary';
import { SessionList } from '../components/SessionList';
import { ShiftPlanning } from '../components/ShiftPlanning';
import { ClosureCard } from '../components/ClosureCard';
export function EmployeeApp() {
const status = useAttendanceStore((s) => s.status);
@@ -53,9 +55,13 @@ export function EmployeeApp() {
</div>
<div className="panel">
<p className="panel-title">Směny</p>
<p className="panel-title">Odpracované směny</p>
<SessionList sessions={live.sessions} />
</div>
<ShiftPlanning />
<ClosureCard />
</>
);
}

View File

@@ -0,0 +1,108 @@
import { create } from 'zustand';
import { api, ApiError } from '../api/client';
import type { Payroll, PayrollOverviewRow } from '../api/types';
import { currentMonthKey, shiftMonthKey } from '../lib/month';
export interface Adjustments {
tips_amount: number;
bonus_amount: number;
other_amount: number;
}
interface AdminPayrollState {
period: string;
rows: PayrollOverviewRow[] | null;
busy: boolean;
error: string | null;
load: () => Promise<void>;
setPeriod: (period: string) => Promise<void>;
prevPeriod: () => Promise<void>;
nextPeriod: () => Promise<void>;
saveAdjustments: (employeeId: number, data: Adjustments) => Promise<void>;
lock: (employeeId: number) => Promise<void>;
reopen: (employeeId: number) => Promise<void>;
markPaid: (employeeId: number) => Promise<void>;
}
const useAdminPayrollStore = create<AdminPayrollState>((set, get) => ({
period: currentMonthKey(),
rows: null,
busy: false,
error: null,
load: async () => {
try {
const res = await api.get<{ rows: PayrollOverviewRow[] }>(`/admin/payroll?period=${get().period}`);
set({ rows: res.rows, error: null });
} catch {
set({ error: 'Nepodařilo se načíst mzdy' });
}
},
setPeriod: async (period) => {
set({ period });
await get().load();
},
prevPeriod: async () => {
await get().setPeriod(shiftMonthKey(get().period, -1));
},
nextPeriod: async () => {
await get().setPeriod(shiftMonthKey(get().period, 1));
},
saveAdjustments: async (employeeId, data) => {
set({ busy: true, error: null });
try {
await api.post<{ payroll: Payroll }>(`/admin/payroll/${employeeId}/adjustments`, {
period: get().period,
...data,
});
await get().load();
} catch (err) {
set({ error: err instanceof ApiError ? err.message : 'Uložení se nezdařilo' });
} finally {
set({ busy: false });
}
},
lock: async (employeeId) => {
set({ busy: true, error: null });
try {
await api.post(`/admin/payroll/${employeeId}/lock`, { period: get().period });
await get().load();
} catch (err) {
set({ error: err instanceof ApiError ? err.message : 'Uzamčení se nezdařilo' });
} finally {
set({ busy: false });
}
},
reopen: async (employeeId) => {
set({ busy: true, error: null });
try {
await api.post(`/admin/payroll/${employeeId}/reopen`, { period: get().period });
await get().load();
} catch (err) {
set({ error: err instanceof ApiError ? err.message : 'Odemknutí se nezdařilo' });
} finally {
set({ busy: false });
}
},
markPaid: async (employeeId) => {
set({ busy: true, error: null });
try {
await api.post(`/admin/payroll/${employeeId}/paid`, { period: get().period });
await get().load();
} catch (err) {
set({ error: err instanceof ApiError ? err.message : 'Označení se nezdařilo' });
} finally {
set({ busy: false });
}
},
}));
export default useAdminPayrollStore;

View File

@@ -0,0 +1,107 @@
import { create } from 'zustand';
import { api, ApiError } from '../api/client';
import type { AdminShiftSlot } from '../api/types';
import { currentMonthKey, shiftMonthKey } from '../lib/month';
export interface SlotFormData {
date: string;
start_time: string;
end_time: string;
capacity: number;
note?: string;
}
interface AdminShiftState {
period: string;
slots: AdminShiftSlot[] | null;
busy: boolean;
error: string | null;
load: () => Promise<void>;
setPeriod: (period: string) => Promise<void>;
prevPeriod: () => Promise<void>;
nextPeriod: () => Promise<void>;
createSlot: (data: SlotFormData) => Promise<void>;
updateSlot: (slotId: number, data: SlotFormData) => Promise<void>;
assignEmployee: (slotId: number, employeeId: number) => Promise<void>;
removeEmployee: (slotId: number, employeeId: number) => Promise<void>;
}
const useAdminShiftStore = create<AdminShiftState>((set, get) => ({
period: currentMonthKey(),
slots: null,
busy: false,
error: null,
load: async () => {
try {
const res = await api.get<{ slots: AdminShiftSlot[] }>(`/admin/shifts?period=${get().period}`);
set({ slots: res.slots, error: null });
} catch {
set({ error: 'Nepodařilo se načíst plán směn' });
}
},
setPeriod: async (period) => {
set({ period });
await get().load();
},
prevPeriod: async () => {
await get().setPeriod(shiftMonthKey(get().period, -1));
},
nextPeriod: async () => {
await get().setPeriod(shiftMonthKey(get().period, 1));
},
createSlot: async (data) => {
set({ busy: true, error: null });
try {
await api.post('/admin/shifts', data);
await get().load();
} catch (err) {
set({ error: err instanceof ApiError ? err.message : 'Vytvoření se nezdařilo' });
} finally {
set({ busy: false });
}
},
updateSlot: async (slotId, data) => {
set({ busy: true, error: null });
try {
await api.patch(`/admin/shifts/${slotId}`, data);
await get().load();
} catch (err) {
set({ error: err instanceof ApiError ? err.message : 'Úprava se nezdařila' });
} finally {
set({ busy: false });
}
},
assignEmployee: async (slotId, employeeId) => {
set({ busy: true, error: null });
try {
await api.post(`/admin/shifts/${slotId}/assign`, { employeeId });
await get().load();
} catch (err) {
set({ error: err instanceof ApiError ? err.message : 'Přiřazení se nezdařilo' });
} finally {
set({ busy: false });
}
},
removeEmployee: async (slotId, employeeId) => {
set({ busy: true, error: null });
try {
await api.delete(`/admin/shifts/${slotId}/assign/${employeeId}`);
await get().load();
} catch (err) {
set({ error: err instanceof ApiError ? err.message : 'Odebrání se nezdařilo' });
} finally {
set({ busy: false });
}
},
}));
export default useAdminShiftStore;

View File

@@ -1,10 +1,10 @@
import { create } from 'zustand';
import { api, ApiError } from '../api/client';
import type { AdminStatsSummary, Employee, StatsSummary } from '../api/types';
import type { AdminStatsSummary, EmployeeWithRate, StatsSummary } from '../api/types';
import { currentMonthKey, shiftMonthKey } from '../lib/month';
interface AdminState {
employees: Employee[] | null;
employees: EmployeeWithRate[] | null;
employeesError: string | null;
employeesBusy: boolean;
@@ -18,6 +18,7 @@ interface AdminState {
loadEmployees: () => Promise<void>;
addEmployee: (email: string) => Promise<void>;
removeEmployee: (id: number) => Promise<void>;
setRate: (id: number, hourlyRate: number) => Promise<void>;
loadStats: () => Promise<void>;
selectEmployee: (id: number) => Promise<void>;
@@ -40,13 +41,25 @@ const useAdminStore = create<AdminState>((set, get) => ({
loadEmployees: async () => {
try {
const res = await api.get<{ employees: Employee[] }>('/admin/employees');
const res = await api.get<{ employees: EmployeeWithRate[] }>('/admin/employees');
set({ employees: res.employees, employeesError: null });
} catch {
set({ employeesError: 'Nepodařilo se načíst zaměstnance' });
}
},
setRate: async (id, hourlyRate) => {
set({ employeesBusy: true, employeesError: null });
try {
await api.post(`/admin/employees/${id}/rate`, { hourly_rate: hourlyRate });
await get().loadEmployees();
} catch (err) {
set({ employeesError: err instanceof ApiError ? err.message : 'Uložení sazby se nezdařilo' });
} finally {
set({ employeesBusy: false });
}
},
addEmployee: async (email) => {
set({ employeesBusy: true, employeesError: null });
try {

View File

@@ -0,0 +1,64 @@
import { create } from 'zustand';
import { api, ApiError } from '../api/client';
import type { ClosureSummary, MonthClosure } from '../api/types';
import { currentMonthKey, shiftMonthKey } from '../lib/month';
interface ClosureState {
period: string;
closure: MonthClosure | null;
summary: ClosureSummary | null;
busy: boolean;
error: string | null;
load: () => Promise<void>;
setPeriod: (period: string) => Promise<void>;
prevPeriod: () => Promise<void>;
nextPeriod: () => Promise<void>;
confirm: () => Promise<void>;
}
const useClosureStore = create<ClosureState>((set, get) => ({
period: currentMonthKey(),
closure: null,
summary: null,
busy: false,
error: null,
load: async () => {
try {
const res = await api.get<{ closure: MonthClosure; summary: ClosureSummary }>(
`/closure?period=${get().period}`
);
set({ closure: res.closure, summary: res.summary, error: null });
} catch {
set({ error: 'Nepodařilo se načíst uzávěrku' });
}
},
setPeriod: async (period) => {
set({ period });
await get().load();
},
prevPeriod: async () => {
await get().setPeriod(shiftMonthKey(get().period, -1));
},
nextPeriod: async () => {
await get().setPeriod(shiftMonthKey(get().period, 1));
},
confirm: async () => {
set({ busy: true, error: null });
try {
await api.post('/closure/confirm', { period: get().period });
await get().load();
} catch (err) {
set({ error: err instanceof ApiError ? err.message : 'Potvrzení se nezdařilo' });
} finally {
set({ busy: false });
}
},
}));
export default useClosureStore;

View File

@@ -0,0 +1,79 @@
import { create } from 'zustand';
import { api, ApiError } from '../api/client';
import type { AvailableShiftSlot, MyShiftSlot } from '../api/types';
import { currentMonthKey, shiftMonthKey } from '../lib/month';
interface ShiftPlanningState {
period: string;
available: AvailableShiftSlot[] | null;
mine: MyShiftSlot[] | null;
busy: boolean;
error: string | null;
load: () => Promise<void>;
setPeriod: (period: string) => Promise<void>;
prevPeriod: () => Promise<void>;
nextPeriod: () => Promise<void>;
signup: (slotId: number) => Promise<void>;
cancel: (slotId: number) => Promise<void>;
}
const useShiftPlanningStore = create<ShiftPlanningState>((set, get) => ({
period: currentMonthKey(),
available: null,
mine: null,
busy: false,
error: null,
load: async () => {
try {
const period = get().period;
const [availableRes, mineRes] = await Promise.all([
api.get<{ slots: AvailableShiftSlot[] }>(`/shifts/available?period=${period}`),
api.get<{ slots: MyShiftSlot[] }>(`/shifts/mine?period=${period}`),
]);
set({ available: availableRes.slots, mine: mineRes.slots, error: null });
} catch {
set({ error: 'Nepodařilo se načíst směny' });
}
},
setPeriod: async (period) => {
set({ period });
await get().load();
},
prevPeriod: async () => {
await get().setPeriod(shiftMonthKey(get().period, -1));
},
nextPeriod: async () => {
await get().setPeriod(shiftMonthKey(get().period, 1));
},
signup: async (slotId) => {
set({ busy: true, error: null });
try {
await api.post(`/shifts/${slotId}/signup`);
await get().load();
} catch (err) {
set({ error: err instanceof ApiError ? err.message : 'Přihlášení se nezdařilo' });
} finally {
set({ busy: false });
}
},
cancel: async (slotId) => {
set({ busy: true, error: null });
try {
await api.delete(`/shifts/${slotId}/signup`);
await get().load();
} catch (err) {
set({ error: err instanceof ApiError ? err.message : 'Zrušení se nezdařilo' });
} finally {
set({ busy: false });
}
},
}));
export default useShiftPlanningStore;

View File

@@ -0,0 +1,735 @@
:root {
--bg: #0d0f13;
--surface: #15181f;
--surface-hover: #1c2029;
--surface-2: #1a1e26;
--border: #262b34;
--border-strong: #333a46;
--fg: #eef0f3;
--muted: #8d95a3;
--muted-2: #5f6675;
--accent: #f2a93b;
--accent-strong: #ffc670;
--accent-fg: #1a1206;
--radius-lg: 0;
--radius: 0;
--radius-sm: 0;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-md: 0 16px 40px -20px rgba(0, 0, 0, 0.65);
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
margin: 0;
}
html,
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font);
font-size: 14px;
line-height: 1.45;
font-variant-numeric: tabular-nums;
color-scheme: dark;
-webkit-font-smoothing: antialiased;
}
body.app {
overflow: hidden;
}
#root {
display: flex;
flex-direction: column;
}
a {
color: inherit;
}
button {
font-family: inherit;
font-size: inherit;
}
/* ---------- form controls ---------- */
input,
select {
font-family: inherit;
font-size: 13px;
color: var(--fg);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.45rem 0.6rem;
outline: none;
}
input::placeholder {
color: var(--muted-2);
}
input:focus,
select:focus {
border-color: var(--accent);
}
input:disabled,
select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ---------- shell ---------- */
.app-shell {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.chrome {
flex-shrink: 0;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.chrome-inner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 1.25rem;
max-width: 72rem;
margin: 0 auto;
width: 100%;
}
.brand {
font-weight: 700;
font-size: 15px;
letter-spacing: -0.01em;
}
.brand-sub {
color: var(--muted);
font-size: 12px;
font-weight: 500;
margin-left: 0.5rem;
}
.chrome-user {
display: flex;
align-items: center;
gap: 1rem;
font-size: 12.5px;
color: var(--muted);
}
.scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.scroll-inner {
max-width: 72rem;
margin: 0 auto;
padding: 1.5rem 1.25rem 3rem;
}
/* ---------- tabs ---------- */
.tabs {
display: flex;
gap: 0.25rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.25rem;
}
.tab {
flex: 1;
padding: 0.5rem 0.75rem;
text-align: center;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.01em;
background: transparent;
color: var(--muted);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.tab:hover {
color: var(--fg);
background: var(--surface-hover);
}
.tab.active {
background: var(--accent);
color: var(--accent-fg);
}
/* ---------- buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
border: 1px solid var(--border-strong);
background: var(--surface-2);
color: var(--fg);
padding: 0.5rem 0.9rem;
font-weight: 600;
font-size: 12.5px;
border-radius: var(--radius-sm);
cursor: pointer;
text-decoration: none;
transition: background 0.15s ease, border-color 0.15s ease, transform 0.05s ease;
}
.btn:hover:not(:disabled) {
background: var(--surface-hover);
border-color: var(--muted-2);
}
.btn:active:not(:disabled) {
transform: translateY(1px);
}
.btn:disabled {
color: var(--muted-2);
cursor: not-allowed;
opacity: 0.6;
}
.btn-block {
width: 100%;
padding: 0.75rem 1rem;
font-size: 13.5px;
}
.btn-ghost {
border: none;
background: transparent;
color: var(--muted);
font-weight: 500;
padding: 0.3rem 0.4rem;
border-radius: var(--radius-sm);
}
.btn-ghost:hover:not(:disabled) {
color: var(--fg);
background: var(--surface-hover);
}
/* ---------- panels / bordered ---------- */
.bordered {
border: 1px solid var(--border);
border-radius: var(--radius);
}
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
box-shadow: var(--shadow-sm);
}
.panel + .panel {
margin-top: 1rem;
}
.panel-title {
text-transform: uppercase;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--muted);
margin: 0 0 0.85rem;
}
/* ---------- field row (bordered input + button) ---------- */
.field-row {
display: flex;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.field-row input {
flex: 1;
border: none;
background: transparent;
padding: 0.6rem 0.8rem;
font-size: 13.5px;
min-width: 0;
}
.field-row .btn {
border: none;
border-left: 1px solid var(--border);
border-radius: 0;
}
/* ---------- dense list rows ---------- */
.list {
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem 0.9rem;
background: var(--surface);
}
.row + .row {
border-top: 1px solid var(--border);
}
.row:hover {
background: var(--surface-hover);
}
.row-main {
min-width: 0;
flex: 1;
}
.row-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 600;
font-size: 13.5px;
}
.row-meta {
color: var(--muted);
font-size: 12px;
margin-top: 0.2rem;
}
.row-action {
flex-shrink: 0;
display: flex;
gap: 0.5rem;
align-items: center;
}
/* ---------- badges ---------- */
.badge {
display: inline-flex;
align-items: center;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 0.2rem 0.55rem;
border-radius: 0;
background: var(--surface-2);
color: var(--muted);
border: 1px solid var(--border);
}
.badge-solid {
background: var(--accent);
color: var(--accent-fg);
border-color: transparent;
font-weight: 700;
}
.badge-dashed {
background: transparent;
color: var(--muted);
border: 1px solid var(--border-strong);
}
/* ---------- stat tiles ---------- */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.stat-tile {
padding: 0.9rem 1rem;
background: var(--surface);
}
.stat-value {
font-family: var(--font-mono);
font-size: 22px;
font-weight: 700;
letter-spacing: -0.01em;
}
.stat-label {
color: var(--muted);
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.25rem;
}
/* ---------- login ---------- */
.login-shell {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.login-card {
width: 100%;
max-width: 23rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 2.25rem 2rem;
text-align: center;
box-shadow: var(--shadow-md);
}
.login-logo {
width: 88px;
height: 88px;
margin: 0 auto 1.1rem;
display: block;
border-radius: var(--radius);
border: 1px solid var(--border);
}
.login-title {
font-weight: 700;
font-size: 17px;
letter-spacing: -0.01em;
margin: 0 0 0.3rem;
}
.login-sub {
color: var(--muted);
font-size: 12.5px;
margin: 0 0 1.75rem;
}
.google-btn-slot {
display: flex;
justify-content: center;
margin: 0 auto;
}
.login-error {
margin-top: 1.25rem;
font-size: 12px;
color: #ffb4b4;
background: rgba(239, 106, 106, 0.1);
border: 1px solid rgba(239, 106, 106, 0.3);
border-radius: var(--radius-sm);
padding: 0.6rem 0.75rem;
text-align: left;
}
/* ---------- month nav ---------- */
.month-nav {
display: flex;
align-items: stretch;
gap: 0.25rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.25rem;
margin-bottom: 1rem;
}
.month-nav .btn {
border: none;
background: transparent;
flex-shrink: 0;
}
.month-nav .btn:hover:not(:disabled) {
background: var(--surface-hover);
}
.month-nav-label {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 12.5px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: capitalize;
}
/* ---------- bar chart ---------- */
.bar-chart-wrap {
display: flex;
gap: 0.75rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem 0.75rem 0.75rem;
}
.bar-chart-yaxis {
flex-shrink: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
height: 140px;
padding-right: 0.6rem;
border-right: 1px solid var(--border);
color: var(--muted);
font-size: 10px;
font-family: var(--font-mono);
line-height: 1;
}
.bar-chart-body {
flex: 1;
min-width: 0;
}
.bar-chart {
display: flex;
align-items: flex-end;
gap: 3px;
height: 140px;
background-image: repeating-linear-gradient(
to top,
var(--border) 0,
var(--border) 1px,
transparent 1px,
transparent 25%
);
}
.bar-col {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: stretch;
min-width: 3px;
}
.bar {
background: var(--accent);
border-radius: 0;
min-height: 2px;
opacity: 0.85;
transition: opacity 0.12s ease;
}
.bar-col:hover .bar {
opacity: 1;
}
.bar-chart-labels {
display: flex;
gap: 3px;
margin-top: 0.35rem;
}
.bar-col-label {
flex: 1;
min-width: 3px;
color: var(--muted);
font-size: 9.5px;
text-align: center;
line-height: 1;
font-family: var(--font-mono);
}
/* ---------- empty / loading ---------- */
.empty-line {
color: var(--muted);
padding: 0.85rem;
font-size: 13px;
}
/* ---------- focus ---------- */
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 0;
}
/* ---------- month calendar ---------- */
.month-calendar-scroll {
overflow-x: auto;
}
.month-calendar {
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
min-width: 30rem;
}
.month-calendar-head {
display: grid;
grid-template-columns: repeat(7, 1fr);
background: var(--surface-2);
border-bottom: 1px solid var(--border);
}
.month-calendar-head div {
padding: 0.5rem;
text-align: center;
text-transform: uppercase;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--muted);
}
.month-calendar-body {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background: var(--border);
}
.month-calendar-cell {
min-height: 4.75rem;
padding: 0.35rem;
background: var(--surface);
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.month-calendar-cell.is-outside {
background: var(--bg);
}
.month-calendar-cell.is-today {
box-shadow: inset 0 0 0 1.5px var(--accent);
}
.month-calendar-daynum {
font-size: 10.5px;
text-align: right;
color: var(--muted);
font-family: var(--font-mono);
}
.month-calendar-cell.is-today .month-calendar-daynum {
color: var(--accent);
font-weight: 700;
}
.month-calendar-day-btn {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
border: none;
background: transparent;
padding: 0;
cursor: pointer;
text-align: left;
font-family: inherit;
border-radius: var(--radius-sm);
}
.month-calendar-day-btn.is-selected {
box-shadow: inset 0 0 0 1.5px var(--accent);
}
.calendar-slot {
display: block;
width: 100%;
font-family: var(--font-mono);
font-size: 9.5px;
line-height: 1.3;
padding: 0.15rem 0.35rem;
border-radius: 0;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--fg);
cursor: pointer;
text-align: left;
}
.calendar-slot:hover:not(:disabled) {
background: var(--surface-hover);
border-color: var(--border-strong);
}
.calendar-slot:disabled {
cursor: not-allowed;
color: var(--muted-2);
}
.calendar-slot.is-mine {
background: var(--accent);
color: var(--accent-fg);
border-color: transparent;
font-weight: 700;
}
.calendar-chip {
display: block;
font-size: 9.5px;
line-height: 1.3;
padding: 0.15rem 0.35rem;
border-radius: 0;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--muted);
font-family: var(--font-mono);
}
.calendar-chip.full {
background: var(--accent);
color: var(--accent-fg);
font-weight: 700;
border-color: transparent;
}
.calendar-chip.closed {
opacity: 0.55;
}

View File

@@ -1,520 +0,0 @@
:root {
--bg: #f0f0f0;
--fg: #000000;
--muted: #666666;
--border: #000000;
--hover: #e0e0e0;
--empty: #d8d8d8;
--panel: #fafafa;
--font: 'IBM Plex Mono', 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
margin: 0;
}
html,
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font);
font-size: 13px;
font-variant-numeric: tabular-nums;
}
body.app {
overflow: hidden;
}
#root {
display: flex;
flex-direction: column;
}
a {
color: inherit;
}
button {
font-family: inherit;
font-size: inherit;
}
/* ---------- shell ---------- */
.app-shell {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.chrome {
flex-shrink: 0;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.chrome-inner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1rem;
max-width: 72rem;
margin: 0 auto;
width: 100%;
}
.brand {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.brand-sub {
color: var(--muted);
font-size: 11px;
margin-left: 0.5rem;
}
.chrome-user {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 11px;
color: var(--muted);
}
.scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.scroll-inner {
max-width: 72rem;
margin: 0 auto;
padding: 1rem;
}
/* ---------- tabs ---------- */
.tabs {
display: flex;
border: 1px solid var(--border);
overflow: hidden;
}
.tab {
flex: 1;
padding: 0.5rem 0.75rem;
text-align: center;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.04em;
background: var(--bg);
cursor: pointer;
border: none;
border-right: 1px solid var(--border);
}
.tab:last-child {
border-right: none;
}
.tab:hover {
background: var(--hover);
}
.tab.active {
background: var(--fg);
color: var(--bg);
font-weight: 700;
}
/* ---------- buttons ---------- */
.btn {
display: inline-block;
border: 1px solid var(--border);
background: var(--bg);
color: var(--fg);
padding: 0.5rem 1rem;
text-transform: uppercase;
font-weight: 700;
font-size: 11px;
letter-spacing: 0.03em;
cursor: pointer;
text-decoration: none;
}
.btn:hover:not(:disabled) {
background: var(--fg);
color: var(--bg);
}
.btn:disabled {
color: var(--muted);
border-color: var(--muted);
cursor: not-allowed;
}
.btn-block {
width: 100%;
padding: 0.9rem 1rem;
font-size: 13px;
}
.btn-ghost {
border: none;
background: transparent;
text-decoration: underline;
text-transform: none;
font-weight: 400;
padding: 0.2rem 0.4rem;
}
.btn-ghost:hover:not(:disabled) {
background: var(--hover);
color: var(--fg);
}
/* ---------- panels / bordered ---------- */
.bordered {
border: 1px solid var(--border);
}
.panel {
border: 1px solid var(--border);
background: var(--panel);
padding: 1rem;
}
.panel + .panel {
margin-top: 1rem;
}
.panel-title {
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--muted);
margin: 0 0 0.75rem;
}
/* ---------- field row (bordered input + button) ---------- */
.field-row {
display: flex;
border: 1px solid var(--border);
}
.field-row input {
flex: 1;
border: none;
background: var(--panel);
padding: 0.6rem 0.75rem;
font-family: inherit;
font-size: 13px;
color: var(--fg);
outline: none;
min-width: 0;
}
.field-row input::placeholder {
color: var(--muted);
}
.field-row .btn {
border: none;
border-left: 1px solid var(--border);
}
/* ---------- dense list rows ---------- */
.list {
border: 1px solid var(--border);
border-top: none;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.6rem 0.75rem;
border-top: 1px solid var(--border);
background: var(--bg);
}
.row:hover {
background: var(--hover);
}
.row-main {
min-width: 0;
flex: 1;
}
.row-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-meta {
color: var(--muted);
font-size: 11px;
margin-top: 0.15rem;
}
.row-action {
flex-shrink: 0;
display: flex;
gap: 0.5rem;
align-items: center;
}
/* ---------- badges ---------- */
.badge {
display: inline-block;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.15rem 0.4rem;
border: 1px solid var(--border);
}
.badge-solid {
background: var(--fg);
color: var(--bg);
font-weight: 700;
}
.badge-dashed {
border-style: dashed;
color: var(--muted);
}
.badge-double {
border-width: 3px;
border-style: double;
}
/* ---------- stat tiles ---------- */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
border: 1px solid var(--border);
}
.stat-tile {
padding: 0.75rem;
border-left: 1px solid var(--border);
}
.stat-tile:first-child {
border-left: none;
}
.stat-value {
font-size: 20px;
font-weight: 700;
}
.stat-label {
color: var(--muted);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-top: 0.2rem;
}
/* ---------- login ---------- */
.login-shell {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.login-card {
width: 100%;
max-width: 22rem;
border: 1px solid var(--border);
background: var(--panel);
padding: 2rem 1.75rem;
text-align: center;
}
.login-logo {
width: 96px;
height: 96px;
margin: 0 auto 1rem;
display: block;
border: 1px solid var(--border);
}
.login-title {
text-transform: uppercase;
font-weight: 700;
letter-spacing: 0.04em;
margin: 0 0 0.25rem;
}
.login-sub {
color: var(--muted);
font-size: 11px;
margin: 0 0 1.5rem;
}
.google-btn-slot {
display: flex;
justify-content: center;
margin: 0 auto;
}
.login-error {
margin-top: 1rem;
font-size: 11px;
color: var(--fg);
border: 1px dashed var(--border);
padding: 0.5rem;
text-align: left;
}
/* ---------- month nav ---------- */
.month-nav {
display: flex;
align-items: stretch;
border: 1px solid var(--border);
margin-bottom: 1rem;
}
.month-nav .btn {
border: none;
flex-shrink: 0;
}
.month-nav .btn:first-child {
border-right: 1px solid var(--border);
}
.month-nav .btn:last-child {
border-left: 1px solid var(--border);
}
.month-nav-label {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-transform: uppercase;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
}
/* ---------- bar chart ---------- */
.bar-chart-wrap {
display: flex;
gap: 0.5rem;
border: 1px solid var(--border);
background: var(--panel);
padding: 0.75rem 0.5rem 0.5rem;
}
.bar-chart-yaxis {
flex-shrink: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
height: 140px;
padding-right: 0.5rem;
border-right: 1px solid var(--border);
color: var(--muted);
font-size: 9px;
line-height: 1;
}
.bar-chart-body {
flex: 1;
min-width: 0;
}
.bar-chart {
display: flex;
align-items: flex-end;
gap: 2px;
height: 140px;
background-image: repeating-linear-gradient(
to top,
var(--empty) 0,
var(--empty) 1px,
transparent 1px,
transparent 25%
);
}
.bar-col {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: stretch;
min-width: 2px;
}
.bar {
background: var(--fg);
min-height: 1px;
}
.bar-col:hover .bar {
background: var(--muted);
}
.bar-chart-labels {
display: flex;
gap: 2px;
margin-top: 0.25rem;
}
.bar-col-label {
flex: 1;
min-width: 2px;
color: var(--muted);
font-size: 9px;
text-align: center;
line-height: 1;
}
/* ---------- empty / loading ---------- */
.empty-line {
color: var(--muted);
padding: 0.75rem;
}
/* ---------- focus ---------- */
:focus-visible {
outline: 1px solid var(--fg);
outline-offset: 2px;
}

View File

@@ -13,8 +13,8 @@ export default defineConfig({
name: 'EatMe — Docházka',
short_name: 'EatMe',
description: 'Evidence docházky zaměstnanců bistra EatMe',
theme_color: '#000000',
background_color: '#f0f0f0',
theme_color: '#0d0f13',
background_color: '#0d0f13',
display: 'standalone',
start_url: '/',
icons: [

16
gscript/.clasp.json Normal file
View File

@@ -0,0 +1,16 @@
{
"scriptId": "1O3j68RUOqdnO4N2-sWgv5seES-Bd-AmZDE5HcDjgz0bTN6-jMwNiVrBj",
"rootDir": "",
"scriptExtensions": [
".js",
".gs"
],
"htmlExtensions": [
".html"
],
"jsonExtensions": [
".json"
],
"filePushOrder": [],
"skipSubdirectories": false
}

1659
gscript/AdminService.js Normal file

File diff suppressed because it is too large Load Diff

6
gscript/App.js Normal file
View File

@@ -0,0 +1,6 @@
function doGet() {
return HtmlService.createTemplateFromFile('Index')
.evaluate()
.setTitle(CFG.APP_NAME)
.addMetaTag('viewport','width=device-width, initial-scale=1');
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,131 @@
function clockAction(token, action) {
const user = requireUser_(token,['EMPLOYEE','MANAGER','ADMIN']);
if (!user.employee_id) throw new Error('Účet není propojený se zaměstnancem.');
return clockForEmployee_(user.employee_id, action, 'PORTAL', user.user_id);
}
function terminalClock(employeeId, pin, action, locationId) {
const emp = findOne_(CFG.SHEETS.EMPLOYEES, r => String(r.employee_id) === String(employeeId));
if (!emp || String(emp.terminal_pin) !== String(pin)) throw new Error('Neplatný PIN.');
if (!(emp.active === true || String(emp.active).toUpperCase() === 'TRUE')) throw new Error('Zaměstnanec není aktivní.');
return clockForEmployee_(employeeId, action, 'TERMINAL', 'TERMINAL:'+String(locationId || emp.location_id));
}
function clockForEmployee_(employeeId, action, source, createdBy) {
const allowed = ['CLOCK_IN','BREAK_START','BREAK_END','CLOCK_OUT'];
if (!allowed.includes(action)) throw new Error('Neplatná akce.');
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
const open = getOpenShift_(employeeId);
const t = now_();
if (action === 'CLOCK_IN') {
if (open) throw new Error('Směna už probíhá.');
const emp = findOne_(CFG.SHEETS.EMPLOYEES, r => String(r.employee_id) === String(employeeId));
const shift = {
shift_id:uuid_('SHIFT'),
employee_id:employeeId,
work_date:isoDate_(t),
clock_in:t,
clock_out:'',
break_minutes:0,
worked_minutes:0,
night_minutes:0,
weekend_minutes:0,
holiday_minutes:0,
status:'OPEN',
location_id:emp ? emp.location_id : '',
created_at:t,
updated_at:t
};
append_(CFG.SHEETS.SHIFTS,shift);
attendanceEvent_(employeeId,t,action,shift.location_id,source,createdBy);
return {ok:true,status:'OPEN',shift};
}
if (!open) throw new Error('Žádná otevřená směna.');
const events = rows_(CFG.SHEETS.ATTENDANCE_EVENTS)
.filter(e => String(e.employee_id) === String(employeeId) && new Date(e.timestamp) >= new Date(open.clock_in))
.sort((a,b) => new Date(a.timestamp)-new Date(b.timestamp));
const last = events[events.length-1];
if (action === 'BREAK_START' && last && String(last.event_type) === 'BREAK_START') throw new Error('Pauza už běží.');
if (action === 'BREAK_END' && (!last || String(last.event_type) !== 'BREAK_START')) throw new Error('Pauza neběží.');
if (action === 'CLOCK_OUT' && last && String(last.event_type) === 'BREAK_START') throw new Error('Nejdřív ukonči pauzu.');
attendanceEvent_(employeeId,t,action,open.location_id,source,createdBy);
if (action === 'CLOCK_OUT') {
const allEvents = events.concat([{timestamp:t,event_type:'CLOCK_OUT'}]);
const calc = calculateShift_(new Date(open.clock_in),t,allEvents);
updateBy_(CFG.SHEETS.SHIFTS,'shift_id',open.shift_id,{
clock_out:t,
break_minutes:calc.breakMinutes,
worked_minutes:calc.workedMinutes,
night_minutes:calc.nightMinutes,
weekend_minutes:calc.weekendMinutes,
status:'COMPLETED',
updated_at:t
});
return {ok:true,status:'COMPLETED',shift_id:open.shift_id,worked_minutes:calc.workedMinutes};
}
return {ok:true,status:action,shift_id:open.shift_id};
} finally {
lock.releaseLock();
}
}
function attendanceEvent_(employeeId,timestamp,type,locationId,source,createdBy) {
append_(CFG.SHEETS.ATTENDANCE_EVENTS,{
event_id:uuid_('EVT'),
employee_id:employeeId,
timestamp,
event_type:type,
location_id:locationId || '',
source:source || '',
created_by:createdBy || ''
});
}
function getOpenShift_(employeeId) {
const list = rows_(CFG.SHEETS.SHIFTS)
.filter(s => String(s.employee_id) === String(employeeId) && String(s.status) === 'OPEN')
.sort((a,b) => new Date(b.clock_in)-new Date(a.clock_in));
return list[0] || null;
}
function calculateShift_(clockIn, clockOut, events) {
let breakMinutes = 0, breakStart = null;
(events || []).forEach(e => {
if (String(e.event_type) === 'BREAK_START') breakStart = new Date(e.timestamp);
if (String(e.event_type) === 'BREAK_END' && breakStart) {
breakMinutes += Math.max(0, Math.round((new Date(e.timestamp)-breakStart)/60000));
breakStart = null;
}
});
const total = Math.max(0, Math.round((clockOut-clockIn)/60000));
const worked = Math.max(0,total-breakMinutes);
// Základní výpočet nočních minut 22:0006:00 po jednotlivých minutách.
let night = 0, weekend = 0;
let p = new Date(clockIn);
while (p < clockOut) {
const n = new Date(Math.min(p.getTime()+60000, clockOut.getTime()));
const hh = Number(Utilities.formatDate(p,CFG.TZ,'H'));
const dow = Number(Utilities.formatDate(p,CFG.TZ,'u'));
if (hh >= 22 || hh < 6) night += (n-p)/60000;
if (dow >= 6) weekend += (n-p)/60000;
p = n;
}
return {
breakMinutes:Math.round(breakMinutes),
workedMinutes:Math.round(worked),
nightMinutes:Math.round(night),
weekendMinutes:Math.round(weekend)
};
}

1104
gscript/AuthService.js Normal file

File diff suppressed because it is too large Load Diff

33
gscript/Automation.js Normal file
View File

@@ -0,0 +1,33 @@
function nightlyMaintenance() {
const cutoff = Date.now() - 14*3600000;
rows_(CFG.SHEETS.SHIFTS)
.filter(s => String(s.status)==='OPEN' && new Date(s.clock_in).getTime() < cutoff)
.forEach(s => {
append_(CFG.SHEETS.NOTIFICATIONS,{
notification_id:uuid_('NOT'),
user_id:'',
type:'OPEN_SHIFT_WARNING',
title:'Pravděpodobně chybí odchod',
message:'Směna '+s.shift_id+' je otevřená déle než 14 hodin.',
url:'',
read_at:'',
created_at:now_(),
email_sent_at:''
});
});
}
function monthlyMaintenance() {
const d = new Date();
d.setMonth(d.getMonth()-1);
const p = Utilities.formatDate(d,CFG.TZ,'yyyy-MM');
rows_(CFG.SHEETS.EMPLOYEES)
.filter(e => e.active === true || String(e.active).toUpperCase()==='TRUE')
.forEach(e => {
const exists = findOne_(CFG.SHEETS.MONTH_CLOSURES,r=>String(r.employee_id)===String(e.employee_id)&&String(r.period)===p);
if (!exists) append_(CFG.SHEETS.MONTH_CLOSURES,{
closure_id:uuid_('CLOSE'),employee_id:e.employee_id,period:p,
employee_confirmed_at:'',manager_approved_at:'',manager_approved_by:'',locked_at:'',status:'WAITING_EMPLOYEE'
});
});
}

2048
gscript/ClosureService.js Normal file

File diff suppressed because it is too large Load Diff

295
gscript/Config.js Normal file
View File

@@ -0,0 +1,295 @@
const CFG = Object.freeze({
APP_NAME: 'EatMe Portál',
TZ: 'Europe/Prague',
SESSION_DAYS: 7,
LOGIN_CODE_MINUTES: 15,
PASSWORD_ROUNDS: 12000,
SHEETS: {
USERS: 'USERS',
EMPLOYEES: 'EMPLOYEES',
COMPANIES: 'COMPANIES',
LOCATIONS: 'LOCATIONS',
USER_COMPANIES: 'USER_COMPANIES',
PAY_RATES: 'PAY_RATES',
ATTENDANCE_EVENTS: 'ATTENDANCE_EVENTS',
SHIFTS: 'SHIFTS',
SHIFT_CHANGE_REQUESTS: 'SHIFT_CHANGE_REQUESTS',
SCHEDULES: 'SCHEDULES',
REQUESTS: 'REQUESTS',
MONTH_CLOSURES: 'MONTH_CLOSURES',
PAYROLL: 'PAYROLL',
PAYROLL_ITEMS: 'PAYROLL_ITEMS',
PAYSLIPS: 'PAYSLIPS',
NEWS: 'NEWS',
NEWS_READS: 'NEWS_READS',
DOCUMENTS: 'DOCUMENTS',
NOTIFICATIONS: 'NOTIFICATIONS',
SESSIONS: 'SESSIONS',
LOGIN_CODES: 'LOGIN_CODES',
AUDIT_LOG: 'AUDIT_LOG',
SETTINGS: 'SETTINGS'
}
});
const HEADERS = Object.freeze({
USERS: [
'user_id',
'email',
'role',
'employee_id',
'password_salt',
'password_hash',
'active',
'created_at',
'last_login'
],
EMPLOYEES: [
'employee_id',
'first_name',
'last_name',
'email',
'phone',
'company_id',
'location_id',
'position',
'employment_type',
'terminal_pin',
'start_date',
'end_date',
'active'
],
COMPANIES: [
'company_id',
'name',
'ico',
'active'
],
LOCATIONS: [
'location_id',
'company_id',
'name',
'active'
],
USER_COMPANIES: [
'user_id',
'company_id'
],
PAY_RATES: [
'pay_rate_id',
'employee_id',
'valid_from',
'valid_to',
'hourly_rate',
'created_by',
'created_at'
],
ATTENDANCE_EVENTS: [
'event_id',
'employee_id',
'timestamp',
'event_type',
'location_id',
'source',
'created_by'
],
SHIFTS: [
'shift_id',
'employee_id',
'work_date',
'clock_in',
'clock_out',
'break_minutes',
'worked_minutes',
'night_minutes',
'weekend_minutes',
'holiday_minutes',
'status',
'location_id',
'created_at',
'updated_at'
],
SHIFT_CHANGE_REQUESTS: [
'request_id',
'shift_id',
'employee_id',
'field',
'old_value',
'requested_value',
'reason',
'status',
'created_at',
'resolved_at',
'resolved_by'
],
SCHEDULES: [
'schedule_id',
'employee_id',
'location_id',
'start_at',
'end_at',
'status',
'note',
'created_by',
'created_at'
],
REQUESTS: [
'request_id',
'employee_id',
'type',
'date_from',
'date_to',
'reason',
'status',
'created_at',
'resolved_at',
'resolved_by',
'note'
],
MONTH_CLOSURES: [
'closure_id',
'employee_id',
'period',
'employee_confirmed_at',
'manager_approved_at',
'manager_approved_by',
'locked_at',
'status'
],
PAYROLL: [
'payroll_id',
'employee_id',
'period',
'approved_minutes',
'base_amount',
'bonus_amount',
'tips_amount',
'other_amount',
'final_amount',
'status',
'payment_date',
'updated_at'
],
PAYROLL_ITEMS: [
'item_id',
'payroll_id',
'employee_id',
'period',
'type',
'quantity',
'rate',
'amount',
'note',
'created_by',
'created_at'
],
PAYSLIPS: [
'payslip_id',
'employee_id',
'period',
'drive_file_id',
'file_name',
'uploaded_at',
'uploaded_by'
],
NEWS: [
'news_id',
'title',
'content',
'category',
'company_id',
'location_id',
'target_role',
'published_from',
'published_to',
'require_confirmation',
'active',
'created_by',
'created_at'
],
NEWS_READS: [
'news_read_id',
'news_id',
'user_id',
'read_at',
'confirmed_at'
],
DOCUMENTS: [
'document_id',
'employee_id',
'company_id',
'type',
'title',
'drive_file_id',
'visible_to_employee',
'uploaded_at',
'uploaded_by'
],
NOTIFICATIONS: [
'notification_id',
'user_id',
'type',
'title',
'message',
'url',
'read_at',
'created_at',
'email_sent_at'
],
SESSIONS: [
'session_id',
'user_id',
'token_hash',
'expires_at',
'created_at',
'last_seen_at'
],
LOGIN_CODES: [
'code_id',
'email',
'purpose',
'code_hash',
'expires_at',
'used_at',
'created_at'
],
AUDIT_LOG: [
'audit_id',
'user_id',
'action',
'entity_type',
'entity_id',
'old_value',
'new_value',
'created_at'
],
SETTINGS: [
'key',
'value'
]
});

3278
gscript/DailySalesService.js Normal file

File diff suppressed because it is too large Load Diff

1099
gscript/DashboardService.js Normal file

File diff suppressed because it is too large Load Diff

72
gscript/Database.js Normal file
View File

@@ -0,0 +1,72 @@
function db_() {
const id = PropertiesService.getScriptProperties().getProperty('DB_SPREADSHEET_ID');
if (!id) throw new Error('Databáze není inicializovaná. Spusť setupSystem().');
return SpreadsheetApp.openById(id);
}
function sh_(name) {
const s = db_().getSheetByName(name);
if (!s) throw new Error('Chybí list ' + name);
return s;
}
function rows_(name) {
const s = sh_(name);
const values = s.getDataRange().getValues();
if (values.length < 2) return [];
const headers = values[0].map(String);
return values.slice(1).filter(r => r.some(v => v !== '')).map(r => {
const o = {};
headers.forEach((h, i) => o[h] = r[i]);
return o;
});
}
function append_(name, obj) {
const s = sh_(name);
const headers = s.getRange(1,1,1,s.getLastColumn()).getValues()[0];
s.appendRow(headers.map(h => obj[h] !== undefined ? obj[h] : ''));
return obj;
}
function updateBy_(name, key, value, patch) {
const s = sh_(name);
const data = s.getDataRange().getValues();
if (!data.length) return false;
const headers = data[0].map(String);
const keyCol = headers.indexOf(key);
if (keyCol < 0) throw new Error('Sloupec ' + key + ' neexistuje v ' + name);
const row = data.slice(1).findIndex(r => String(r[keyCol]) === String(value));
if (row < 0) return false;
Object.keys(patch).forEach(k => {
const c = headers.indexOf(k);
if (c >= 0) s.getRange(row + 2, c + 1).setValue(patch[k]);
});
return true;
}
function findOne_(name, predicate) {
return rows_(name).find(predicate) || null;
}
function uuid_(prefix) {
return (prefix || 'ID') + '_' + Utilities.getUuid().replace(/-/g,'').slice(0,16).toUpperCase();
}
function now_() { return new Date(); }
function isoDate_(d) {
return Utilities.formatDate(new Date(d), CFG.TZ, 'yyyy-MM-dd');
}
function period_(d) {
return Utilities.formatDate(new Date(d), CFG.TZ, 'yyyy-MM');
}
function emailNorm_(email) {
return String(email || '').trim().toLowerCase();
}
function json_(v) {
try { return JSON.stringify(v); } catch(e) { return String(v); }
}

File diff suppressed because it is too large Load Diff

293
gscript/EmployeeService.js Normal file
View File

@@ -0,0 +1,293 @@
function createEmployee(token, data) {
const admin = requireUser_(token,['ADMIN','MANAGER']);
const email = emailNorm_(data.email);
if (!email) throw new Error('E-mail je povinný.');
if (findOne_(CFG.SHEETS.EMPLOYEES, r => emailNorm_(r.email) === email)) {
throw new Error('Zaměstnanec s tímto e-mailem už existuje.');
}
const role = String(data.role || 'EMPLOYEE');
if (!['EMPLOYEE','MANAGER','ACCOUNTANT','ADMIN'].includes(role)) {
throw new Error('Neplatná role.');
}
const hourlyRate = data.hourly_rate === '' || data.hourly_rate === undefined
? ''
: Number(data.hourly_rate);
if (hourlyRate !== '' && (isNaN(hourlyRate) || hourlyRate < 0)) {
throw new Error('Hodinová sazba není platná.');
}
const pin = String(data.terminal_pin || '').trim();
if (pin && !/^\d{4,6}$/.test(pin)) {
throw new Error('PIN musí mít 4 až 6 číslic.');
}
const emp = {
employee_id: uuid_('EMP'),
first_name: String(data.first_name || '').trim(),
last_name: String(data.last_name || '').trim(),
email: email,
phone: String(data.phone || '').trim(),
company_id: String(data.company_id || 'COMPANY_MAIN'),
location_id: String(data.location_id || 'LOCATION_MAIN'),
position: String(data.position || '').trim(),
employment_type: String(data.employment_type || '').trim(),
terminal_pin: pin,
start_date: data.start_date || isoDate_(now_()),
end_date: '',
active: true
};
if (!emp.first_name || !emp.last_name) {
throw new Error('Jméno a příjmení jsou povinné.');
}
append_(CFG.SHEETS.EMPLOYEES, emp);
const user = {
user_id: uuid_('USR'),
email: email,
role: role,
employee_id: emp.employee_id,
password_salt: '',
password_hash: '',
active: true,
created_at: now_(),
last_login: ''
};
append_(CFG.SHEETS.USERS, user);
append_(CFG.SHEETS.USER_COMPANIES, {
user_id: user.user_id,
company_id: emp.company_id
});
if (hourlyRate !== '') {
append_(CFG.SHEETS.PAY_RATES, {
pay_rate_id: uuid_('RATE'),
employee_id: emp.employee_id,
valid_from: emp.start_date,
valid_to: '',
hourly_rate: hourlyRate,
created_by: admin.user_id,
created_at: now_()
});
}
audit_(admin.user_id,'EMPLOYEE_CREATED','EMPLOYEE',emp.employee_id,'',emp);
requestActivation(email);
return {
ok: true,
employee_id: emp.employee_id
};
}
function listEmployees(token) {
requireUser_(
token,
['ADMIN','MANAGER','ACCOUNTANT']
);
const employees =
rows_(CFG.SHEETS.EMPLOYEES);
const users =
rows_(CFG.SHEETS.USERS);
const rates =
rows_(CFG.SHEETS.PAY_RATES);
return employees.map(function(employee) {
const user =
users.find(function(u) {
return String(u.employee_id) ===
String(employee.employee_id);
});
const employeeRates =
rates.filter(function(rate) {
return String(rate.employee_id) ===
String(employee.employee_id);
});
employeeRates.sort(function(a,b) {
const dateA =
a.valid_from
? new Date(a.valid_from).getTime()
: 0;
const dateB =
b.valid_from
? new Date(b.valid_from).getTime()
: 0;
return dateB - dateA;
});
let hourlyRate = 0;
if (employeeRates.length > 0) {
hourlyRate =
Number(
employeeRates[0].hourly_rate || 0
);
}
const active =
employee.active === true ||
String(employee.active)
.toUpperCase() === 'TRUE';
return {
employee_id:
String(employee.employee_id || ''),
first_name:
String(employee.first_name || ''),
last_name:
String(employee.last_name || ''),
name:
(
String(employee.first_name || '') +
' ' +
String(employee.last_name || '')
).trim(),
email:
String(employee.email || ''),
phone:
String(employee.phone || ''),
company_id:
String(employee.company_id || ''),
location_id:
String(employee.location_id || ''),
position:
String(employee.position || ''),
employment_type:
String(employee.employment_type || ''),
terminal_pin:
String(employee.terminal_pin || ''),
start_date:
employee.start_date
? String(employee.start_date)
: '',
end_date:
employee.end_date
? String(employee.end_date)
: '',
active:
active,
role:
user
? String(user.role || 'EMPLOYEE')
: 'EMPLOYEE',
hourly_rate:
hourlyRate
};
});
}
function setEmployeeActive(token, employeeId, active) {
const admin = requireUser_(token,['ADMIN']);
const emp = findOne_(
CFG.SHEETS.EMPLOYEES,
r => String(r.employee_id) === String(employeeId)
);
if (!emp) throw new Error('Zaměstnanec nebyl nalezen.');
const user = findOne_(
CFG.SHEETS.USERS,
r => String(r.employee_id) === String(employeeId)
);
updateBy_(
CFG.SHEETS.EMPLOYEES,
'employee_id',
employeeId,
{
active: !!active,
end_date: active ? '' : isoDate_(now_())
}
);
if (user) {
updateBy_(
CFG.SHEETS.USERS,
'user_id',
user.user_id,
{ active: !!active }
);
}
audit_(
admin.user_id,
'EMPLOYEE_ACTIVE_CHANGED',
'EMPLOYEE',
employeeId,
emp.active,
!!active
);
return { ok: true };
}
function getCompaniesAndLocations(token) {
requireUser_(token,['ADMIN','MANAGER']);
return {
companies: rows_(CFG.SHEETS.COMPANIES)
.filter(x =>
x.active === true ||
String(x.active).toUpperCase() === 'TRUE'
),
locations: rows_(CFG.SHEETS.LOCATIONS)
.filter(x =>
x.active === true ||
String(x.active).toUpperCase() === 'TRUE'
)
};
}

18711
gscript/Index.html Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,15 @@
/* ============================================================
EATME PORTÁL LOGIN PERFORMANCE SERVICE
============================================================ */
function loginWithUser(
email,
password
) {
return login(
email,
password
);
}

55
gscript/NewsService.js Normal file
View File

@@ -0,0 +1,55 @@
function getNewsForUser_(user, emp) {
const now = now_();
const memberships = rows_(CFG.SHEETS.USER_COMPANIES)
.filter(x => String(x.user_id) === String(user.user_id))
.map(x => String(x.company_id));
return rows_(CFG.SHEETS.NEWS)
.filter(n => n.active === true || String(n.active).toUpperCase() === 'TRUE')
.filter(n => !n.published_from || new Date(n.published_from) <= now)
.filter(n => !n.published_to || new Date(n.published_to) >= now)
.filter(n => !n.target_role || String(n.target_role) === 'ALL' || String(n.target_role) === String(user.role))
.filter(n => !n.company_id || memberships.includes(String(n.company_id)) || (emp && String(emp.company_id) === String(n.company_id)))
.filter(n => !n.location_id || (emp && String(emp.location_id) === String(n.location_id)))
.sort((a,b)=>new Date(b.created_at)-new Date(a.created_at))
.map(n => ({
news_id:n.news_id,title:n.title,content:n.content,category:n.category,
require_confirmation:n.require_confirmation,created_at:n.created_at
}));
}
function publishNews(token, data) {
const user = requireUser_(token,['ADMIN','MANAGER']);
const n = {
news_id:uuid_('NEWS'),
title:String(data.title || '').trim(),
content:String(data.content || '').trim(),
category:String(data.category || 'INFO'),
company_id:String(data.company_id || ''),
location_id:String(data.location_id || ''),
target_role:String(data.target_role || 'ALL'),
published_from:data.published_from || now_(),
published_to:data.published_to || '',
require_confirmation:!!data.require_confirmation,
active:true,
created_by:user.user_id,
created_at:now_()
};
if (!n.title) throw new Error('Titulek je povinný.');
append_(CFG.SHEETS.NEWS,n);
audit_(user.user_id,'NEWS_PUBLISHED','NEWS',n.news_id,'',n);
return {ok:true,news_id:n.news_id};
}
function confirmNews(token, newsId) {
const user = requireUser_(token);
let r = findOne_(CFG.SHEETS.NEWS_READS, x => String(x.news_id)===String(newsId) && String(x.user_id)===String(user.user_id));
if (r) {
updateBy_(CFG.SHEETS.NEWS_READS,'news_read_id',r.news_read_id,{read_at:r.read_at || now_(),confirmed_at:now_()});
} else {
append_(CFG.SHEETS.NEWS_READS,{
news_read_id:uuid_('NREAD'),news_id:newsId,user_id:user.user_id,read_at:now_(),confirmed_at:now_()
});
}
return {ok:true};
}

1575
gscript/NewsService_Roles.js Normal file

File diff suppressed because it is too large Load Diff

1554
gscript/PayrollService.js Normal file

File diff suppressed because it is too large Load Diff

377
gscript/Security.js Normal file
View File

@@ -0,0 +1,377 @@
/* ============================================================
EATME PORTÁL SECURITY HELPERS
============================================================ */
/* ============================================================
NÁHODNÝ AKTIVAČNÍ KÓD
============================================================ */
function randomCode_() {
return String(
Math.floor(
100000 +
Math.random() *
900000
)
);
}
/* ============================================================
SESSION TOKEN
============================================================ */
function randomToken_() {
return Utilities
.base64EncodeWebSafe(
Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256,
Utilities.getUuid() +
':' +
new Date().getTime() +
':' +
Math.random()
)
)
.replace(
/=+$/,
''
);
}
/* ============================================================
SHA-256
============================================================ */
function hashText_(text) {
const bytes =
Utilities.computeDigest(
Utilities.DigestAlgorithm.SHA_256,
String(
text
)
);
return bytes
.map(
function(byte) {
return (
'0' +
(
(
byte < 0
? byte + 256
: byte
)
.toString(
16
)
)
).slice(
-2
);
}
)
.join(
''
);
}
/* ============================================================
PASSWORD HASH
POZOR:
Toto je původní algoritmus.
Neměníme jej, aby fungovala současná hesla.
============================================================ */
function passwordHash_(
password,
salt
) {
const pepper =
PropertiesService
.getScriptProperties()
.getProperty(
'PASSWORD_PEPPER'
) ||
'';
let value =
String(
password
) +
'|' +
salt +
'|' +
pepper;
for (
let i = 0;
i < CFG.PASSWORD_ROUNDS;
i++
) {
value =
hashText_(
value +
'|' +
i
);
}
return value;
}
/* ============================================================
VALIDACE HESLA
============================================================ */
function validatePassword_(password) {
const value =
String(
password ||
''
);
if (
value.length <
10
) {
throw new Error(
'Heslo musí mít alespoň 10 znaků.'
);
}
if (
!/[A-Za-z]/.test(
value
) ||
!/[0-9]/.test(
value
)
) {
throw new Error(
'Heslo musí obsahovat písmeno a číslo.'
);
}
}
/* ============================================================
OVĚŘENÍ UŽIVATELE
Optimalizace:
už při každém requestu NEZAPISUJEME last_seen_at.
============================================================ */
function requireUser_(
token,
roles
) {
const tokenHash =
hashText_(
String(
token ||
''
)
);
const session =
findOne_(
CFG.SHEETS.SESSIONS,
function(row) {
return (
String(
row.token_hash
) ===
tokenHash &&
new Date(
row.expires_at
).getTime() >
Date.now()
);
}
);
if (
!session
) {
throw new Error(
'Přihlášení vypršelo. Přihlas se znovu.'
);
}
const user =
findOne_(
CFG.SHEETS.USERS,
function(row) {
return (
String(
row.user_id
) ===
String(
session.user_id
)
);
}
);
if (
!user ||
!(
user.active ===
true ||
String(
user.active
).toUpperCase() ===
'TRUE'
)
) {
throw new Error(
'Účet není aktivní.'
);
}
if (
roles &&
roles.length &&
!roles.includes(
String(
user.role
)
)
) {
throw new Error(
'Nemáš oprávnění.'
);
}
/*
* PŮVODNĚ ZDE BYLO:
*
* updateBy_(
* CFG.SHEETS.SESSIONS,
* 'session_id',
* session.session_id,
* {last_seen_at:now_()}
* );
*
* To způsobovalo zápis do Google Sheets při téměř
* každém kliknutí v aplikaci.
*/
return user;
}
/* ============================================================
AUDIT
============================================================ */
function audit_(
userId,
action,
entityType,
entityId,
oldValue,
newValue
) {
append_(
CFG.SHEETS.AUDIT_LOG,
{
audit_id:
uuid_(
'AUD'
),
user_id:
userId ||
'SYSTEM',
action:
action,
entity_type:
entityType ||
'',
entity_id:
entityId ||
'',
old_value:
json_(
oldValue
),
new_value:
json_(
newValue
),
created_at:
now_()
}
);
}

128
gscript/Setup.js Normal file
View File

@@ -0,0 +1,128 @@
function setupSystem() {
const props = PropertiesService.getScriptProperties();
let id = props.getProperty('DB_SPREADSHEET_ID');
let ss;
if (id) {
ss = SpreadsheetApp.openById(id);
} else {
ss = SpreadsheetApp.create('MeStaff Database');
props.setProperty('DB_SPREADSHEET_ID', ss.getId());
}
Object.keys(CFG.SHEETS).forEach(k => {
const name = CFG.SHEETS[k];
let s = ss.getSheetByName(name);
if (!s) s = ss.insertSheet(name);
const headers = HEADERS[k];
if (s.getLastRow() === 0) s.getRange(1,1,1,headers.length).setValues([headers]);
s.setFrozenRows(1);
s.getRange(1,1,1,headers.length).setFontWeight('bold');
});
const defaultSheet = ss.getSheetByName('Sheet1') || ss.getSheetByName('List1');
if (defaultSheet && Object.values(CFG.SHEETS).indexOf(defaultSheet.getName()) === -1) {
ss.deleteSheet(defaultSheet);
}
if (!props.getProperty('PASSWORD_PEPPER')) {
props.setProperty('PASSWORD_PEPPER', Utilities.getUuid() + Utilities.getUuid());
}
seedSettings_();
return {ok:true, spreadsheetId:ss.getId(), url:ss.getUrl()};
}
function seedSettings_() {
const current = rows_(CFG.SHEETS.SETTINGS);
const map = {};
current.forEach(r => map[r.key] = r.value);
const defaults = {
APP_NAME: 'MeStaff',
CURRENCY: 'CZK',
TIMEZONE: CFG.TZ,
NIGHT_START: '22:00',
NIGHT_END: '06:00'
};
Object.keys(defaults).forEach(k => {
if (map[k] === undefined) append_(CFG.SHEETS.SETTINGS, {key:k, value:defaults[k]});
});
}
function seedFirstAdmin(email, firstName, lastName) {
email = emailNorm_(email);
if (!email) throw new Error('Zadej e-mail.');
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
let company = findOne_(CFG.SHEETS.COMPANIES, r => r.active === true || String(r.active).toUpperCase() === 'TRUE');
if (!company) {
company = {company_id:'COMPANY_MAIN', name:'Hlavní společnost', ico:'', active:true};
append_(CFG.SHEETS.COMPANIES, company);
}
let loc = findOne_(CFG.SHEETS.LOCATIONS, r => String(r.company_id) === String(company.company_id));
if (!loc) {
loc = {location_id:'LOCATION_MAIN', company_id:company.company_id, name:'Hlavní provozovna', active:true};
append_(CFG.SHEETS.LOCATIONS, loc);
}
let emp = findOne_(CFG.SHEETS.EMPLOYEES, r => emailNorm_(r.email) === email);
if (!emp) {
emp = {
employee_id: uuid_('EMP'),
first_name:firstName || 'Admin',
last_name:lastName || '',
email,
company_id:company.company_id,
location_id:loc.location_id,
position:'Administrátor',
employment_type:'',
terminal_pin:'',
start_date:isoDate_(now_()),
end_date:'',
active:true
};
append_(CFG.SHEETS.EMPLOYEES, emp);
}
let user = findOne_(CFG.SHEETS.USERS, r => emailNorm_(r.email) === email);
if (!user) {
user = {
user_id:uuid_('USR'),
email,
role:'ADMIN',
employee_id:emp.employee_id,
password_salt:'',
password_hash:'',
active:true,
created_at:now_(),
last_login:''
};
append_(CFG.SHEETS.USERS, user);
}
return {ok:true, email, message:'Admin založen. Teď použij „Aktivovat účet“ na přihlašovací stránce.'};
} finally {
lock.releaseLock();
}
}
function createSystemTriggers() {
ScriptApp.getProjectTriggers().forEach(t => {
if (['nightlyMaintenance','monthlyMaintenance'].includes(t.getHandlerFunction())) {
ScriptApp.deleteTrigger(t);
}
});
ScriptApp.newTrigger('nightlyMaintenance')
.timeBased().everyDays(1).atHour(3).inTimezone(CFG.TZ).create();
ScriptApp.newTrigger('monthlyMaintenance')
.timeBased().onMonthDay(1).atHour(4).inTimezone(CFG.TZ).create();
return {ok:true};
}
function createMyAdmin() {
return seedFirstAdmin(
'filip.thurrigl@gmail.com',
'Philipp',
'Thürrigl'
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

10
gscript/appsscript.json Normal file
View File

@@ -0,0 +1,10 @@
{
"timeZone": "Europe/Prague",
"dependencies": {},
"exceptionLogging": "STACKDRIVER",
"runtimeVersion": "V8",
"webapp": {
"executeAs": "USER_DEPLOYING",
"access": "ANYONE_ANONYMOUS"
}
}