Add shift planning, month closure + payroll, and a dark modern redesign
Ports two modules from the friend's Google Apps Script build (kept as reference in gscript/) onto the TypeScript stack, rewritten cleanly against this app's own data model rather than copied 1:1: - Shift planning: weekly template (Sun-Thu evening, Fri/Sat two slots), lazy idempotent generation per period (no cron needed), employee signup/cancel with collision + capacity checks, admin calendar view with slot editing and manual assignment. - Month closure + payroll: employee confirms the month (blocked while a shift is still open), admin locks and finalizes pay (base hours * rate + tips/bonus/other), reopen to undo a premature lock, mark paid. Pay rates are versioned by date, defaulting the first-ever rate to apply retroactively to the employee's whole history. - A shift left open more than 12h (forgotten clock-out) is auto-closed at clock_in + 12h, checked lazily on read instead of a background job. - Full dark, sharp-edged modern restyle (theme.css replaces tui.css) with an amber accent, keeping every existing class name so no component logic needed to change. Backend test coverage (jest) for all three workflows: shift planning, closure/payroll, and the forgotten-clock-out auto-close.
This commit is contained in:
6
backend/jest.config.js
Normal file
6
backend/jest.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('jest').Config} */
|
||||||
|
module.exports = {
|
||||||
|
preset: "ts-jest",
|
||||||
|
testEnvironment: "node",
|
||||||
|
testMatch: ["<rootDir>/src/**/*.test.ts"],
|
||||||
|
};
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc --noEmit && node esbuild.config.js",
|
"build": "tsc --noEmit && node esbuild.config.js",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^11.9.1",
|
"better-sqlite3": "^11.9.1",
|
||||||
@@ -25,9 +26,12 @@
|
|||||||
"@types/cookie-parser": "^1.4.8",
|
"@types/cookie-parser": "^1.4.8",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.7",
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"esbuild": "^0.28.2",
|
"esbuild": "^0.28.2",
|
||||||
|
"jest": "^30.4.2",
|
||||||
|
"ts-jest": "^29.4.12",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,4 +27,74 @@ db.exec(`
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_attendance_employee_ts
|
CREATE INDEX IF NOT EXISTS idx_attendance_employee_ts
|
||||||
ON attendance_events (employee_id, ts);
|
ON attendance_events (employee_id, ts);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS shift_slots (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
start_time TEXT NOT NULL,
|
||||||
|
end_time TEXT NOT NULL,
|
||||||
|
capacity INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('open', 'full', 'closed')) DEFAULT 'open',
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
generated INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_shift_slots_key
|
||||||
|
ON shift_slots (date, start_time, end_time);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS shift_signups (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
slot_id INTEGER NOT NULL REFERENCES shift_slots(id),
|
||||||
|
employee_id INTEGER NOT NULL REFERENCES employees(id),
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('approved', 'cancelled')) DEFAULT 'approved',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
cancelled_at TEXT,
|
||||||
|
cancelled_by TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shift_signups_slot
|
||||||
|
ON shift_signups (slot_id, status);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shift_signups_employee
|
||||||
|
ON shift_signups (employee_id, status);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pay_rates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
employee_id INTEGER NOT NULL REFERENCES employees(id),
|
||||||
|
hourly_rate REAL NOT NULL,
|
||||||
|
valid_from TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
UNIQUE (employee_id, valid_from)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pay_rates_employee
|
||||||
|
ON pay_rates (employee_id, valid_from);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS month_closures (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
employee_id INTEGER NOT NULL REFERENCES employees(id),
|
||||||
|
period TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('waiting_employee', 'confirmed', 'locked')) DEFAULT 'waiting_employee',
|
||||||
|
employee_confirmed_at TEXT,
|
||||||
|
locked_at TEXT,
|
||||||
|
UNIQUE (employee_id, period)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS payroll (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
employee_id INTEGER NOT NULL REFERENCES employees(id),
|
||||||
|
period TEXT NOT NULL,
|
||||||
|
worked_minutes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
base_amount REAL NOT NULL DEFAULT 0,
|
||||||
|
tips_amount REAL NOT NULL DEFAULT 0,
|
||||||
|
bonus_amount REAL NOT NULL DEFAULT 0,
|
||||||
|
other_amount REAL NOT NULL DEFAULT 0,
|
||||||
|
final_amount REAL NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('draft', 'ready', 'paid')) DEFAULT 'draft',
|
||||||
|
payment_date TEXT,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
UNIQUE (employee_id, period)
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { env } from "./env";
|
|||||||
import { authRouter } from "./routes/auth";
|
import { authRouter } from "./routes/auth";
|
||||||
import { attendanceRouter } from "./routes/attendance";
|
import { attendanceRouter } from "./routes/attendance";
|
||||||
import { adminRouter } from "./routes/admin";
|
import { adminRouter } from "./routes/admin";
|
||||||
|
import { shiftsRouter } from "./routes/shifts";
|
||||||
|
import { closureRouter } from "./routes/closure";
|
||||||
|
|
||||||
const app = express();
|
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/auth", authRouter);
|
||||||
app.use("/api/attendance", attendanceRouter);
|
app.use("/api/attendance", attendanceRouter);
|
||||||
|
app.use("/api/shifts", shiftsRouter);
|
||||||
|
app.use("/api/closure", closureRouter);
|
||||||
app.use("/api/admin", adminRouter);
|
app.use("/api/admin", adminRouter);
|
||||||
|
|
||||||
// In the production Docker image the built frontend is copied next to this
|
// In the production Docker image the built frontend is copied next to this
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Router } from "express";
|
import { Router, type Request } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requireAdmin, requireAuth } from "../auth/middleware";
|
import { requireAdmin, requireAuth } from "../auth/middleware";
|
||||||
import {
|
import {
|
||||||
@@ -9,13 +9,52 @@ import {
|
|||||||
} from "../services/employees";
|
} from "../services/employees";
|
||||||
import { eventsInRange, summarize } from "../services/attendance";
|
import { eventsInRange, summarize } from "../services/attendance";
|
||||||
import { parseRange } from "../util/dateRange";
|
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();
|
export const adminRouter = Router();
|
||||||
adminRouter.use(requireAuth, requireAdmin);
|
adminRouter.use(requireAuth, requireAdmin);
|
||||||
|
|
||||||
adminRouter.get("/employees", (_req, res) => {
|
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({
|
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.setHeader("Content-Disposition", `attachment; filename="${safeName}_${period}.csv"`);
|
||||||
res.send("" + csv); // BOM so Excel opens the Czech diacritics as UTF-8
|
res.send("" + csv); // BOM so Excel opens the Czech diacritics as UTF-8
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ---------- shift planning ---------- */
|
||||||
|
|
||||||
|
function periodParam(req: Request): string {
|
||||||
|
const period = req.query.period;
|
||||||
|
if (typeof period === "string" && /^\d{4}-\d{2}$/.test(period)) return period;
|
||||||
|
const now = new Date();
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
adminRouter.get("/shifts", (req, res) => {
|
||||||
|
res.json({ slots: listAdminSlotsForPeriod(periodParam(req)) });
|
||||||
|
});
|
||||||
|
|
||||||
|
const slotSchema = z.object({
|
||||||
|
date: z.string(),
|
||||||
|
start_time: z.string(),
|
||||||
|
end_time: z.string(),
|
||||||
|
capacity: z.number(),
|
||||||
|
note: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.post("/shifts", (req, res) => {
|
||||||
|
const parsed = slotSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: "Neplatná data směny." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const id = createSlot(parsed.data);
|
||||||
|
res.status(201).json({ id });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: err instanceof Error ? err.message : "Vytvoření se nezdařilo" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.patch("/shifts/:slotId", (req, res) => {
|
||||||
|
const slotId = Number(req.params.slotId);
|
||||||
|
const parsed = slotSchema.safeParse(req.body);
|
||||||
|
if (!Number.isInteger(slotId) || !parsed.success) {
|
||||||
|
res.status(400).json({ error: "Neplatná data směny." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
updateSlot(slotId, parsed.data);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: err instanceof Error ? err.message : "Úprava se nezdařila" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.post("/shifts/:slotId/assign", (req, res) => {
|
||||||
|
const slotId = Number(req.params.slotId);
|
||||||
|
const employeeId = Number(req.body?.employeeId);
|
||||||
|
if (!Number.isInteger(slotId) || !Number.isInteger(employeeId)) {
|
||||||
|
res.status(400).json({ error: "Neplatný požadavek." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
assignEmployeeToSlot(slotId, employeeId);
|
||||||
|
res.status(201).json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: err instanceof Error ? err.message : "Přiřazení se nezdařilo" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.delete("/shifts/:slotId/assign/:employeeId", (req, res) => {
|
||||||
|
const slotId = Number(req.params.slotId);
|
||||||
|
const employeeId = Number(req.params.employeeId);
|
||||||
|
if (!Number.isInteger(slotId) || !Number.isInteger(employeeId)) {
|
||||||
|
res.status(400).json({ error: "Neplatný požadavek." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
removeEmployeeFromSlot(slotId, employeeId, req.user!.email);
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: err instanceof Error ? err.message : "Odebrání se nezdařilo" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---------- payroll ---------- */
|
||||||
|
|
||||||
|
adminRouter.get("/payroll", (req, res) => {
|
||||||
|
res.json({ rows: getPeriodOverview(periodParam(req)) });
|
||||||
|
});
|
||||||
|
|
||||||
|
const adjustmentsSchema = z.object({
|
||||||
|
period: z.string().regex(/^\d{4}-\d{2}$/),
|
||||||
|
tips_amount: z.number(),
|
||||||
|
bonus_amount: z.number(),
|
||||||
|
other_amount: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.post("/payroll/:employeeId/adjustments", (req, res) => {
|
||||||
|
const employeeId = Number(req.params.employeeId);
|
||||||
|
const parsed = adjustmentsSchema.safeParse(req.body);
|
||||||
|
if (!Number.isInteger(employeeId) || !parsed.success) {
|
||||||
|
res.status(400).json({ error: "Neplatná data." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payroll = saveDraftAdjustments(employeeId, parsed.data.period, {
|
||||||
|
tips_amount: parsed.data.tips_amount,
|
||||||
|
bonus_amount: parsed.data.bonus_amount,
|
||||||
|
other_amount: parsed.data.other_amount,
|
||||||
|
});
|
||||||
|
res.json({ payroll });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: err instanceof Error ? err.message : "Uložení se nezdařilo" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const periodBodySchema = z.object({ period: z.string().regex(/^\d{4}-\d{2}$/) });
|
||||||
|
|
||||||
|
adminRouter.post("/payroll/:employeeId/lock", (req, res) => {
|
||||||
|
const employeeId = Number(req.params.employeeId);
|
||||||
|
const parsed = periodBodySchema.safeParse(req.body);
|
||||||
|
if (!Number.isInteger(employeeId) || !parsed.success) {
|
||||||
|
res.status(400).json({ error: "Neplatná data." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payroll = lockAndFinalizePayroll(employeeId, parsed.data.period);
|
||||||
|
res.json({ payroll });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: err instanceof Error ? err.message : "Uzamčení se nezdařilo" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.post("/payroll/:employeeId/reopen", (req, res) => {
|
||||||
|
const employeeId = Number(req.params.employeeId);
|
||||||
|
const parsed = periodBodySchema.safeParse(req.body);
|
||||||
|
if (!Number.isInteger(employeeId) || !parsed.success) {
|
||||||
|
res.status(400).json({ error: "Neplatná data." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payroll = reopenPayroll(employeeId, parsed.data.period);
|
||||||
|
res.json({ payroll });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: err instanceof Error ? err.message : "Odemknutí se nezdařilo" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.post("/payroll/:employeeId/paid", (req, res) => {
|
||||||
|
const employeeId = Number(req.params.employeeId);
|
||||||
|
const parsed = periodBodySchema.safeParse(req.body);
|
||||||
|
if (!Number.isInteger(employeeId) || !parsed.success) {
|
||||||
|
res.status(400).json({ error: "Neplatná data." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payroll = markPaid(employeeId, parsed.data.period);
|
||||||
|
res.json({ payroll });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: err instanceof Error ? err.message : "Označení se nezdařilo" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRouter.get("/payroll/export", (req, res) => {
|
||||||
|
const period = periodParam(req);
|
||||||
|
const csv = payrollToCsv(getPeriodOverview(period));
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="mzdy_${period}.csv"`);
|
||||||
|
res.send("" + csv);
|
||||||
|
});
|
||||||
|
|||||||
37
backend/src/routes/closure.ts
Normal file
37
backend/src/routes/closure.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Router, type Request } from "express";
|
||||||
|
import { requireAuth, requireEmployee } from "../auth/middleware";
|
||||||
|
import { confirmClosure, getClosureSummary, getOrCreateClosure } from "../services/closure";
|
||||||
|
|
||||||
|
export const closureRouter = Router();
|
||||||
|
closureRouter.use(requireAuth, requireEmployee);
|
||||||
|
|
||||||
|
function periodParam(req: Request): string {
|
||||||
|
const period = req.query.period;
|
||||||
|
if (typeof period === "string" && /^\d{4}-\d{2}$/.test(period)) return period;
|
||||||
|
const now = new Date();
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
closureRouter.get("/", (req, res) => {
|
||||||
|
const employeeId = req.user!.employeeId!;
|
||||||
|
const period = periodParam(req);
|
||||||
|
res.json({
|
||||||
|
closure: getOrCreateClosure(employeeId, period),
|
||||||
|
summary: getClosureSummary(employeeId, period),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
closureRouter.post("/confirm", (req, res) => {
|
||||||
|
const employeeId = req.user!.employeeId!;
|
||||||
|
const period = req.body?.period;
|
||||||
|
if (typeof period !== "string" || !/^\d{4}-\d{2}$/.test(period)) {
|
||||||
|
res.status(400).json({ error: "Neplatné období." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
confirmClosure(employeeId, period);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: err instanceof Error ? err.message : "Potvrzení se nezdařilo" });
|
||||||
|
}
|
||||||
|
});
|
||||||
51
backend/src/routes/shifts.ts
Normal file
51
backend/src/routes/shifts.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Router, type Request } from "express";
|
||||||
|
import { requireAuth, requireEmployee } from "../auth/middleware";
|
||||||
|
import { cancelMySignup, getAvailableSlotsForEmployee, getMySlotsForEmployee, signupForSlot } from "../services/shiftPlanning";
|
||||||
|
|
||||||
|
export const shiftsRouter = Router();
|
||||||
|
shiftsRouter.use(requireAuth, requireEmployee);
|
||||||
|
|
||||||
|
function periodParam(req: Request): string {
|
||||||
|
const period = req.query.period;
|
||||||
|
if (typeof period === "string" && /^\d{4}-\d{2}$/.test(period)) return period;
|
||||||
|
const now = new Date();
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
shiftsRouter.get("/available", (req, res) => {
|
||||||
|
const employeeId = req.user!.employeeId!;
|
||||||
|
res.json({ slots: getAvailableSlotsForEmployee(employeeId, periodParam(req)) });
|
||||||
|
});
|
||||||
|
|
||||||
|
shiftsRouter.get("/mine", (req, res) => {
|
||||||
|
const employeeId = req.user!.employeeId!;
|
||||||
|
res.json({ slots: getMySlotsForEmployee(employeeId, periodParam(req)) });
|
||||||
|
});
|
||||||
|
|
||||||
|
shiftsRouter.post("/:slotId/signup", (req, res) => {
|
||||||
|
const slotId = Number(req.params.slotId);
|
||||||
|
if (!Number.isInteger(slotId)) {
|
||||||
|
res.status(400).json({ error: "Invalid slot id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
signupForSlot(req.user!.employeeId!, slotId);
|
||||||
|
res.status(201).json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: err instanceof Error ? err.message : "Přihlášení se nezdařilo" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
shiftsRouter.delete("/:slotId/signup", (req, res) => {
|
||||||
|
const slotId = Number(req.params.slotId);
|
||||||
|
if (!Number.isInteger(slotId)) {
|
||||||
|
res.status(400).json({ error: "Invalid slot id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
cancelMySignup(req.user!.employeeId!, slotId, req.user!.email);
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ error: err instanceof Error ? err.message : "Zrušení se nezdařilo" });
|
||||||
|
}
|
||||||
|
});
|
||||||
74
backend/src/services/attendance.test.ts
Normal file
74
backend/src/services/attendance.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
// Env + a throwaway DB must be in place before ../db is first imported (it
|
||||||
|
// creates the schema on import), so this runs ahead of the other imports.
|
||||||
|
const dbDir = fs.mkdtempSync(path.join(os.tmpdir(), "eatme-attendance-test-"));
|
||||||
|
process.env.DB_PATH = path.join(dbDir, "test.db");
|
||||||
|
process.env.GOOGLE_CLIENT_ID = "test-client-id";
|
||||||
|
process.env.ADMIN_EMAILS = "admin@example.com";
|
||||||
|
process.env.JWT_SECRET = "test-secret";
|
||||||
|
|
||||||
|
import { db } from "../db";
|
||||||
|
import { eventsInRange, getLiveStatus, summarize } from "./attendance";
|
||||||
|
|
||||||
|
function makeEmployee(email: string): number {
|
||||||
|
const result = db.prepare("INSERT INTO employees (email, name) VALUES (?, ?)").run(email, email);
|
||||||
|
return Number(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertEvent(employeeId: number, type: string, ts: string): void {
|
||||||
|
db.prepare("INSERT INTO attendance_events (employee_id, type, ts) VALUES (?, ?, ?)").run(employeeId, type, ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("forgotten clock-out is capped automatically at 12h", () => {
|
||||||
|
test("a shift open more than 12h is auto-closed at clock_in + 12h", () => {
|
||||||
|
const alice = makeEmployee("alice@example.com");
|
||||||
|
const clockIn = new Date(Date.now() - 20 * 60 * 60 * 1000).toISOString(); // 20h ago
|
||||||
|
insertEvent(alice, "clock_in", clockIn);
|
||||||
|
|
||||||
|
expect(getLiveStatus(alice)).toBe("clocked_out");
|
||||||
|
|
||||||
|
const events = db
|
||||||
|
.prepare("SELECT * FROM attendance_events WHERE employee_id = ? ORDER BY ts ASC")
|
||||||
|
.all(alice) as { type: string; ts: string }[];
|
||||||
|
expect(events.map((e) => e.type)).toEqual(["clock_in", "clock_out"]);
|
||||||
|
expect(new Date(events[1].ts).getTime() - new Date(clockIn).getTime()).toBe(12 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a shift left open on a break is also closed cleanly (break_end inserted first)", () => {
|
||||||
|
const bob = makeEmployee("bob@example.com");
|
||||||
|
const clockIn = new Date(Date.now() - 15 * 60 * 60 * 1000).toISOString();
|
||||||
|
const breakStart = new Date(Date.now() - 14 * 60 * 60 * 1000).toISOString();
|
||||||
|
insertEvent(bob, "clock_in", clockIn);
|
||||||
|
insertEvent(bob, "break_start", breakStart);
|
||||||
|
|
||||||
|
expect(getLiveStatus(bob)).toBe("clocked_out");
|
||||||
|
|
||||||
|
const events = db
|
||||||
|
.prepare("SELECT * FROM attendance_events WHERE employee_id = ? ORDER BY ts ASC")
|
||||||
|
.all(bob) as { type: string }[];
|
||||||
|
expect(events.map((e) => e.type)).toEqual(["clock_in", "break_start", "break_end", "clock_out"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a shift within 12h is left untouched", () => {
|
||||||
|
const carol = makeEmployee("carol@example.com");
|
||||||
|
const clockIn = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
|
||||||
|
insertEvent(carol, "clock_in", clockIn);
|
||||||
|
|
||||||
|
expect(getLiveStatus(carol)).toBe("working");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closure/stats reads (eventsInRange) also self-heal a stale shift", () => {
|
||||||
|
const dave = makeEmployee("dave@example.com");
|
||||||
|
const clockIn = new Date(Date.now() - 30 * 60 * 60 * 1000).toISOString(); // 30h ago
|
||||||
|
insertEvent(dave, "clock_in", clockIn);
|
||||||
|
|
||||||
|
const events = eventsInRange(dave, "2000-01-01T00:00:00.000Z", "2100-01-01T00:00:00.000Z");
|
||||||
|
const { sessions } = summarize(events, new Date());
|
||||||
|
expect(sessions).toHaveLength(1);
|
||||||
|
expect(sessions[0].open).toBe(false);
|
||||||
|
expect(sessions[0].workedMs).toBe(12 * 60 * 60 * 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,8 @@ function statusAfter(lastType: EventType | null): LiveStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_SHIFT_MS = 12 * 60 * 60 * 1000;
|
||||||
|
|
||||||
export function getLastEvent(employeeId: number): AttendanceEvent | undefined {
|
export function getLastEvent(employeeId: number): AttendanceEvent | undefined {
|
||||||
return db
|
return db
|
||||||
.prepare<[number], AttendanceEvent>(
|
.prepare<[number], AttendanceEvent>(
|
||||||
@@ -31,10 +33,42 @@ export function getLastEvent(employeeId: number): AttendanceEvent | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getLiveStatus(employeeId: number): LiveStatus {
|
export function getLiveStatus(employeeId: number): LiveStatus {
|
||||||
|
autoCloseStaleShift(employeeId);
|
||||||
const last = getLastEvent(employeeId);
|
const last = getLastEvent(employeeId);
|
||||||
return statusAfter(last?.type ?? null);
|
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 {
|
export class InvalidTransitionError extends Error {
|
||||||
constructor(public readonly current: LiveStatus, public readonly attempted: EventType) {
|
constructor(public readonly current: LiveStatus, public readonly attempted: EventType) {
|
||||||
super(`Cannot record "${attempted}" while status is "${current}"`);
|
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[] {
|
export function eventsInRange(employeeId: number, fromIso: string, toIso: string): AttendanceEvent[] {
|
||||||
|
autoCloseStaleShift(employeeId);
|
||||||
return db
|
return db
|
||||||
.prepare<[number, string, string], AttendanceEvent>(
|
.prepare<[number, string, string], AttendanceEvent>(
|
||||||
`SELECT * FROM attendance_events
|
`SELECT * FROM attendance_events
|
||||||
|
|||||||
88
backend/src/services/closure.ts
Normal file
88
backend/src/services/closure.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { db } from "../db";
|
||||||
|
import type { MonthClosure } from "../types";
|
||||||
|
import { eventsInRange, summarize } from "./attendance";
|
||||||
|
import { getRateAt } from "./payRates";
|
||||||
|
import { parseRange } from "../util/dateRange";
|
||||||
|
|
||||||
|
export interface ClosureSummary {
|
||||||
|
worked_minutes: number;
|
||||||
|
shift_count: number;
|
||||||
|
earned_estimate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClosureSummary(employeeId: number, period: string): ClosureSummary {
|
||||||
|
const { fromIso, toIso } = parseRange({ month: period });
|
||||||
|
const events = eventsInRange(employeeId, fromIso, toIso);
|
||||||
|
const { sessions } = summarize(events, new Date());
|
||||||
|
|
||||||
|
let workedMs = 0;
|
||||||
|
let earned = 0;
|
||||||
|
let shiftCount = 0;
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
workedMs += session.workedMs;
|
||||||
|
if (session.workedMs > 0) shiftCount++;
|
||||||
|
const rate = getRateAt(employeeId, session.clockIn.slice(0, 10));
|
||||||
|
earned += (session.workedMs / 3_600_000) * rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
worked_minutes: Math.round(workedMs / 60_000),
|
||||||
|
shift_count: shiftCount,
|
||||||
|
earned_estimate: Math.round(earned),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasOpenSessionInPeriod(employeeId: number, period: string): boolean {
|
||||||
|
const { fromIso, toIso } = parseRange({ month: period });
|
||||||
|
const events = eventsInRange(employeeId, fromIso, toIso);
|
||||||
|
const { sessions } = summarize(events, new Date());
|
||||||
|
return sessions.some((s) => s.open);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findClosure(employeeId: number, period: string): MonthClosure | undefined {
|
||||||
|
return db
|
||||||
|
.prepare<[number, string], MonthClosure>("SELECT * FROM month_closures WHERE employee_id = ? AND period = ?")
|
||||||
|
.get(employeeId, period);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrCreateClosure(employeeId: number, period: string): MonthClosure {
|
||||||
|
const existing = findClosure(employeeId, period);
|
||||||
|
if (existing) return existing;
|
||||||
|
db.prepare("INSERT INTO month_closures (employee_id, period, status) VALUES (?, ?, 'waiting_employee')").run(
|
||||||
|
employeeId,
|
||||||
|
period
|
||||||
|
);
|
||||||
|
return findClosure(employeeId, period)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmClosure(employeeId: number, period: string): void {
|
||||||
|
const closure = getOrCreateClosure(employeeId, period);
|
||||||
|
if (closure.status === "locked") {
|
||||||
|
throw new Error("Docházka je už uzamčena.");
|
||||||
|
}
|
||||||
|
if (closure.status === "confirmed") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasOpenSessionInPeriod(employeeId, period)) {
|
||||||
|
throw new Error("V tomto měsíci máš stále otevřenou směnu.");
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE month_closures SET status = 'confirmed', employee_confirmed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
|
||||||
|
).run(closure.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lockClosure(employeeId: number, period: string): void {
|
||||||
|
const closure = findClosure(employeeId, period);
|
||||||
|
if (!closure || closure.status !== "confirmed") {
|
||||||
|
throw new Error("Docházku zatím nepotvrdil zaměstnanec.");
|
||||||
|
}
|
||||||
|
if (hasOpenSessionInPeriod(employeeId, period)) {
|
||||||
|
throw new Error("Zaměstnanec má stále otevřenou směnu.");
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE month_closures SET status = 'locked', locked_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
|
||||||
|
).run(closure.id);
|
||||||
|
}
|
||||||
52
backend/src/services/payRates.ts
Normal file
52
backend/src/services/payRates.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { db } from "../db";
|
||||||
|
import type { PayRate } from "../types";
|
||||||
|
import { todayIso } from "../util/date";
|
||||||
|
|
||||||
|
/** The rate is the most recent one with valid_from <= the given date — a new rate implicitly ends the previous one. */
|
||||||
|
export function getRateAt(employeeId: number, dateOnly: string): number {
|
||||||
|
const row = db
|
||||||
|
.prepare<[number, string], { hourly_rate: number }>(
|
||||||
|
"SELECT hourly_rate FROM pay_rates WHERE employee_id = ? AND valid_from <= ? ORDER BY valid_from DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
.get(employeeId, dateOnly);
|
||||||
|
return row?.hourly_rate ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentRate(employeeId: number): number {
|
||||||
|
return getRateAt(employeeId, todayIso());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRate(employeeId: number, hourlyRate: number, validFrom?: string): PayRate {
|
||||||
|
if (!Number.isFinite(hourlyRate) || hourlyRate < 0) {
|
||||||
|
throw new Error("Sazba musí být nezáporné číslo.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = validFrom && /^\d{4}-\d{2}-\d{2}$/.test(validFrom) ? validFrom : defaultValidFrom(employeeId);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO pay_rates (employee_id, hourly_rate, valid_from) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT (employee_id, valid_from) DO UPDATE SET hourly_rate = excluded.hourly_rate`
|
||||||
|
).run(employeeId, hourlyRate, from);
|
||||||
|
|
||||||
|
return db
|
||||||
|
.prepare<[number, string], PayRate>("SELECT * FROM pay_rates WHERE employee_id = ? AND valid_from = ?")
|
||||||
|
.get(employeeId, from)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first rate ever set for an employee applies retroactively to their whole history
|
||||||
|
* (back to when they were added), so hours worked before the admin got around to setting
|
||||||
|
* a rate still get paid out. Later raises only take effect from today.
|
||||||
|
*/
|
||||||
|
function defaultValidFrom(employeeId: number): string {
|
||||||
|
const hasExisting = db
|
||||||
|
.prepare<[number], { n: number }>("SELECT COUNT(*) as n FROM pay_rates WHERE employee_id = ?")
|
||||||
|
.get(employeeId)!.n;
|
||||||
|
if (hasExisting > 0) {
|
||||||
|
return todayIso();
|
||||||
|
}
|
||||||
|
const employee = db
|
||||||
|
.prepare<[number], { created_at: string }>("SELECT created_at FROM employees WHERE id = ?")
|
||||||
|
.get(employeeId);
|
||||||
|
return employee ? employee.created_at.slice(0, 10) : todayIso();
|
||||||
|
}
|
||||||
157
backend/src/services/payroll.test.ts
Normal file
157
backend/src/services/payroll.test.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
// Env + a throwaway DB must be in place before ../db is first imported (it
|
||||||
|
// creates the schema on import), so this runs ahead of the other imports.
|
||||||
|
const dbDir = fs.mkdtempSync(path.join(os.tmpdir(), "eatme-payroll-test-"));
|
||||||
|
process.env.DB_PATH = path.join(dbDir, "test.db");
|
||||||
|
process.env.GOOGLE_CLIENT_ID = "test-client-id";
|
||||||
|
process.env.ADMIN_EMAILS = "admin@example.com";
|
||||||
|
process.env.JWT_SECRET = "test-secret";
|
||||||
|
|
||||||
|
import { db } from "../db";
|
||||||
|
import { setRate } from "./payRates";
|
||||||
|
import { confirmClosure, findClosure, getClosureSummary } from "./closure";
|
||||||
|
import {
|
||||||
|
findPayroll,
|
||||||
|
getPeriodOverview,
|
||||||
|
lockAndFinalizePayroll,
|
||||||
|
markPaid,
|
||||||
|
reopenPayroll,
|
||||||
|
saveDraftAdjustments,
|
||||||
|
} from "./payroll";
|
||||||
|
|
||||||
|
const PERIOD = "2026-06";
|
||||||
|
|
||||||
|
function makeEmployee(email: string): number {
|
||||||
|
const result = db.prepare("INSERT INTO employees (email, name) VALUES (?, ?)").run(email, email);
|
||||||
|
return Number(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** created_at needs to predate the shifts in these tests, to simulate "employee existed before the rate was ever set". */
|
||||||
|
function makeEmployeeWithCreatedAt(email: string, createdAt: string): number {
|
||||||
|
const result = db
|
||||||
|
.prepare("INSERT INTO employees (email, name, created_at) VALUES (?, ?, ?)")
|
||||||
|
.run(email, email, createdAt);
|
||||||
|
return Number(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertEvent(employeeId: number, type: "clock_in" | "clock_out", ts: string): void {
|
||||||
|
db.prepare("INSERT INTO attendance_events (employee_id, type, ts) VALUES (?, ?, ?)").run(employeeId, type, ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("closure + payroll workflow", () => {
|
||||||
|
test("summary is computed from worked sessions using the rate active on the shift's date", () => {
|
||||||
|
const eve = makeEmployee("eve@example.com");
|
||||||
|
setRate(eve, 200, "2026-01-01");
|
||||||
|
|
||||||
|
insertEvent(eve, "clock_in", "2026-06-05T08:00:00.000Z");
|
||||||
|
insertEvent(eve, "clock_out", "2026-06-05T12:00:00.000Z"); // 4h shift
|
||||||
|
|
||||||
|
const summary = getClosureSummary(eve, PERIOD);
|
||||||
|
expect(summary.worked_minutes).toBe(240);
|
||||||
|
expect(summary.shift_count).toBe(1);
|
||||||
|
expect(summary.earned_estimate).toBe(800); // 4h * 200 Kč
|
||||||
|
});
|
||||||
|
|
||||||
|
test("employee cannot confirm the month while a shift is still open, and confirming is idempotent", () => {
|
||||||
|
// Uses the real current month/time (not the fixed PERIOD) because a genuinely "still open"
|
||||||
|
// shift only makes sense within the last 12h — anything older gets auto-closed (see
|
||||||
|
// attendance.test.ts), which would make this scenario impossible to set up otherwise.
|
||||||
|
const currentPeriod = new Date().toISOString().slice(0, 7);
|
||||||
|
const frank = makeEmployee("frank@example.com");
|
||||||
|
setRate(frank, 150, "2000-01-01");
|
||||||
|
insertEvent(frank, "clock_in", new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString()); // still open
|
||||||
|
|
||||||
|
expect(() => confirmClosure(frank, currentPeriod)).toThrow("otevřenou směnu");
|
||||||
|
|
||||||
|
insertEvent(frank, "clock_out", new Date().toISOString());
|
||||||
|
confirmClosure(frank, currentPeriod);
|
||||||
|
expect(findClosure(frank, currentPeriod)!.status).toBe("confirmed");
|
||||||
|
|
||||||
|
confirmClosure(frank, currentPeriod); // already confirmed -> no-op, not an error
|
||||||
|
expect(findClosure(frank, currentPeriod)!.status).toBe("confirmed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admin can only adjust tips/bonus/other after employee confirmation, and locking freezes the numbers", () => {
|
||||||
|
const gina = makeEmployee("gina@example.com");
|
||||||
|
setRate(gina, 200, "2026-01-01");
|
||||||
|
insertEvent(gina, "clock_in", "2026-06-01T08:00:00.000Z");
|
||||||
|
insertEvent(gina, "clock_out", "2026-06-01T16:00:00.000Z"); // 8h -> base 1600
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
saveDraftAdjustments(gina, PERIOD, { tips_amount: 100, bonus_amount: 0, other_amount: 0 })
|
||||||
|
).toThrow("po potvrzení docházky");
|
||||||
|
|
||||||
|
confirmClosure(gina, PERIOD);
|
||||||
|
|
||||||
|
const draft = saveDraftAdjustments(gina, PERIOD, { tips_amount: 300, bonus_amount: 200, other_amount: -50 });
|
||||||
|
expect(draft.status).toBe("draft");
|
||||||
|
expect(draft.final_amount).toBe(1600 + 300 + 200 - 50);
|
||||||
|
|
||||||
|
const locked = lockAndFinalizePayroll(gina, PERIOD);
|
||||||
|
expect(locked.status).toBe("ready");
|
||||||
|
expect(locked.final_amount).toBe(draft.final_amount);
|
||||||
|
expect(findClosure(gina, PERIOD)!.status).toBe("locked");
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
saveDraftAdjustments(gina, PERIOD, { tips_amount: 999, bonus_amount: 0, other_amount: 0 })
|
||||||
|
).toThrow("uzamčen");
|
||||||
|
expect(() => lockAndFinalizePayroll(gina, PERIOD)).toThrow("nepotvrdil zaměstnanec");
|
||||||
|
|
||||||
|
const paid = markPaid(gina, PERIOD);
|
||||||
|
expect(paid.status).toBe("paid");
|
||||||
|
expect(paid.payment_date).toBeTruthy();
|
||||||
|
|
||||||
|
expect(() => markPaid(gina, PERIOD)).toThrow("nejdřív uzamčena");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the first rate set for an employee applies retroactively; later raises only apply from today", () => {
|
||||||
|
const iris = makeEmployeeWithCreatedAt("iris@example.com", "2026-05-01T00:00:00.000Z");
|
||||||
|
insertEvent(iris, "clock_in", "2026-06-03T08:00:00.000Z");
|
||||||
|
insertEvent(iris, "clock_out", "2026-06-03T12:00:00.000Z"); // 4h shift, before any rate exists
|
||||||
|
|
||||||
|
expect(getClosureSummary(iris, PERIOD).earned_estimate).toBe(0);
|
||||||
|
|
||||||
|
setRate(iris, 200); // no valid_from given -> must cover the already-worked June shift
|
||||||
|
expect(getClosureSummary(iris, PERIOD).earned_estimate).toBe(800); // 4h * 200
|
||||||
|
|
||||||
|
setRate(iris, 300); // a raise -> only from today, not retroactive
|
||||||
|
expect(getClosureSummary(iris, PERIOD).earned_estimate).toBe(800); // June shift unaffected
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reopen undoes a premature lock so the numbers can be corrected, but not once already paid", () => {
|
||||||
|
const jack = makeEmployeeWithCreatedAt("jack@example.com", "2026-05-01T00:00:00.000Z");
|
||||||
|
insertEvent(jack, "clock_in", "2026-06-04T08:00:00.000Z");
|
||||||
|
insertEvent(jack, "clock_out", "2026-06-04T12:00:00.000Z"); // 4h shift
|
||||||
|
|
||||||
|
confirmClosure(jack, PERIOD);
|
||||||
|
const locked = lockAndFinalizePayroll(jack, PERIOD); // locked before any rate was set
|
||||||
|
expect(locked.base_amount).toBe(0);
|
||||||
|
|
||||||
|
reopenPayroll(jack, PERIOD);
|
||||||
|
expect(findClosure(jack, PERIOD)!.status).toBe("confirmed");
|
||||||
|
expect(findPayroll(jack, PERIOD)!.status).toBe("draft");
|
||||||
|
|
||||||
|
setRate(jack, 150);
|
||||||
|
const relocked = lockAndFinalizePayroll(jack, PERIOD);
|
||||||
|
expect(relocked.base_amount).toBe(600); // 4h * 150, now correct
|
||||||
|
|
||||||
|
markPaid(jack, PERIOD);
|
||||||
|
expect(() => reopenPayroll(jack, PERIOD)).toThrow("Lze odemknout");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admin overview shows live status for employees who haven't touched their closure yet", () => {
|
||||||
|
const henry = makeEmployee("henry@example.com");
|
||||||
|
setRate(henry, 100, "2026-01-01");
|
||||||
|
insertEvent(henry, "clock_in", "2026-06-02T08:00:00.000Z");
|
||||||
|
insertEvent(henry, "clock_out", "2026-06-02T10:00:00.000Z"); // 2h -> base 200
|
||||||
|
|
||||||
|
const row = getPeriodOverview(PERIOD).find((r) => r.employee_id === henry)!;
|
||||||
|
expect(row.closure_status).toBe("waiting_employee");
|
||||||
|
expect(row.payroll_status).toBeNull();
|
||||||
|
expect(row.worked_minutes).toBe(120);
|
||||||
|
expect(row.base_amount).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
188
backend/src/services/payroll.ts
Normal file
188
backend/src/services/payroll.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { db } from "../db";
|
||||||
|
import type { ClosureStatus, Payroll, PayrollStatus } from "../types";
|
||||||
|
import { findClosure, getClosureSummary, lockClosure } from "./closure";
|
||||||
|
import { listEmployees } from "./employees";
|
||||||
|
import { todayIso } from "../util/date";
|
||||||
|
|
||||||
|
export interface PayrollAdjustments {
|
||||||
|
tips_amount: number;
|
||||||
|
bonus_amount: number;
|
||||||
|
other_amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findPayroll(employeeId: number, period: string): Payroll | undefined {
|
||||||
|
return db
|
||||||
|
.prepare<[number, string], Payroll>("SELECT * FROM payroll WHERE employee_id = ? AND period = ?")
|
||||||
|
.get(employeeId, period);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manager-side draft (tips/bonus/other) prepared before the month is locked. Frozen once locked. */
|
||||||
|
export function saveDraftAdjustments(employeeId: number, period: string, adjustments: PayrollAdjustments): Payroll {
|
||||||
|
const closure = findClosure(employeeId, period);
|
||||||
|
if (!closure || closure.status === "waiting_employee") {
|
||||||
|
throw new Error("Odměny lze zadat až po potvrzení docházky zaměstnancem.");
|
||||||
|
}
|
||||||
|
const existing = findPayroll(employeeId, period);
|
||||||
|
if (existing && existing.status !== "draft") {
|
||||||
|
throw new Error("Mzdový podklad už byl uzamčen a nelze jej tímto způsobem měnit.");
|
||||||
|
}
|
||||||
|
for (const value of Object.values(adjustments)) {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
throw new Error("Částka musí být číslo.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = getClosureSummary(employeeId, period);
|
||||||
|
const finalAmount =
|
||||||
|
summary.earned_estimate + adjustments.tips_amount + adjustments.bonus_amount + adjustments.other_amount;
|
||||||
|
|
||||||
|
upsertPayroll(employeeId, period, {
|
||||||
|
worked_minutes: summary.worked_minutes,
|
||||||
|
base_amount: summary.earned_estimate,
|
||||||
|
tips_amount: adjustments.tips_amount,
|
||||||
|
bonus_amount: adjustments.bonus_amount,
|
||||||
|
other_amount: adjustments.other_amount,
|
||||||
|
final_amount: finalAmount,
|
||||||
|
status: "draft",
|
||||||
|
});
|
||||||
|
|
||||||
|
return findPayroll(employeeId, period)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locks the month closure and freezes the payroll numbers (whatever draft adjustments existed, or zero). */
|
||||||
|
export function lockAndFinalizePayroll(employeeId: number, period: string): Payroll {
|
||||||
|
lockClosure(employeeId, period);
|
||||||
|
|
||||||
|
const summary = getClosureSummary(employeeId, period);
|
||||||
|
const draft = findPayroll(employeeId, period);
|
||||||
|
const tips = draft?.tips_amount ?? 0;
|
||||||
|
const bonus = draft?.bonus_amount ?? 0;
|
||||||
|
const other = draft?.other_amount ?? 0;
|
||||||
|
const finalAmount = summary.earned_estimate + tips + bonus + other;
|
||||||
|
|
||||||
|
upsertPayroll(employeeId, period, {
|
||||||
|
worked_minutes: summary.worked_minutes,
|
||||||
|
base_amount: summary.earned_estimate,
|
||||||
|
tips_amount: tips,
|
||||||
|
bonus_amount: bonus,
|
||||||
|
other_amount: other,
|
||||||
|
final_amount: finalAmount,
|
||||||
|
status: "ready",
|
||||||
|
});
|
||||||
|
|
||||||
|
return findPayroll(employeeId, period)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Undoes a lock that turned out to be premature (e.g. the rate wasn't set yet) — back to an editable draft. */
|
||||||
|
export function reopenPayroll(employeeId: number, period: string): Payroll {
|
||||||
|
const closure = findClosure(employeeId, period);
|
||||||
|
const payroll = findPayroll(employeeId, period);
|
||||||
|
if (!closure || closure.status !== "locked" || !payroll || payroll.status !== "ready") {
|
||||||
|
throw new Error("Lze odemknout jen uzamčenou a dosud nevyplacenou mzdu.");
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare("UPDATE month_closures SET status = 'confirmed', locked_at = NULL WHERE id = ?").run(closure.id);
|
||||||
|
|
||||||
|
const summary = getClosureSummary(employeeId, period);
|
||||||
|
const finalAmount = summary.earned_estimate + payroll.tips_amount + payroll.bonus_amount + payroll.other_amount;
|
||||||
|
upsertPayroll(employeeId, period, {
|
||||||
|
worked_minutes: summary.worked_minutes,
|
||||||
|
base_amount: summary.earned_estimate,
|
||||||
|
tips_amount: payroll.tips_amount,
|
||||||
|
bonus_amount: payroll.bonus_amount,
|
||||||
|
other_amount: payroll.other_amount,
|
||||||
|
final_amount: finalAmount,
|
||||||
|
status: "draft",
|
||||||
|
});
|
||||||
|
|
||||||
|
return findPayroll(employeeId, period)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markPaid(employeeId: number, period: string): Payroll {
|
||||||
|
const payroll = findPayroll(employeeId, period);
|
||||||
|
if (!payroll || payroll.status !== "ready") {
|
||||||
|
throw new Error("Mzda musí být nejdřív uzamčena.");
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE payroll SET status = 'paid', payment_date = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
|
||||||
|
).run(todayIso(), payroll.id);
|
||||||
|
|
||||||
|
return findPayroll(employeeId, period)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PayrollOverviewRow {
|
||||||
|
employee_id: number;
|
||||||
|
employee_name: string;
|
||||||
|
closure_status: ClosureStatus;
|
||||||
|
worked_minutes: number;
|
||||||
|
base_amount: number;
|
||||||
|
tips_amount: number;
|
||||||
|
bonus_amount: number;
|
||||||
|
other_amount: number;
|
||||||
|
final_amount: number;
|
||||||
|
payroll_status: PayrollStatus | null;
|
||||||
|
payment_date: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPeriodOverview(period: string): PayrollOverviewRow[] {
|
||||||
|
return listEmployees()
|
||||||
|
.filter((employee) => employee.active === 1)
|
||||||
|
.map((employee) => {
|
||||||
|
const closure = findClosure(employee.id, period);
|
||||||
|
const payroll = findPayroll(employee.id, period);
|
||||||
|
const summary = payroll ? null : getClosureSummary(employee.id, period);
|
||||||
|
|
||||||
|
return {
|
||||||
|
employee_id: employee.id,
|
||||||
|
employee_name: employee.name ?? employee.email,
|
||||||
|
closure_status: closure?.status ?? "waiting_employee",
|
||||||
|
worked_minutes: payroll?.worked_minutes ?? summary!.worked_minutes,
|
||||||
|
base_amount: payroll?.base_amount ?? summary!.earned_estimate,
|
||||||
|
tips_amount: payroll?.tips_amount ?? 0,
|
||||||
|
bonus_amount: payroll?.bonus_amount ?? 0,
|
||||||
|
other_amount: payroll?.other_amount ?? 0,
|
||||||
|
final_amount: payroll?.final_amount ?? summary!.earned_estimate,
|
||||||
|
payroll_status: payroll?.status ?? null,
|
||||||
|
payment_date: payroll?.payment_date ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertPayroll(
|
||||||
|
employeeId: number,
|
||||||
|
period: string,
|
||||||
|
data: {
|
||||||
|
worked_minutes: number;
|
||||||
|
base_amount: number;
|
||||||
|
tips_amount: number;
|
||||||
|
bonus_amount: number;
|
||||||
|
other_amount: number;
|
||||||
|
final_amount: number;
|
||||||
|
status: PayrollStatus;
|
||||||
|
}
|
||||||
|
): void {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO payroll (employee_id, period, worked_minutes, base_amount, tips_amount, bonus_amount, other_amount, final_amount, status, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
ON CONFLICT (employee_id, period) DO UPDATE SET
|
||||||
|
worked_minutes = excluded.worked_minutes,
|
||||||
|
base_amount = excluded.base_amount,
|
||||||
|
tips_amount = excluded.tips_amount,
|
||||||
|
bonus_amount = excluded.bonus_amount,
|
||||||
|
other_amount = excluded.other_amount,
|
||||||
|
final_amount = excluded.final_amount,
|
||||||
|
status = excluded.status,
|
||||||
|
updated_at = excluded.updated_at`
|
||||||
|
).run(
|
||||||
|
employeeId,
|
||||||
|
period,
|
||||||
|
data.worked_minutes,
|
||||||
|
data.base_amount,
|
||||||
|
data.tips_amount,
|
||||||
|
data.bonus_amount,
|
||||||
|
data.other_amount,
|
||||||
|
data.final_amount,
|
||||||
|
data.status
|
||||||
|
);
|
||||||
|
}
|
||||||
121
backend/src/services/shiftPlanning.test.ts
Normal file
121
backend/src/services/shiftPlanning.test.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
// Env + a throwaway DB must be in place before ../db is first imported (it
|
||||||
|
// creates the schema on import), so this runs ahead of the other imports.
|
||||||
|
const dbDir = fs.mkdtempSync(path.join(os.tmpdir(), "eatme-shift-test-"));
|
||||||
|
process.env.DB_PATH = path.join(dbDir, "test.db");
|
||||||
|
process.env.GOOGLE_CLIENT_ID = "test-client-id";
|
||||||
|
process.env.ADMIN_EMAILS = "admin@example.com";
|
||||||
|
process.env.JWT_SECRET = "test-secret";
|
||||||
|
|
||||||
|
import { db } from "../db";
|
||||||
|
import {
|
||||||
|
assignEmployeeToSlot,
|
||||||
|
cancelMySignup,
|
||||||
|
createSlot,
|
||||||
|
ensureSlotsForPeriod,
|
||||||
|
getAvailableSlotsForEmployee,
|
||||||
|
getMySlotsForEmployee,
|
||||||
|
listAdminSlotsForPeriod,
|
||||||
|
removeEmployeeFromSlot,
|
||||||
|
signupForSlot,
|
||||||
|
updateSlot,
|
||||||
|
} from "./shiftPlanning";
|
||||||
|
|
||||||
|
const PERIOD = "2026-09";
|
||||||
|
|
||||||
|
function makeEmployee(email: string): number {
|
||||||
|
const result = db.prepare("INSERT INTO employees (email, name) VALUES (?, ?)").run(email, email);
|
||||||
|
return Number(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sun-Thu = 1 generated slot/day, Fri-Sat = 2 (mirrors the template in shiftPlanning.ts). */
|
||||||
|
function expectedTemplateCount(period: string): number {
|
||||||
|
const [year, month] = period.split("-").map(Number);
|
||||||
|
const days = new Date(year, month, 0).getDate();
|
||||||
|
let count = 0;
|
||||||
|
for (let day = 1; day <= days; day++) {
|
||||||
|
const dow = new Date(year, month - 1, day).getDay();
|
||||||
|
count += dow === 5 || dow === 6 ? 2 : 1;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("shift planning", () => {
|
||||||
|
test("generates the weekly template idempotently", () => {
|
||||||
|
ensureSlotsForPeriod(PERIOD);
|
||||||
|
const first = listAdminSlotsForPeriod(PERIOD);
|
||||||
|
expect(first.length).toBe(expectedTemplateCount(PERIOD));
|
||||||
|
|
||||||
|
ensureSlotsForPeriod(PERIOD);
|
||||||
|
const second = listAdminSlotsForPeriod(PERIOD);
|
||||||
|
expect(second.length).toBe(first.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("employee sign-up fills capacity; duplicates and oversubscription are rejected; cancelling frees the slot", () => {
|
||||||
|
const alice = makeEmployee("alice@example.com");
|
||||||
|
const bob = makeEmployee("bob@example.com");
|
||||||
|
|
||||||
|
const single = listAdminSlotsForPeriod(PERIOD).find((s) => s.capacity === 1)!;
|
||||||
|
|
||||||
|
signupForSlot(alice, single.id);
|
||||||
|
expect(getMySlotsForEmployee(alice, PERIOD).some((s) => s.slot_id === single.id)).toBe(true);
|
||||||
|
expect(getAvailableSlotsForEmployee(alice, PERIOD).some((s) => s.slot_id === single.id)).toBe(false);
|
||||||
|
|
||||||
|
expect(() => signupForSlot(alice, single.id)).toThrow("Na této směně už jsi přihlášen.");
|
||||||
|
expect(() => signupForSlot(bob, single.id)).toThrow("Směna je už obsazená.");
|
||||||
|
|
||||||
|
expect(listAdminSlotsForPeriod(PERIOD).find((s) => s.id === single.id)!.status).toBe("full");
|
||||||
|
|
||||||
|
cancelMySignup(alice, single.id, "alice@example.com");
|
||||||
|
|
||||||
|
const afterCancel = listAdminSlotsForPeriod(PERIOD).find((s) => s.id === single.id)!;
|
||||||
|
expect(afterCancel.status).toBe("open");
|
||||||
|
expect(getAvailableSlotsForEmployee(alice, PERIOD).some((s) => s.slot_id === single.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects signing up for two shifts that overlap in time", () => {
|
||||||
|
const carol = makeEmployee("carol@example.com");
|
||||||
|
|
||||||
|
// The Fri/Sat template deliberately overlaps: 18:00-02:00 and 20:00-00:00.
|
||||||
|
const byDate = new Map<string, ReturnType<typeof listAdminSlotsForPeriod>>();
|
||||||
|
for (const slot of listAdminSlotsForPeriod(PERIOD)) {
|
||||||
|
byDate.set(slot.date, [...(byDate.get(slot.date) ?? []), slot]);
|
||||||
|
}
|
||||||
|
const [firstSlot, secondSlot] = [...byDate.values()].find((group) => group.length === 2)!;
|
||||||
|
|
||||||
|
signupForSlot(carol, firstSlot.id);
|
||||||
|
expect(() => signupForSlot(carol, secondSlot.id)).toThrow(/překrývá/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admin can create a custom slot, assign/remove employees, and capacity can't drop below occupancy", () => {
|
||||||
|
const dave = makeEmployee("dave@example.com");
|
||||||
|
|
||||||
|
const slotId = createSlot({
|
||||||
|
date: "2026-09-01",
|
||||||
|
start_time: "10:00",
|
||||||
|
end_time: "14:00",
|
||||||
|
capacity: 2,
|
||||||
|
note: "brunch",
|
||||||
|
});
|
||||||
|
expect(() =>
|
||||||
|
createSlot({ date: "2026-09-01", start_time: "10:00", end_time: "14:00", capacity: 2 })
|
||||||
|
).toThrow("Stejná směna už existuje.");
|
||||||
|
|
||||||
|
assignEmployeeToSlot(slotId, dave);
|
||||||
|
let slot = listAdminSlotsForPeriod(PERIOD).find((s) => s.id === slotId)!;
|
||||||
|
expect(slot.employees.map((e) => e.employee_id)).toContain(dave);
|
||||||
|
expect(slot.occupied).toBe(1);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
updateSlot(slotId, { date: slot.date, start_time: slot.start_time, end_time: slot.end_time, capacity: 0 })
|
||||||
|
).toThrow("Kapacitu nelze snížit pod počet již přihlášených zaměstnanců.");
|
||||||
|
|
||||||
|
removeEmployeeFromSlot(slotId, dave, "admin@example.com");
|
||||||
|
slot = listAdminSlotsForPeriod(PERIOD).find((s) => s.id === slotId)!;
|
||||||
|
expect(slot.occupied).toBe(0);
|
||||||
|
expect(slot.status).toBe("open");
|
||||||
|
});
|
||||||
|
});
|
||||||
362
backend/src/services/shiftPlanning.ts
Normal file
362
backend/src/services/shiftPlanning.ts
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
import { db } from "../db";
|
||||||
|
import type { ShiftSlot, ShiftSlotStatus } from "../types";
|
||||||
|
import { formatDateOnly } from "../util/date";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default weekly shift template. Slots for a whole calendar month are
|
||||||
|
* generated lazily (idempotently, via a UNIQUE index on date+start+end) the
|
||||||
|
* first time anyone asks for that period, rather than on a cron schedule —
|
||||||
|
* there's no scheduler in this app and this is simpler and just as reliable.
|
||||||
|
*/
|
||||||
|
function templatesForDow(dow: number): { start: string; end: string; capacity: number }[] {
|
||||||
|
if (dow === 5 || dow === 6) {
|
||||||
|
// Friday / Saturday
|
||||||
|
return [
|
||||||
|
{ start: "18:00", end: "02:00", capacity: 1 },
|
||||||
|
{ start: "20:00", end: "00:00", capacity: 1 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// Sunday - Thursday
|
||||||
|
return [{ start: "18:00", end: "23:00", capacity: 1 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureSlotsForPeriod(period: string): void {
|
||||||
|
const [year, monthIndex] = parsePeriod(period);
|
||||||
|
const days = new Date(year, monthIndex + 1, 0).getDate();
|
||||||
|
|
||||||
|
const insert = db.prepare(
|
||||||
|
`INSERT OR IGNORE INTO shift_slots (date, start_time, end_time, capacity, status, generated)
|
||||||
|
VALUES (?, ?, ?, ?, 'open', 1)`
|
||||||
|
);
|
||||||
|
|
||||||
|
const generate = db.transaction(() => {
|
||||||
|
for (let day = 1; day <= days; day++) {
|
||||||
|
const date = new Date(year, monthIndex, day);
|
||||||
|
const dateText = formatDateOnly(date);
|
||||||
|
for (const template of templatesForDow(date.getDay())) {
|
||||||
|
insert.run(dateText, template.start, template.end, template.capacity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
generate();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminShiftSlotView {
|
||||||
|
id: number;
|
||||||
|
date: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
capacity: number;
|
||||||
|
status: ShiftSlotStatus;
|
||||||
|
note: string;
|
||||||
|
generated: boolean;
|
||||||
|
occupied: number;
|
||||||
|
employees: { employee_id: number; name: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAdminSlotsForPeriod(period: string): AdminShiftSlotView[] {
|
||||||
|
ensureSlotsForPeriod(period);
|
||||||
|
|
||||||
|
const slots = db
|
||||||
|
.prepare<[string], ShiftSlot>("SELECT * FROM shift_slots WHERE date LIKE ? ORDER BY date, start_time")
|
||||||
|
.all(`${period}-%`);
|
||||||
|
|
||||||
|
const signupsForSlot = db.prepare<[number], { employee_id: number; email: string; name: string | null }>(
|
||||||
|
`SELECT ss.employee_id, e.email, e.name
|
||||||
|
FROM shift_signups ss
|
||||||
|
JOIN employees e ON e.id = ss.employee_id
|
||||||
|
WHERE ss.slot_id = ? AND ss.status = 'approved'`
|
||||||
|
);
|
||||||
|
|
||||||
|
return slots.map((slot) => {
|
||||||
|
const employees = signupsForSlot
|
||||||
|
.all(slot.id)
|
||||||
|
.map((row) => ({ employee_id: row.employee_id, name: row.name ?? row.email }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: slot.id,
|
||||||
|
date: slot.date,
|
||||||
|
start_time: slot.start_time,
|
||||||
|
end_time: slot.end_time,
|
||||||
|
capacity: slot.capacity,
|
||||||
|
status: slot.status,
|
||||||
|
note: slot.note,
|
||||||
|
generated: slot.generated === 1,
|
||||||
|
occupied: employees.length,
|
||||||
|
employees,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlotInput {
|
||||||
|
date: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
capacity: number;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSlot(data: SlotInput): number {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(data.date)) {
|
||||||
|
throw new Error("Neplatné datum.");
|
||||||
|
}
|
||||||
|
const startTime = normalizeTime(data.start_time);
|
||||||
|
const endTime = normalizeTime(data.end_time);
|
||||||
|
if (!Number.isInteger(data.capacity) || data.capacity < 1) {
|
||||||
|
throw new Error("Kapacita musí být alespoň 1.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO shift_slots (date, start_time, end_time, capacity, status, note, generated)
|
||||||
|
VALUES (?, ?, ?, ?, 'open', ?, 0)`
|
||||||
|
)
|
||||||
|
.run(data.date, startTime, endTime, data.capacity, data.note ?? "");
|
||||||
|
return Number(result.lastInsertRowid);
|
||||||
|
} catch (err) {
|
||||||
|
if (String(err).includes("UNIQUE")) {
|
||||||
|
throw new Error("Stejná směna už existuje.");
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSlot(slotId: number, data: SlotInput): void {
|
||||||
|
const slot = getSlot(slotId);
|
||||||
|
if (!slot) {
|
||||||
|
throw new Error("Směna nebyla nalezena.");
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(data.capacity) || data.capacity < 0) {
|
||||||
|
throw new Error("Kapacita musí být celé číslo 0 nebo vyšší.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const occupied = countApproved(slotId);
|
||||||
|
if (data.capacity < occupied) {
|
||||||
|
throw new Error("Kapacitu nelze snížit pod počet již přihlášených zaměstnanců.");
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE shift_slots
|
||||||
|
SET date = ?, start_time = ?, end_time = ?, capacity = ?, status = ?, note = ?,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||||
|
WHERE id = ?`
|
||||||
|
).run(
|
||||||
|
data.date || slot.date,
|
||||||
|
normalizeTime(data.start_time),
|
||||||
|
normalizeTime(data.end_time),
|
||||||
|
data.capacity,
|
||||||
|
computeStatus(data.capacity, occupied),
|
||||||
|
data.note ?? "",
|
||||||
|
slotId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assignEmployeeToSlot(slotId: number, employeeId: number): void {
|
||||||
|
signupInternal(slotId, employeeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeEmployeeFromSlot(slotId: number, employeeId: number, actorEmail: string): void {
|
||||||
|
cancelInternal(slotId, employeeId, actorEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailableSlotView {
|
||||||
|
slot_id: number;
|
||||||
|
date: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
capacity: number;
|
||||||
|
occupied: number;
|
||||||
|
free_places: number;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvailableSlotsForEmployee(employeeId: number, period: string): AvailableSlotView[] {
|
||||||
|
ensureSlotsForPeriod(period);
|
||||||
|
|
||||||
|
return db
|
||||||
|
.prepare<[string], ShiftSlot>(
|
||||||
|
"SELECT * FROM shift_slots WHERE date LIKE ? AND status = 'open' ORDER BY date, start_time"
|
||||||
|
)
|
||||||
|
.all(`${period}-%`)
|
||||||
|
.filter((slot) => !hasApprovedSignup(slot.id, employeeId))
|
||||||
|
.map((slot) => {
|
||||||
|
const occupied = countApproved(slot.id);
|
||||||
|
return {
|
||||||
|
slot_id: slot.id,
|
||||||
|
date: slot.date,
|
||||||
|
start_time: slot.start_time,
|
||||||
|
end_time: slot.end_time,
|
||||||
|
capacity: slot.capacity,
|
||||||
|
occupied,
|
||||||
|
free_places: Math.max(0, slot.capacity - occupied),
|
||||||
|
note: slot.note,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MySlotView {
|
||||||
|
signup_id: number;
|
||||||
|
slot_id: number;
|
||||||
|
date: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMySlotsForEmployee(employeeId: number, period: string): MySlotView[] {
|
||||||
|
ensureSlotsForPeriod(period);
|
||||||
|
|
||||||
|
return db
|
||||||
|
.prepare<[number, string], MySlotView>(
|
||||||
|
`SELECT ss.id as signup_id, sl.id as slot_id, sl.date, sl.start_time, sl.end_time, sl.note
|
||||||
|
FROM shift_signups ss
|
||||||
|
JOIN shift_slots sl ON sl.id = ss.slot_id
|
||||||
|
WHERE ss.employee_id = ? AND ss.status = 'approved' AND sl.date LIKE ?
|
||||||
|
ORDER BY sl.date, sl.start_time`
|
||||||
|
)
|
||||||
|
.all(employeeId, `${period}-%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signupForSlot(employeeId: number, slotId: number): void {
|
||||||
|
signupInternal(slotId, employeeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelMySignup(employeeId: number, slotId: number, actorEmail: string): void {
|
||||||
|
cancelInternal(slotId, employeeId, actorEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- internals ---------- */
|
||||||
|
|
||||||
|
function signupInternal(slotId: number, employeeId: number): void {
|
||||||
|
const slot = getSlot(slotId);
|
||||||
|
if (!slot) {
|
||||||
|
throw new Error("Směna nebyla nalezena.");
|
||||||
|
}
|
||||||
|
if (slot.status === "closed") {
|
||||||
|
throw new Error("Směna je uzavřená.");
|
||||||
|
}
|
||||||
|
if (hasApprovedSignup(slotId, employeeId)) {
|
||||||
|
throw new Error("Na této směně už jsi přihlášen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoCollision(employeeId, slot);
|
||||||
|
|
||||||
|
const occupied = countApproved(slotId);
|
||||||
|
if (occupied >= slot.capacity) {
|
||||||
|
throw new Error("Směna je už obsazená.");
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare("INSERT INTO shift_signups (slot_id, employee_id, status) VALUES (?, ?, 'approved')").run(
|
||||||
|
slotId,
|
||||||
|
employeeId
|
||||||
|
);
|
||||||
|
|
||||||
|
refreshSlotStatus(slotId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelInternal(slotId: number, employeeId: number, actorEmail: string): void {
|
||||||
|
const signup = db
|
||||||
|
.prepare<[number, number], { id: number }>(
|
||||||
|
"SELECT id FROM shift_signups WHERE slot_id = ? AND employee_id = ? AND status = 'approved'"
|
||||||
|
)
|
||||||
|
.get(slotId, employeeId);
|
||||||
|
|
||||||
|
if (!signup) {
|
||||||
|
throw new Error("Přihlášení na směnu nebylo nalezeno.");
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE shift_signups
|
||||||
|
SET status = 'cancelled', cancelled_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), cancelled_by = ?
|
||||||
|
WHERE id = ?`
|
||||||
|
).run(actorEmail, signup.id);
|
||||||
|
|
||||||
|
refreshSlotStatus(slotId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNoCollision(employeeId: number, targetSlot: ShiftSlot): void {
|
||||||
|
const mySlots = db
|
||||||
|
.prepare<[number], ShiftSlot>(
|
||||||
|
`SELECT sl.* FROM shift_slots sl
|
||||||
|
JOIN shift_signups ss ON ss.slot_id = sl.id
|
||||||
|
WHERE ss.employee_id = ? AND ss.status = 'approved'`
|
||||||
|
)
|
||||||
|
.all(employeeId);
|
||||||
|
|
||||||
|
const targetStart = slotStart(targetSlot);
|
||||||
|
const targetEnd = slotEnd(targetSlot);
|
||||||
|
|
||||||
|
for (const slot of mySlots) {
|
||||||
|
if (targetStart < slotEnd(slot) && targetEnd > slotStart(slot)) {
|
||||||
|
throw new Error("Tato směna se překrývá s jinou směnou, na kterou už jsi přihlášen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSlotStatus(slotId: number): void {
|
||||||
|
const slot = getSlot(slotId);
|
||||||
|
if (!slot) return;
|
||||||
|
const occupied = countApproved(slotId);
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE shift_slots SET status = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`
|
||||||
|
).run(computeStatus(slot.capacity, occupied), slotId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeStatus(capacity: number, occupied: number): ShiftSlotStatus {
|
||||||
|
if (capacity <= 0) return "closed";
|
||||||
|
return occupied >= capacity ? "full" : "open";
|
||||||
|
}
|
||||||
|
|
||||||
|
function countApproved(slotId: number): number {
|
||||||
|
const row = db
|
||||||
|
.prepare<[number], { n: number }>("SELECT COUNT(*) as n FROM shift_signups WHERE slot_id = ? AND status = 'approved'")
|
||||||
|
.get(slotId)!;
|
||||||
|
return row.n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasApprovedSignup(slotId: number, employeeId: number): boolean {
|
||||||
|
return !!db
|
||||||
|
.prepare("SELECT 1 FROM shift_signups WHERE slot_id = ? AND employee_id = ? AND status = 'approved'")
|
||||||
|
.get(slotId, employeeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSlot(id: number): ShiftSlot | undefined {
|
||||||
|
return db.prepare<[number], ShiftSlot>("SELECT * FROM shift_slots WHERE id = ?").get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotStart(slot: Pick<ShiftSlot, "date" | "start_time">): Date {
|
||||||
|
const [year, month, day] = slot.date.split("-").map(Number);
|
||||||
|
const [hour, minute] = slot.start_time.split(":").map(Number);
|
||||||
|
return new Date(year, month - 1, day, hour, minute);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End time rolls over to the next day whenever it's not after the start time (e.g. 18:00-02:00). */
|
||||||
|
function slotEnd(slot: Pick<ShiftSlot, "date" | "start_time" | "end_time">): Date {
|
||||||
|
const start = slotStart(slot);
|
||||||
|
const [hour, minute] = slot.end_time.split(":").map(Number);
|
||||||
|
const end = new Date(start.getFullYear(), start.getMonth(), start.getDate(), hour, minute);
|
||||||
|
if (end <= start) end.setDate(end.getDate() + 1);
|
||||||
|
return end;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePeriod(period: string): [number, number] {
|
||||||
|
if (!/^\d{4}-\d{2}$/.test(period)) {
|
||||||
|
throw new Error("Neplatné období.");
|
||||||
|
}
|
||||||
|
return [Number(period.slice(0, 4)), Number(period.slice(5, 7)) - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTime(value: string): string {
|
||||||
|
const match = /^(\d{1,2}):(\d{2})$/.exec(String(value ?? "").trim());
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("Neplatný čas.");
|
||||||
|
}
|
||||||
|
const hour = Number(match[1]);
|
||||||
|
const minute = Number(match[2]);
|
||||||
|
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||||
|
throw new Error("Neplatný čas.");
|
||||||
|
}
|
||||||
|
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
@@ -15,6 +15,69 @@ export interface AttendanceEvent {
|
|||||||
ts: string;
|
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 type Role = "admin" | "employee";
|
||||||
|
|
||||||
export interface SessionUser {
|
export interface SessionUser {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Session } from "../services/attendance";
|
import type { Session } from "../services/attendance";
|
||||||
|
import type { PayrollOverviewRow } from "../services/payroll";
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
return new Date(iso).toLocaleDateString("cs-CZ");
|
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");
|
return [header, ...rows].map((row) => row.map(escapeCsvField).join(",")).join("\r\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function payrollStatusLabel(row: PayrollOverviewRow): string {
|
||||||
|
if (row.payroll_status === "paid") return "vyplaceno";
|
||||||
|
if (row.payroll_status === "ready") return "připraveno k výplatě";
|
||||||
|
if (row.closure_status === "confirmed") return "potvrzeno zaměstnancem";
|
||||||
|
if (row.closure_status === "locked") return "uzamčeno";
|
||||||
|
return "čeká na zaměstnance";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function payrollToCsv(rows: PayrollOverviewRow[]): string {
|
||||||
|
const header = [
|
||||||
|
"Zaměstnanec",
|
||||||
|
"Odpracováno (h:mm)",
|
||||||
|
"Základ",
|
||||||
|
"Spropitné",
|
||||||
|
"Bonusy",
|
||||||
|
"Ostatní",
|
||||||
|
"Celkem",
|
||||||
|
"Stav",
|
||||||
|
"Datum výplaty",
|
||||||
|
];
|
||||||
|
const body = rows.map((r) => [
|
||||||
|
r.employee_name,
|
||||||
|
formatDuration(r.worked_minutes * 60_000),
|
||||||
|
r.base_amount.toFixed(2),
|
||||||
|
r.tips_amount.toFixed(2),
|
||||||
|
r.bonus_amount.toFixed(2),
|
||||||
|
r.other_amount.toFixed(2),
|
||||||
|
r.final_amount.toFixed(2),
|
||||||
|
payrollStatusLabel(r),
|
||||||
|
r.payment_date ?? "",
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [header, ...body].map((row) => row.map(escapeCsvField).join(",")).join("\r\n");
|
||||||
|
}
|
||||||
|
|||||||
7
backend/src/util/date.ts
Normal file
7
backend/src/util/date.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function formatDateOnly(date: Date): string {
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function todayIso(): string {
|
||||||
|
return formatDateOnly(new Date());
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
<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="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" />
|
<meta name="description" content="Evidence docházky zaměstnanců bistra EatMe" />
|
||||||
<title>EatMe — Docházka</title>
|
<title>EatMe — Docházka</title>
|
||||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||||
|
|||||||
@@ -34,5 +34,7 @@ export const api = {
|
|||||||
get: <T>(path: string) => request<T>(path),
|
get: <T>(path: string) => request<T>(path),
|
||||||
post: <T>(path: string, data?: unknown) =>
|
post: <T>(path: string, data?: unknown) =>
|
||||||
request<T>(path, { method: 'POST', body: data ? JSON.stringify(data) : undefined }),
|
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' }),
|
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,3 +53,90 @@ export interface AdminStatsSummary {
|
|||||||
totals: { totalWorkedMs: number; totalBreakMs: number; shiftCount: number };
|
totals: { totalWorkedMs: number; totalBreakMs: number; shiftCount: number };
|
||||||
employees: EmployeeStat[];
|
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;
|
||||||
|
}
|
||||||
|
|||||||
66
frontend/src/components/ClosureCard.tsx
Normal file
66
frontend/src/components/ClosureCard.tsx
Normal 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">> 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} Kč</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export function EmployeeManager() {
|
|||||||
const loadEmployees = useAdminStore((s) => s.loadEmployees);
|
const loadEmployees = useAdminStore((s) => s.loadEmployees);
|
||||||
const addEmployee = useAdminStore((s) => s.addEmployee);
|
const addEmployee = useAdminStore((s) => s.addEmployee);
|
||||||
const removeEmployee = useAdminStore((s) => s.removeEmployee);
|
const removeEmployee = useAdminStore((s) => s.removeEmployee);
|
||||||
|
const setRate = useAdminStore((s) => s.setRate);
|
||||||
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
|
||||||
@@ -46,27 +47,65 @@ export function EmployeeManager() {
|
|||||||
{employees === null && <div className="empty-line">> načítám…</div>}
|
{employees === null && <div className="empty-line">> načítám…</div>}
|
||||||
{employees?.length === 0 && <div className="empty-line">· zatím žádní zaměstnanci</div>}
|
{employees?.length === 0 && <div className="empty-line">· zatím žádní zaměstnanci</div>}
|
||||||
{employees?.map((emp) => (
|
{employees?.map((emp) => (
|
||||||
<div className="row" key={emp.id}>
|
<EmployeeRow key={emp.id} employee={emp} busy={busy} onRemove={removeEmployee} onSetRate={setRate} />
|
||||||
<div className="row-main">
|
|
||||||
<div className="row-title">{emp.name ?? emp.email}</div>
|
|
||||||
<div className="row-meta">
|
|
||||||
{emp.email} · od {formatDate(emp.created_at)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="row-action">
|
|
||||||
{emp.active ? (
|
|
||||||
<button className="btn" disabled={busy} onClick={() => removeEmployee(emp.id)}>
|
|
||||||
Odebrat
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="badge badge-dashed" title="Historie docházky zůstává zachována">
|
|
||||||
NEAKTIVNÍ
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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">{employee.name ?? employee.email}</div>
|
||||||
|
<div className="row-meta">
|
||||||
|
{employee.email} · od {formatDate(employee.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="row-action">
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
|
||||||
|
<span className="row-meta" style={{ margin: 0 }}>
|
||||||
|
Kč/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>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-dashed" title="Historie docházky zůstává zachována">
|
||||||
|
NEAKTIVNÍ
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
61
frontend/src/components/MonthCalendar.tsx
Normal file
61
frontend/src/components/MonthCalendar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
frontend/src/components/PayrollManager.tsx
Normal file
169
frontend/src/components/PayrollManager.tsx
Normal 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">> 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)} · základ {row.base_amount} Kč · celkem{' '}
|
||||||
|
{row.final_amount} Kč
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
274
frontend/src/components/ShiftPlanManager.tsx
Normal file
274
frontend/src/components/ShiftPlanManager.tsx
Normal 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">> 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
frontend/src/components/ShiftPlanning.tsx
Normal file
86
frontend/src/components/ShiftPlanning.tsx
Normal 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">> 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í · ostatní = volná směna, klikni pro přihlášení
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,3 +20,13 @@ export function formatDate(iso: string): string {
|
|||||||
export function formatDateTime(iso: string): string {
|
export function formatDateTime(iso: string): string {
|
||||||
return `${formatDate(iso)} ${formatTime(iso)}`;
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import './styles/tui.css';
|
import './styles/theme.css';
|
||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { EmployeeManager } from '../components/EmployeeManager';
|
import { EmployeeManager } from '../components/EmployeeManager';
|
||||||
import { AdminStats } from '../components/AdminStats';
|
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() {
|
export function AdminApp() {
|
||||||
const [tab, setTab] = useState<Tab>('employees');
|
const [tab, setTab] = useState<Tab>('employees');
|
||||||
@@ -13,6 +15,8 @@ export function AdminApp() {
|
|||||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
|
||||||
if (e.key === '1') setTab('employees');
|
if (e.key === '1') setTab('employees');
|
||||||
if (e.key === '2') setTab('stats');
|
if (e.key === '2') setTab('stats');
|
||||||
|
if (e.key === '3') setTab('shifts');
|
||||||
|
if (e.key === '4') setTab('payroll');
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
@@ -30,10 +34,19 @@ export function AdminApp() {
|
|||||||
<button className={`tab ${tab === 'stats' ? 'active' : ''}`} onClick={() => setTab('stats')}>
|
<button className={`tab ${tab === 'stats' ? 'active' : ''}`} onClick={() => setTab('stats')}>
|
||||||
[2] Statistiky
|
[2] Statistiky
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div style={{ marginTop: '1rem' }}>
|
<div style={{ marginTop: '1rem' }}>
|
||||||
{tab === 'employees' ? <EmployeeManager /> : <AdminStats />}
|
{tab === 'employees' && <EmployeeManager />}
|
||||||
|
{tab === 'stats' && <AdminStats />}
|
||||||
|
{tab === 'shifts' && <ShiftPlanManager />}
|
||||||
|
{tab === 'payroll' && <PayrollManager />}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { ClockControls } from '../components/ClockControls';
|
|||||||
import { StatusBadge } from '../components/StatusBadge';
|
import { StatusBadge } from '../components/StatusBadge';
|
||||||
import { StatsSummary } from '../components/StatsSummary';
|
import { StatsSummary } from '../components/StatsSummary';
|
||||||
import { SessionList } from '../components/SessionList';
|
import { SessionList } from '../components/SessionList';
|
||||||
|
import { ShiftPlanning } from '../components/ShiftPlanning';
|
||||||
|
import { ClosureCard } from '../components/ClosureCard';
|
||||||
|
|
||||||
export function EmployeeApp() {
|
export function EmployeeApp() {
|
||||||
const status = useAttendanceStore((s) => s.status);
|
const status = useAttendanceStore((s) => s.status);
|
||||||
@@ -53,9 +55,13 @@ export function EmployeeApp() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<p className="panel-title">Směny</p>
|
<p className="panel-title">Odpracované směny</p>
|
||||||
<SessionList sessions={live.sessions} />
|
<SessionList sessions={live.sessions} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ShiftPlanning />
|
||||||
|
|
||||||
|
<ClosureCard />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
108
frontend/src/store/adminPayrollStore.ts
Normal file
108
frontend/src/store/adminPayrollStore.ts
Normal 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;
|
||||||
107
frontend/src/store/adminShiftStore.ts
Normal file
107
frontend/src/store/adminShiftStore.ts
Normal 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;
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { api, ApiError } from '../api/client';
|
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';
|
import { currentMonthKey, shiftMonthKey } from '../lib/month';
|
||||||
|
|
||||||
interface AdminState {
|
interface AdminState {
|
||||||
employees: Employee[] | null;
|
employees: EmployeeWithRate[] | null;
|
||||||
employeesError: string | null;
|
employeesError: string | null;
|
||||||
employeesBusy: boolean;
|
employeesBusy: boolean;
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ interface AdminState {
|
|||||||
loadEmployees: () => Promise<void>;
|
loadEmployees: () => Promise<void>;
|
||||||
addEmployee: (email: string) => Promise<void>;
|
addEmployee: (email: string) => Promise<void>;
|
||||||
removeEmployee: (id: number) => Promise<void>;
|
removeEmployee: (id: number) => Promise<void>;
|
||||||
|
setRate: (id: number, hourlyRate: number) => Promise<void>;
|
||||||
|
|
||||||
loadStats: () => Promise<void>;
|
loadStats: () => Promise<void>;
|
||||||
selectEmployee: (id: number) => Promise<void>;
|
selectEmployee: (id: number) => Promise<void>;
|
||||||
@@ -40,13 +41,25 @@ const useAdminStore = create<AdminState>((set, get) => ({
|
|||||||
|
|
||||||
loadEmployees: async () => {
|
loadEmployees: async () => {
|
||||||
try {
|
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 });
|
set({ employees: res.employees, employeesError: null });
|
||||||
} catch {
|
} catch {
|
||||||
set({ employeesError: 'Nepodařilo se načíst zaměstnance' });
|
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) => {
|
addEmployee: async (email) => {
|
||||||
set({ employeesBusy: true, employeesError: null });
|
set({ employeesBusy: true, employeesError: null });
|
||||||
try {
|
try {
|
||||||
|
|||||||
64
frontend/src/store/closureStore.ts
Normal file
64
frontend/src/store/closureStore.ts
Normal 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;
|
||||||
79
frontend/src/store/shiftPlanningStore.ts
Normal file
79
frontend/src/store/shiftPlanningStore.ts
Normal 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;
|
||||||
735
frontend/src/styles/theme.css
Normal file
735
frontend/src/styles/theme.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -13,8 +13,8 @@ export default defineConfig({
|
|||||||
name: 'EatMe — Docházka',
|
name: 'EatMe — Docházka',
|
||||||
short_name: 'EatMe',
|
short_name: 'EatMe',
|
||||||
description: 'Evidence docházky zaměstnanců bistra EatMe',
|
description: 'Evidence docházky zaměstnanců bistra EatMe',
|
||||||
theme_color: '#000000',
|
theme_color: '#0d0f13',
|
||||||
background_color: '#f0f0f0',
|
background_color: '#0d0f13',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
start_url: '/',
|
start_url: '/',
|
||||||
icons: [
|
icons: [
|
||||||
|
|||||||
16
gscript/.clasp.json
Normal file
16
gscript/.clasp.json
Normal 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
1659
gscript/AdminService.js
Normal file
File diff suppressed because it is too large
Load Diff
6
gscript/App.js
Normal file
6
gscript/App.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
function doGet() {
|
||||||
|
return HtmlService.createTemplateFromFile('Index')
|
||||||
|
.evaluate()
|
||||||
|
.setTitle(CFG.APP_NAME)
|
||||||
|
.addMetaTag('viewport','width=device-width, initial-scale=1');
|
||||||
|
}
|
||||||
1344
gscript/AttendanceCorrectionAdminService.js
Normal file
1344
gscript/AttendanceCorrectionAdminService.js
Normal file
File diff suppressed because it is too large
Load Diff
131
gscript/AttendanceService.js
Normal file
131
gscript/AttendanceService.js
Normal 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:00–06: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
1104
gscript/AuthService.js
Normal file
File diff suppressed because it is too large
Load Diff
33
gscript/Automation.js
Normal file
33
gscript/Automation.js
Normal 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
2048
gscript/ClosureService.js
Normal file
File diff suppressed because it is too large
Load Diff
295
gscript/Config.js
Normal file
295
gscript/Config.js
Normal 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
3278
gscript/DailySalesService.js
Normal file
File diff suppressed because it is too large
Load Diff
1099
gscript/DashboardService.js
Normal file
1099
gscript/DashboardService.js
Normal file
File diff suppressed because it is too large
Load Diff
72
gscript/Database.js
Normal file
72
gscript/Database.js
Normal 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); }
|
||||||
|
}
|
||||||
1175
gscript/EmployeeEditService.js
Normal file
1175
gscript/EmployeeEditService.js
Normal file
File diff suppressed because it is too large
Load Diff
293
gscript/EmployeeService.js
Normal file
293
gscript/EmployeeService.js
Normal 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
18711
gscript/Index.html
Normal file
File diff suppressed because one or more lines are too long
15
gscript/LoginPerformanceService.js
Normal file
15
gscript/LoginPerformanceService.js
Normal 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
55
gscript/NewsService.js
Normal 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
1575
gscript/NewsService_Roles.js
Normal file
File diff suppressed because it is too large
Load Diff
1554
gscript/PayrollService.js
Normal file
1554
gscript/PayrollService.js
Normal file
File diff suppressed because it is too large
Load Diff
377
gscript/Security.js
Normal file
377
gscript/Security.js
Normal 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
128
gscript/Setup.js
Normal 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'
|
||||||
|
);
|
||||||
|
}
|
||||||
2866
gscript/ShiftPlanningService.js
Normal file
2866
gscript/ShiftPlanningService.js
Normal file
File diff suppressed because it is too large
Load Diff
1591
gscript/ShiftReconciliationService.js
Normal file
1591
gscript/ShiftReconciliationService.js
Normal file
File diff suppressed because it is too large
Load Diff
3605
gscript/WeeklyPayrollService.js
Normal file
3605
gscript/WeeklyPayrollService.js
Normal file
File diff suppressed because it is too large
Load Diff
10
gscript/appsscript.json
Normal file
10
gscript/appsscript.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"timeZone": "Europe/Prague",
|
||||||
|
"dependencies": {},
|
||||||
|
"exceptionLogging": "STACKDRIVER",
|
||||||
|
"runtimeVersion": "V8",
|
||||||
|
"webapp": {
|
||||||
|
"executeAs": "USER_DEPLOYING",
|
||||||
|
"access": "ANYONE_ANONYMOUS"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user