From f917ed06a872b630b58c357407c758c731a7ca35 Mon Sep 17 00:00:00 2001 From: Michal Pemcak Date: Sun, 16 Aug 2026 18:27:50 +0200 Subject: [PATCH] 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. --- backend/jest.config.js | 6 + backend/package.json | 6 +- backend/src/db/index.ts | 70 + backend/src/index.ts | 4 + backend/src/routes/admin.ts | 213 +- backend/src/routes/closure.ts | 37 + backend/src/routes/shifts.ts | 51 + backend/src/services/attendance.test.ts | 74 + backend/src/services/attendance.ts | 35 + backend/src/services/closure.ts | 88 + backend/src/services/payRates.ts | 52 + backend/src/services/payroll.test.ts | 157 + backend/src/services/payroll.ts | 188 + backend/src/services/shiftPlanning.test.ts | 121 + backend/src/services/shiftPlanning.ts | 362 + backend/src/types.ts | 63 + backend/src/util/csv.ts | 36 + backend/src/util/date.ts | 7 + frontend/index.html | 2 +- frontend/src/api/client.ts | 2 + frontend/src/api/types.ts | 87 + frontend/src/components/ClosureCard.tsx | 66 + frontend/src/components/EmployeeManager.tsx | 77 +- frontend/src/components/MonthCalendar.tsx | 61 + frontend/src/components/PayrollManager.tsx | 169 + frontend/src/components/ShiftPlanManager.tsx | 274 + frontend/src/components/ShiftPlanning.tsx | 86 + frontend/src/lib/format.ts | 10 + frontend/src/main.tsx | 2 +- frontend/src/pages/AdminApp.tsx | 17 +- frontend/src/pages/EmployeeApp.tsx | 8 +- frontend/src/store/adminPayrollStore.ts | 108 + frontend/src/store/adminShiftStore.ts | 107 + frontend/src/store/adminStore.ts | 19 +- frontend/src/store/closureStore.ts | 64 + frontend/src/store/shiftPlanningStore.ts | 79 + frontend/src/styles/theme.css | 735 + frontend/src/styles/tui.css | 520 - frontend/vite.config.ts | 4 +- gscript/.clasp.json | 16 + gscript/AdminService.js | 1659 ++ gscript/App.js | 6 + gscript/AttendanceCorrectionAdminService.js | 1344 ++ gscript/AttendanceService.js | 131 + gscript/AuthService.js | 1104 + gscript/Automation.js | 33 + gscript/ClosureService.js | 2048 ++ gscript/Config.js | 295 + gscript/DailySalesService.js | 3278 +++ gscript/DashboardService.js | 1099 + gscript/Database.js | 72 + gscript/EmployeeEditService.js | 1175 ++ gscript/EmployeeService.js | 293 + gscript/Index.html | 18711 +++++++++++++++++ gscript/LoginPerformanceService.js | 15 + gscript/NewsService.js | 55 + gscript/NewsService_Roles.js | 1575 ++ gscript/PayrollService.js | 1554 ++ gscript/Security.js | 377 + gscript/Setup.js | 128 + gscript/ShiftPlanningService.js | 2866 +++ gscript/ShiftReconciliationService.js | 1591 ++ gscript/WeeklyPayrollService.js | 3605 ++++ gscript/appsscript.json | 10 + 64 files changed, 46554 insertions(+), 553 deletions(-) create mode 100644 backend/jest.config.js create mode 100644 backend/src/routes/closure.ts create mode 100644 backend/src/routes/shifts.ts create mode 100644 backend/src/services/attendance.test.ts create mode 100644 backend/src/services/closure.ts create mode 100644 backend/src/services/payRates.ts create mode 100644 backend/src/services/payroll.test.ts create mode 100644 backend/src/services/payroll.ts create mode 100644 backend/src/services/shiftPlanning.test.ts create mode 100644 backend/src/services/shiftPlanning.ts create mode 100644 backend/src/util/date.ts create mode 100644 frontend/src/components/ClosureCard.tsx create mode 100644 frontend/src/components/MonthCalendar.tsx create mode 100644 frontend/src/components/PayrollManager.tsx create mode 100644 frontend/src/components/ShiftPlanManager.tsx create mode 100644 frontend/src/components/ShiftPlanning.tsx create mode 100644 frontend/src/store/adminPayrollStore.ts create mode 100644 frontend/src/store/adminShiftStore.ts create mode 100644 frontend/src/store/closureStore.ts create mode 100644 frontend/src/store/shiftPlanningStore.ts create mode 100644 frontend/src/styles/theme.css delete mode 100644 frontend/src/styles/tui.css create mode 100644 gscript/.clasp.json create mode 100644 gscript/AdminService.js create mode 100644 gscript/App.js create mode 100644 gscript/AttendanceCorrectionAdminService.js create mode 100644 gscript/AttendanceService.js create mode 100644 gscript/AuthService.js create mode 100644 gscript/Automation.js create mode 100644 gscript/ClosureService.js create mode 100644 gscript/Config.js create mode 100644 gscript/DailySalesService.js create mode 100644 gscript/DashboardService.js create mode 100644 gscript/Database.js create mode 100644 gscript/EmployeeEditService.js create mode 100644 gscript/EmployeeService.js create mode 100644 gscript/Index.html create mode 100644 gscript/LoginPerformanceService.js create mode 100644 gscript/NewsService.js create mode 100644 gscript/NewsService_Roles.js create mode 100644 gscript/PayrollService.js create mode 100644 gscript/Security.js create mode 100644 gscript/Setup.js create mode 100644 gscript/ShiftPlanningService.js create mode 100644 gscript/ShiftReconciliationService.js create mode 100644 gscript/WeeklyPayrollService.js create mode 100644 gscript/appsscript.json diff --git a/backend/jest.config.js b/backend/jest.config.js new file mode 100644 index 0000000..e0535e2 --- /dev/null +++ b/backend/jest.config.js @@ -0,0 +1,6 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + testMatch: ["/src/**/*.test.ts"], +}; diff --git a/backend/package.json b/backend/package.json index df5f70f..c401cda 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,7 +8,8 @@ "dev": "tsx watch src/index.ts", "build": "tsc --noEmit && node esbuild.config.js", "start": "node dist/index.js", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "jest" }, "dependencies": { "better-sqlite3": "^11.9.1", @@ -25,9 +26,12 @@ "@types/cookie-parser": "^1.4.8", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.7", "@types/node": "^22.10.2", "esbuild": "^0.28.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.12", "tsx": "^4.19.2", "typescript": "^5.7.2" } diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index fb99d4d..ecb770a 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -27,4 +27,74 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_attendance_employee_ts ON attendance_events (employee_id, ts); + + CREATE TABLE IF NOT EXISTS shift_slots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + start_time TEXT NOT NULL, + end_time TEXT NOT NULL, + capacity INTEGER NOT NULL, + status TEXT NOT NULL CHECK (status IN ('open', 'full', 'closed')) DEFAULT 'open', + note TEXT NOT NULL DEFAULT '', + generated INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_shift_slots_key + ON shift_slots (date, start_time, end_time); + + CREATE TABLE IF NOT EXISTS shift_signups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slot_id INTEGER NOT NULL REFERENCES shift_slots(id), + employee_id INTEGER NOT NULL REFERENCES employees(id), + status TEXT NOT NULL CHECK (status IN ('approved', 'cancelled')) DEFAULT 'approved', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + cancelled_at TEXT, + cancelled_by TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_shift_signups_slot + ON shift_signups (slot_id, status); + + CREATE INDEX IF NOT EXISTS idx_shift_signups_employee + ON shift_signups (employee_id, status); + + CREATE TABLE IF NOT EXISTS pay_rates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + employee_id INTEGER NOT NULL REFERENCES employees(id), + hourly_rate REAL NOT NULL, + valid_from TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE (employee_id, valid_from) + ); + + CREATE INDEX IF NOT EXISTS idx_pay_rates_employee + ON pay_rates (employee_id, valid_from); + + CREATE TABLE IF NOT EXISTS month_closures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + employee_id INTEGER NOT NULL REFERENCES employees(id), + period TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('waiting_employee', 'confirmed', 'locked')) DEFAULT 'waiting_employee', + employee_confirmed_at TEXT, + locked_at TEXT, + UNIQUE (employee_id, period) + ); + + CREATE TABLE IF NOT EXISTS payroll ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + employee_id INTEGER NOT NULL REFERENCES employees(id), + period TEXT NOT NULL, + worked_minutes INTEGER NOT NULL DEFAULT 0, + base_amount REAL NOT NULL DEFAULT 0, + tips_amount REAL NOT NULL DEFAULT 0, + bonus_amount REAL NOT NULL DEFAULT 0, + other_amount REAL NOT NULL DEFAULT 0, + final_amount REAL NOT NULL DEFAULT 0, + status TEXT NOT NULL CHECK (status IN ('draft', 'ready', 'paid')) DEFAULT 'draft', + payment_date TEXT, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE (employee_id, period) + ); `); diff --git a/backend/src/index.ts b/backend/src/index.ts index e8a8057..255d0b2 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -8,6 +8,8 @@ import { env } from "./env"; import { authRouter } from "./routes/auth"; import { attendanceRouter } from "./routes/attendance"; import { adminRouter } from "./routes/admin"; +import { shiftsRouter } from "./routes/shifts"; +import { closureRouter } from "./routes/closure"; const app = express(); @@ -19,6 +21,8 @@ app.get("/api/health", (_req, res) => res.json({ ok: true })); app.use("/api/auth", authRouter); app.use("/api/attendance", attendanceRouter); +app.use("/api/shifts", shiftsRouter); +app.use("/api/closure", closureRouter); app.use("/api/admin", adminRouter); // In the production Docker image the built frontend is copied next to this diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index f520604..fb1fb3f 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -1,4 +1,4 @@ -import { Router } from "express"; +import { Router, type Request } from "express"; import { z } from "zod"; import { requireAdmin, requireAuth } from "../auth/middleware"; import { @@ -9,13 +9,52 @@ import { } from "../services/employees"; import { eventsInRange, summarize } from "../services/attendance"; import { parseRange } from "../util/dateRange"; -import { sessionsToCsv } from "../util/csv"; +import { payrollToCsv, sessionsToCsv } from "../util/csv"; +import { + assignEmployeeToSlot, + createSlot, + listAdminSlotsForPeriod, + removeEmployeeFromSlot, + updateSlot, +} from "../services/shiftPlanning"; +import { getCurrentRate, setRate } from "../services/payRates"; +import { + getPeriodOverview, + lockAndFinalizePayroll, + markPaid, + reopenPayroll, + saveDraftAdjustments, +} from "../services/payroll"; export const adminRouter = Router(); adminRouter.use(requireAuth, requireAdmin); adminRouter.get("/employees", (_req, res) => { - res.json({ employees: listEmployees() }); + const employees = listEmployees().map((employee) => ({ + ...employee, + hourly_rate: getCurrentRate(employee.id), + })); + res.json({ employees }); +}); + +const rateSchema = z.object({ + hourly_rate: z.number().nonnegative(), + valid_from: z.string().optional(), +}); + +adminRouter.post("/employees/:id/rate", (req, res) => { + const id = Number(req.params.id); + const parsed = rateSchema.safeParse(req.body); + if (!Number.isInteger(id) || !parsed.success) { + res.status(400).json({ error: "Neplatná data." }); + return; + } + try { + setRate(id, parsed.data.hourly_rate, parsed.data.valid_from); + res.status(201).json({ hourly_rate: getCurrentRate(id) }); + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : "Uložení se nezdařilo" }); + } }); const addEmployeeSchema = z.object({ @@ -107,3 +146,171 @@ adminRouter.get("/stats/:id/export", (req, res) => { res.setHeader("Content-Disposition", `attachment; filename="${safeName}_${period}.csv"`); res.send("" + csv); // BOM so Excel opens the Czech diacritics as UTF-8 }); + +/* ---------- shift planning ---------- */ + +function periodParam(req: Request): string { + const period = req.query.period; + if (typeof period === "string" && /^\d{4}-\d{2}$/.test(period)) return period; + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; +} + +adminRouter.get("/shifts", (req, res) => { + res.json({ slots: listAdminSlotsForPeriod(periodParam(req)) }); +}); + +const slotSchema = z.object({ + date: z.string(), + start_time: z.string(), + end_time: z.string(), + capacity: z.number(), + note: z.string().optional(), +}); + +adminRouter.post("/shifts", (req, res) => { + const parsed = slotSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: "Neplatná data směny." }); + return; + } + try { + const id = createSlot(parsed.data); + res.status(201).json({ id }); + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : "Vytvoření se nezdařilo" }); + } +}); + +adminRouter.patch("/shifts/:slotId", (req, res) => { + const slotId = Number(req.params.slotId); + const parsed = slotSchema.safeParse(req.body); + if (!Number.isInteger(slotId) || !parsed.success) { + res.status(400).json({ error: "Neplatná data směny." }); + return; + } + try { + updateSlot(slotId, parsed.data); + res.json({ ok: true }); + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : "Úprava se nezdařila" }); + } +}); + +adminRouter.post("/shifts/:slotId/assign", (req, res) => { + const slotId = Number(req.params.slotId); + const employeeId = Number(req.body?.employeeId); + if (!Number.isInteger(slotId) || !Number.isInteger(employeeId)) { + res.status(400).json({ error: "Neplatný požadavek." }); + return; + } + try { + assignEmployeeToSlot(slotId, employeeId); + res.status(201).json({ ok: true }); + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : "Přiřazení se nezdařilo" }); + } +}); + +adminRouter.delete("/shifts/:slotId/assign/:employeeId", (req, res) => { + const slotId = Number(req.params.slotId); + const employeeId = Number(req.params.employeeId); + if (!Number.isInteger(slotId) || !Number.isInteger(employeeId)) { + res.status(400).json({ error: "Neplatný požadavek." }); + return; + } + try { + removeEmployeeFromSlot(slotId, employeeId, req.user!.email); + res.status(204).end(); + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : "Odebrání se nezdařilo" }); + } +}); + +/* ---------- payroll ---------- */ + +adminRouter.get("/payroll", (req, res) => { + res.json({ rows: getPeriodOverview(periodParam(req)) }); +}); + +const adjustmentsSchema = z.object({ + period: z.string().regex(/^\d{4}-\d{2}$/), + tips_amount: z.number(), + bonus_amount: z.number(), + other_amount: z.number(), +}); + +adminRouter.post("/payroll/:employeeId/adjustments", (req, res) => { + const employeeId = Number(req.params.employeeId); + const parsed = adjustmentsSchema.safeParse(req.body); + if (!Number.isInteger(employeeId) || !parsed.success) { + res.status(400).json({ error: "Neplatná data." }); + return; + } + try { + const payroll = saveDraftAdjustments(employeeId, parsed.data.period, { + tips_amount: parsed.data.tips_amount, + bonus_amount: parsed.data.bonus_amount, + other_amount: parsed.data.other_amount, + }); + res.json({ payroll }); + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : "Uložení se nezdařilo" }); + } +}); + +const periodBodySchema = z.object({ period: z.string().regex(/^\d{4}-\d{2}$/) }); + +adminRouter.post("/payroll/:employeeId/lock", (req, res) => { + const employeeId = Number(req.params.employeeId); + const parsed = periodBodySchema.safeParse(req.body); + if (!Number.isInteger(employeeId) || !parsed.success) { + res.status(400).json({ error: "Neplatná data." }); + return; + } + try { + const payroll = lockAndFinalizePayroll(employeeId, parsed.data.period); + res.json({ payroll }); + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : "Uzamčení se nezdařilo" }); + } +}); + +adminRouter.post("/payroll/:employeeId/reopen", (req, res) => { + const employeeId = Number(req.params.employeeId); + const parsed = periodBodySchema.safeParse(req.body); + if (!Number.isInteger(employeeId) || !parsed.success) { + res.status(400).json({ error: "Neplatná data." }); + return; + } + try { + const payroll = reopenPayroll(employeeId, parsed.data.period); + res.json({ payroll }); + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : "Odemknutí se nezdařilo" }); + } +}); + +adminRouter.post("/payroll/:employeeId/paid", (req, res) => { + const employeeId = Number(req.params.employeeId); + const parsed = periodBodySchema.safeParse(req.body); + if (!Number.isInteger(employeeId) || !parsed.success) { + res.status(400).json({ error: "Neplatná data." }); + return; + } + try { + const payroll = markPaid(employeeId, parsed.data.period); + res.json({ payroll }); + } catch (err) { + res.status(400).json({ error: err instanceof Error ? err.message : "Označení se nezdařilo" }); + } +}); + +adminRouter.get("/payroll/export", (req, res) => { + const period = periodParam(req); + const csv = payrollToCsv(getPeriodOverview(period)); + + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader("Content-Disposition", `attachment; filename="mzdy_${period}.csv"`); + res.send("" + csv); +}); diff --git a/backend/src/routes/closure.ts b/backend/src/routes/closure.ts new file mode 100644 index 0000000..4c83257 --- /dev/null +++ b/backend/src/routes/closure.ts @@ -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" }); + } +}); diff --git a/backend/src/routes/shifts.ts b/backend/src/routes/shifts.ts new file mode 100644 index 0000000..86c28f6 --- /dev/null +++ b/backend/src/routes/shifts.ts @@ -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" }); + } +}); diff --git a/backend/src/services/attendance.test.ts b/backend/src/services/attendance.test.ts new file mode 100644 index 0000000..4fb3a5b --- /dev/null +++ b/backend/src/services/attendance.test.ts @@ -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); + }); +}); diff --git a/backend/src/services/attendance.ts b/backend/src/services/attendance.ts index d379cf6..b36f2f1 100644 --- a/backend/src/services/attendance.ts +++ b/backend/src/services/attendance.ts @@ -22,6 +22,8 @@ function statusAfter(lastType: EventType | null): LiveStatus { } } +const MAX_SHIFT_MS = 12 * 60 * 60 * 1000; + export function getLastEvent(employeeId: number): AttendanceEvent | undefined { return db .prepare<[number], AttendanceEvent>( @@ -31,10 +33,42 @@ export function getLastEvent(employeeId: number): AttendanceEvent | undefined { } export function getLiveStatus(employeeId: number): LiveStatus { + autoCloseStaleShift(employeeId); const last = getLastEvent(employeeId); return statusAfter(last?.type ?? null); } +/** + * A shift nobody clocked out of (forgotten, phone died, whatever) is capped at 12h — closed + * at clock_in + 12h rather than growing forever. Runs lazily on every read instead of a + * background job, so it self-heals without needing a scheduler. + */ +function autoCloseStaleShift(employeeId: number): void { + const last = getLastEvent(employeeId); + const status = statusAfter(last?.type ?? null); + if (status === "clocked_out") return; + + const clockIn = db + .prepare<[number], AttendanceEvent>( + "SELECT * FROM attendance_events WHERE employee_id = ? AND type = 'clock_in' ORDER BY ts DESC, id DESC LIMIT 1" + ) + .get(employeeId)!; + + if (Date.now() - new Date(clockIn.ts).getTime() <= MAX_SHIFT_MS) return; + + const cutoff = new Date(new Date(clockIn.ts).getTime() + MAX_SHIFT_MS).toISOString(); + if (status === "on_break") { + db.prepare("INSERT INTO attendance_events (employee_id, type, ts) VALUES (?, 'break_end', ?)").run( + employeeId, + cutoff + ); + } + db.prepare("INSERT INTO attendance_events (employee_id, type, ts) VALUES (?, 'clock_out', ?)").run( + employeeId, + cutoff + ); +} + export class InvalidTransitionError extends Error { constructor(public readonly current: LiveStatus, public readonly attempted: EventType) { super(`Cannot record "${attempted}" while status is "${current}"`); @@ -55,6 +89,7 @@ export function recordEvent(employeeId: number, type: EventType): AttendanceEven } export function eventsInRange(employeeId: number, fromIso: string, toIso: string): AttendanceEvent[] { + autoCloseStaleShift(employeeId); return db .prepare<[number, string, string], AttendanceEvent>( `SELECT * FROM attendance_events diff --git a/backend/src/services/closure.ts b/backend/src/services/closure.ts new file mode 100644 index 0000000..d5dce8a --- /dev/null +++ b/backend/src/services/closure.ts @@ -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); +} diff --git a/backend/src/services/payRates.ts b/backend/src/services/payRates.ts new file mode 100644 index 0000000..9077a01 --- /dev/null +++ b/backend/src/services/payRates.ts @@ -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(); +} diff --git a/backend/src/services/payroll.test.ts b/backend/src/services/payroll.test.ts new file mode 100644 index 0000000..9be027f --- /dev/null +++ b/backend/src/services/payroll.test.ts @@ -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); + }); +}); diff --git a/backend/src/services/payroll.ts b/backend/src/services/payroll.ts new file mode 100644 index 0000000..fb996ba --- /dev/null +++ b/backend/src/services/payroll.ts @@ -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 + ); +} diff --git a/backend/src/services/shiftPlanning.test.ts b/backend/src/services/shiftPlanning.test.ts new file mode 100644 index 0000000..2a0f67f --- /dev/null +++ b/backend/src/services/shiftPlanning.test.ts @@ -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>(); + 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"); + }); +}); diff --git a/backend/src/services/shiftPlanning.ts b/backend/src/services/shiftPlanning.ts new file mode 100644 index 0000000..d7cb6af --- /dev/null +++ b/backend/src/services/shiftPlanning.ts @@ -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): 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): 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")}`; +} diff --git a/backend/src/types.ts b/backend/src/types.ts index 6f47daf..0186101 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -15,6 +15,69 @@ export interface AttendanceEvent { ts: string; } +export type ShiftSlotStatus = "open" | "full" | "closed"; + +export interface ShiftSlot { + id: number; + date: string; + start_time: string; + end_time: string; + capacity: number; + status: ShiftSlotStatus; + note: string; + generated: 0 | 1; + created_at: string; + updated_at: string; +} + +export type ShiftSignupStatus = "approved" | "cancelled"; + +export interface ShiftSignup { + id: number; + slot_id: number; + employee_id: number; + status: ShiftSignupStatus; + created_at: string; + cancelled_at: string | null; + cancelled_by: string | null; +} + +export interface PayRate { + id: number; + employee_id: number; + hourly_rate: number; + valid_from: string; + created_at: string; +} + +export type ClosureStatus = "waiting_employee" | "confirmed" | "locked"; + +export interface MonthClosure { + id: number; + employee_id: number; + period: string; + status: ClosureStatus; + employee_confirmed_at: string | null; + locked_at: string | null; +} + +export type PayrollStatus = "draft" | "ready" | "paid"; + +export interface Payroll { + id: number; + employee_id: number; + period: string; + worked_minutes: number; + base_amount: number; + tips_amount: number; + bonus_amount: number; + other_amount: number; + final_amount: number; + status: PayrollStatus; + payment_date: string | null; + updated_at: string; +} + export type Role = "admin" | "employee"; export interface SessionUser { diff --git a/backend/src/util/csv.ts b/backend/src/util/csv.ts index 7441034..c3113dc 100644 --- a/backend/src/util/csv.ts +++ b/backend/src/util/csv.ts @@ -1,4 +1,5 @@ import type { Session } from "../services/attendance"; +import type { PayrollOverviewRow } from "../services/payroll"; function formatDate(iso: string): string { return new Date(iso).toLocaleDateString("cs-CZ"); @@ -33,3 +34,38 @@ export function sessionsToCsv(sessions: Session[]): string { return [header, ...rows].map((row) => row.map(escapeCsvField).join(",")).join("\r\n"); } + +function payrollStatusLabel(row: PayrollOverviewRow): string { + if (row.payroll_status === "paid") return "vyplaceno"; + if (row.payroll_status === "ready") return "připraveno k výplatě"; + if (row.closure_status === "confirmed") return "potvrzeno zaměstnancem"; + if (row.closure_status === "locked") return "uzamčeno"; + return "čeká na zaměstnance"; +} + +export function payrollToCsv(rows: PayrollOverviewRow[]): string { + const header = [ + "Zaměstnanec", + "Odpracováno (h:mm)", + "Základ", + "Spropitné", + "Bonusy", + "Ostatní", + "Celkem", + "Stav", + "Datum výplaty", + ]; + const body = rows.map((r) => [ + r.employee_name, + formatDuration(r.worked_minutes * 60_000), + r.base_amount.toFixed(2), + r.tips_amount.toFixed(2), + r.bonus_amount.toFixed(2), + r.other_amount.toFixed(2), + r.final_amount.toFixed(2), + payrollStatusLabel(r), + r.payment_date ?? "", + ]); + + return [header, ...body].map((row) => row.map(escapeCsvField).join(",")).join("\r\n"); +} diff --git a/backend/src/util/date.ts b/backend/src/util/date.ts new file mode 100644 index 0000000..2955ee8 --- /dev/null +++ b/backend/src/util/date.ts @@ -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()); +} diff --git a/frontend/index.html b/frontend/index.html index 9ca235d..4a92879 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,7 +5,7 @@ - + EatMe — Docházka diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 54cdadd..54651b5 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -34,5 +34,7 @@ export const api = { get: (path: string) => request(path), post: (path: string, data?: unknown) => request(path, { method: 'POST', body: data ? JSON.stringify(data) : undefined }), + patch: (path: string, data?: unknown) => + request(path, { method: 'PATCH', body: data ? JSON.stringify(data) : undefined }), delete: (path: string) => request(path, { method: 'DELETE' }), }; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 571a633..8c345be 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -53,3 +53,90 @@ export interface AdminStatsSummary { totals: { totalWorkedMs: number; totalBreakMs: number; shiftCount: number }; employees: EmployeeStat[]; } + +export type ShiftSlotStatus = 'open' | 'full' | 'closed'; + +export interface AvailableShiftSlot { + slot_id: number; + date: string; + start_time: string; + end_time: string; + capacity: number; + occupied: number; + free_places: number; + note: string; +} + +export interface MyShiftSlot { + signup_id: number; + slot_id: number; + date: string; + start_time: string; + end_time: string; + note: string; +} + +export interface AdminShiftSlot { + id: number; + date: string; + start_time: string; + end_time: string; + capacity: number; + status: ShiftSlotStatus; + note: string; + generated: boolean; + occupied: number; + employees: { employee_id: number; name: string }[]; +} + +export interface EmployeeWithRate extends Employee { + hourly_rate: number; +} + +export type ClosureStatus = 'waiting_employee' | 'confirmed' | 'locked'; + +export interface MonthClosure { + id: number; + employee_id: number; + period: string; + status: ClosureStatus; + employee_confirmed_at: string | null; + locked_at: string | null; +} + +export interface ClosureSummary { + worked_minutes: number; + shift_count: number; + earned_estimate: number; +} + +export type PayrollStatus = 'draft' | 'ready' | 'paid'; + +export interface Payroll { + id: number; + employee_id: number; + period: string; + worked_minutes: number; + base_amount: number; + tips_amount: number; + bonus_amount: number; + other_amount: number; + final_amount: number; + status: PayrollStatus; + payment_date: string | null; + updated_at: string; +} + +export interface PayrollOverviewRow { + employee_id: number; + employee_name: string; + closure_status: ClosureStatus; + worked_minutes: number; + base_amount: number; + tips_amount: number; + bonus_amount: number; + other_amount: number; + final_amount: number; + payroll_status: PayrollStatus | null; + payment_date: string | null; +} diff --git a/frontend/src/components/ClosureCard.tsx b/frontend/src/components/ClosureCard.tsx new file mode 100644 index 0000000..bba3364 --- /dev/null +++ b/frontend/src/components/ClosureCard.tsx @@ -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 = { + 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 ( +
+

Uzávěrka měsíce

+ + {error &&
{error}
} + + {!closure || !summary ? ( +
> načítám…
+ ) : ( + <> +
+
+
{formatDuration(summary.worked_minutes * 60_000)}
+
odpracováno
+
+
+
{summary.shift_count}
+
směn
+
+
+
{summary.earned_estimate} Kč
+
odhad výdělku
+
+
+ +
+ + {STATUS_LABEL[closure.status]} + + {closure.status === 'waiting_employee' && ( + + )} +
+ + )} +
+ ); +} diff --git a/frontend/src/components/EmployeeManager.tsx b/frontend/src/components/EmployeeManager.tsx index 2d4d0df..00c76d1 100644 --- a/frontend/src/components/EmployeeManager.tsx +++ b/frontend/src/components/EmployeeManager.tsx @@ -9,6 +9,7 @@ export function EmployeeManager() { const loadEmployees = useAdminStore((s) => s.loadEmployees); const addEmployee = useAdminStore((s) => s.addEmployee); const removeEmployee = useAdminStore((s) => s.removeEmployee); + const setRate = useAdminStore((s) => s.setRate); const [email, setEmail] = useState(''); @@ -46,27 +47,65 @@ export function EmployeeManager() { {employees === null &&
> načítám…
} {employees?.length === 0 &&
· zatím žádní zaměstnanci
} {employees?.map((emp) => ( -
-
-
{emp.name ?? emp.email}
-
- {emp.email} · od {formatDate(emp.created_at)} -
-
-
- {emp.active ? ( - - ) : ( - - NEAKTIVNÍ - - )} -
-
+ ))} ); } + +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 ( +
+
+
{employee.name ?? employee.email}
+
+ {employee.email} · od {formatDate(employee.created_at)} +
+
+
+ + {dirty && ( + + )} + {employee.active ? ( + + ) : ( + + NEAKTIVNÍ + + )} +
+
+ ); +} diff --git a/frontend/src/components/MonthCalendar.tsx b/frontend/src/components/MonthCalendar.tsx new file mode 100644 index 0000000..2e4e20c --- /dev/null +++ b/frontend/src/components/MonthCalendar.tsx @@ -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 ( +
+
+
+ {WEEKDAY_LABELS.map((label) => ( +
{label}
+ ))} +
+
+ {cells.map((dateStr, i) => ( +
+ {dateStr && ( + <> +
{Number(dateStr.slice(8, 10))}
+ {renderDay(dateStr, Number(dateStr.slice(8, 10)))} + + )} +
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/components/PayrollManager.tsx b/frontend/src/components/PayrollManager.tsx new file mode 100644 index 0000000..a88041c --- /dev/null +++ b/frontend/src/components/PayrollManager.tsx @@ -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 = { + waiting_employee: 'čeká na zaměstnance', + confirmed: 'potvrzeno', + locked: 'uzamčeno', +}; + +const PAYROLL_LABEL: Record = { + 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 ( +
+
+

Mzdy

+ + Export CSV + +
+ + {error &&
{error}
} + +
+ {rows === null &&
> načítám…
} + {rows?.length === 0 &&
· žádní zaměstnanci
} + {rows?.map((row) => ( + saveAdjustments(row.employee_id, data)} + onLock={() => lock(row.employee_id)} + onReopen={() => reopen(row.employee_id)} + onMarkPaid={() => markPaid(row.employee_id)} + /> + ))} +
+
+ ); +} + +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 ( +
+
+
+ {row.employee_name} {CLOSURE_LABEL[row.closure_status]} + {row.payroll_status && {PAYROLL_LABEL[row.payroll_status]}} +
+
+ {formatDuration(row.worked_minutes * 60_000)} · základ {row.base_amount} Kč · celkem{' '} + {row.final_amount} Kč +
+ + {editable && ( +
+ + + + {dirty && ( + + )} + {row.closure_status === 'confirmed' && ( + + )} +
+ )} + + {row.payroll_status === 'ready' && ( +
+ + +
+ )} + + {row.payroll_status === 'paid' && ( +
+ vyplaceno {row.payment_date} +
+ )} +
+
+ ); +} + +function AmountField({ + label, + value, + onChange, + busy, +}: { + label: string; + value: string; + onChange: (value: string) => void; + busy: boolean; +}) { + return ( + + ); +} diff --git a/frontend/src/components/ShiftPlanManager.tsx b/frontend/src/components/ShiftPlanManager.tsx new file mode 100644 index 0000000..d92e426 --- /dev/null +++ b/frontend/src/components/ShiftPlanManager.tsx @@ -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(null); + + useEffect(() => { + load(); + if (!employees) loadEmployees(); + }, [load, loadEmployees, employees]); + + const slotsByDate = useMemo(() => { + const map = new Map(); + 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 ( + + ); + } + + const daySlots = selectedDate ? slotsByDate.get(selectedDate) ?? [] : []; + + return ( +
+

Plán směn

+ + {error &&
{error}
} + + {slots === null ? ( +
> načítám…
+ ) : ( + + )} + +
+

{selectedDate ? formatDateOnly(selectedDate) : 'Vyber den v kalendáři'}

+ + {selectedDate && ( + <> +
+ {daySlots.length === 0 &&
· žádné směny
} + {daySlots.map((slot) => ( + updateSlot(slot.id, data)} + onAssign={(employeeId) => assignEmployee(slot.id, employeeId)} + onRemove={(employeeId) => removeEmployee(slot.id, employeeId)} + /> + ))} +
+ +
+ +
+ + )} +
+
+ ); +} + +function NewSlotForm({ + busy, + defaultDate, + onCreate, +}: { + busy: boolean; + defaultDate: string; + onCreate: (data: { date: string; start_time: string; end_time: string; capacity: number; note?: string }) => Promise; +}) { + 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 ( +
+ setDate(e.target.value)} disabled={busy} required /> + setStartTime(e.target.value)} + disabled={busy} + required + style={{ width: '6rem' }} + /> + setEndTime(e.target.value)} + disabled={busy} + required + style={{ width: '6rem' }} + /> + setCapacity(e.target.value)} + disabled={busy} + style={{ width: '4rem' }} + /> + setNote(e.target.value)} + disabled={busy} + style={{ flex: 1, minWidth: '8rem' }} + /> + +
+ ); +} + +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; + onAssign: (employeeId: number) => Promise; + onRemove: (employeeId: number) => Promise; +}) { + 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 ( +
+
+
+ {slot.start_time}–{slot.end_time}{' '} + + {slot.status === 'open' ? 'volno' : slot.status === 'full' ? 'obsazeno' : 'uzavřeno'} + +
+
+ {slot.employees.length === 0 ? '· nikdo přihlášen' : slot.employees.map((e) => e.name).join(', ')} +
+
+ setCapacity(e.target.value)} + disabled={busy} + style={{ width: '3.5rem' }} + /> + setNote(e.target.value)} + disabled={busy} + style={{ width: '10rem' }} + /> + {dirty && ( + + )} + + {slot.employees.map((e) => ( + + {e.name}{' '} + + + ))} + + {candidates.length > 0 && ( + <> + + + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/ShiftPlanning.tsx b/frontend/src/components/ShiftPlanning.tsx new file mode 100644 index 0000000..1e848a3 --- /dev/null +++ b/frontend/src/components/ShiftPlanning.tsx @@ -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(); + 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) => ( + + ))} + {entry.available.map((slot) => ( + + ))} + + ); + } + + return ( +
+

Plánování směn

+ + {error &&
{error}
} + + {available === null || mine === null ? ( +
> načítám…
+ ) : ( + + )} + +

+ Tučně = moje směna, klikni pro zrušení · ostatní = volná směna, klikni pro přihlášení +

+
+ ); +} diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index f529f13..5cec4d6 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -20,3 +20,13 @@ export function formatDate(iso: string): string { export function formatDateTime(iso: string): string { return `${formatDate(iso)} ${formatTime(iso)}`; } + +/** Formats a "YYYY-MM-DD" date-only string, parsed as a local date (not UTC) so it never shifts by a day. */ +export function formatDateOnly(dateOnly: string): string { + const [year, month, day] = dateOnly.split('-').map(Number); + return new Date(year, month - 1, day).toLocaleDateString('cs-CZ', { + weekday: 'short', + day: '2-digit', + month: '2-digit', + }); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f8d99e9..d2709df 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,6 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import './styles/tui.css'; +import './styles/theme.css'; import App from './App.tsx'; createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/pages/AdminApp.tsx b/frontend/src/pages/AdminApp.tsx index 26547c8..434d4c0 100644 --- a/frontend/src/pages/AdminApp.tsx +++ b/frontend/src/pages/AdminApp.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from 'react'; import { EmployeeManager } from '../components/EmployeeManager'; import { AdminStats } from '../components/AdminStats'; +import { ShiftPlanManager } from '../components/ShiftPlanManager'; +import { PayrollManager } from '../components/PayrollManager'; -type Tab = 'employees' | 'stats'; +type Tab = 'employees' | 'stats' | 'shifts' | 'payroll'; export function AdminApp() { const [tab, setTab] = useState('employees'); @@ -13,6 +15,8 @@ export function AdminApp() { if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return; if (e.key === '1') setTab('employees'); if (e.key === '2') setTab('stats'); + if (e.key === '3') setTab('shifts'); + if (e.key === '4') setTab('payroll'); } window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); @@ -30,10 +34,19 @@ export function AdminApp() { + +
- {tab === 'employees' ? : } + {tab === 'employees' && } + {tab === 'stats' && } + {tab === 'shifts' && } + {tab === 'payroll' && }
); diff --git a/frontend/src/pages/EmployeeApp.tsx b/frontend/src/pages/EmployeeApp.tsx index f91484b..617af10 100644 --- a/frontend/src/pages/EmployeeApp.tsx +++ b/frontend/src/pages/EmployeeApp.tsx @@ -4,6 +4,8 @@ import { ClockControls } from '../components/ClockControls'; import { StatusBadge } from '../components/StatusBadge'; import { StatsSummary } from '../components/StatsSummary'; import { SessionList } from '../components/SessionList'; +import { ShiftPlanning } from '../components/ShiftPlanning'; +import { ClosureCard } from '../components/ClosureCard'; export function EmployeeApp() { const status = useAttendanceStore((s) => s.status); @@ -53,9 +55,13 @@ export function EmployeeApp() {
-

Směny

+

Odpracované směny

+ + + + ); } diff --git a/frontend/src/store/adminPayrollStore.ts b/frontend/src/store/adminPayrollStore.ts new file mode 100644 index 0000000..1ec6512 --- /dev/null +++ b/frontend/src/store/adminPayrollStore.ts @@ -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; + setPeriod: (period: string) => Promise; + prevPeriod: () => Promise; + nextPeriod: () => Promise; + saveAdjustments: (employeeId: number, data: Adjustments) => Promise; + lock: (employeeId: number) => Promise; + reopen: (employeeId: number) => Promise; + markPaid: (employeeId: number) => Promise; +} + +const useAdminPayrollStore = create((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; diff --git a/frontend/src/store/adminShiftStore.ts b/frontend/src/store/adminShiftStore.ts new file mode 100644 index 0000000..79dde54 --- /dev/null +++ b/frontend/src/store/adminShiftStore.ts @@ -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; + setPeriod: (period: string) => Promise; + prevPeriod: () => Promise; + nextPeriod: () => Promise; + createSlot: (data: SlotFormData) => Promise; + updateSlot: (slotId: number, data: SlotFormData) => Promise; + assignEmployee: (slotId: number, employeeId: number) => Promise; + removeEmployee: (slotId: number, employeeId: number) => Promise; +} + +const useAdminShiftStore = create((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; diff --git a/frontend/src/store/adminStore.ts b/frontend/src/store/adminStore.ts index 30c87c3..00c66d3 100644 --- a/frontend/src/store/adminStore.ts +++ b/frontend/src/store/adminStore.ts @@ -1,10 +1,10 @@ import { create } from 'zustand'; import { api, ApiError } from '../api/client'; -import type { AdminStatsSummary, Employee, StatsSummary } from '../api/types'; +import type { AdminStatsSummary, EmployeeWithRate, StatsSummary } from '../api/types'; import { currentMonthKey, shiftMonthKey } from '../lib/month'; interface AdminState { - employees: Employee[] | null; + employees: EmployeeWithRate[] | null; employeesError: string | null; employeesBusy: boolean; @@ -18,6 +18,7 @@ interface AdminState { loadEmployees: () => Promise; addEmployee: (email: string) => Promise; removeEmployee: (id: number) => Promise; + setRate: (id: number, hourlyRate: number) => Promise; loadStats: () => Promise; selectEmployee: (id: number) => Promise; @@ -40,13 +41,25 @@ const useAdminStore = create((set, get) => ({ loadEmployees: async () => { try { - const res = await api.get<{ employees: Employee[] }>('/admin/employees'); + const res = await api.get<{ employees: EmployeeWithRate[] }>('/admin/employees'); set({ employees: res.employees, employeesError: null }); } catch { set({ employeesError: 'Nepodařilo se načíst zaměstnance' }); } }, + setRate: async (id, hourlyRate) => { + set({ employeesBusy: true, employeesError: null }); + try { + await api.post(`/admin/employees/${id}/rate`, { hourly_rate: hourlyRate }); + await get().loadEmployees(); + } catch (err) { + set({ employeesError: err instanceof ApiError ? err.message : 'Uložení sazby se nezdařilo' }); + } finally { + set({ employeesBusy: false }); + } + }, + addEmployee: async (email) => { set({ employeesBusy: true, employeesError: null }); try { diff --git a/frontend/src/store/closureStore.ts b/frontend/src/store/closureStore.ts new file mode 100644 index 0000000..800daa9 --- /dev/null +++ b/frontend/src/store/closureStore.ts @@ -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; + setPeriod: (period: string) => Promise; + prevPeriod: () => Promise; + nextPeriod: () => Promise; + confirm: () => Promise; +} + +const useClosureStore = create((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; diff --git a/frontend/src/store/shiftPlanningStore.ts b/frontend/src/store/shiftPlanningStore.ts new file mode 100644 index 0000000..d66882a --- /dev/null +++ b/frontend/src/store/shiftPlanningStore.ts @@ -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; + setPeriod: (period: string) => Promise; + prevPeriod: () => Promise; + nextPeriod: () => Promise; + signup: (slotId: number) => Promise; + cancel: (slotId: number) => Promise; +} + +const useShiftPlanningStore = create((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; diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css new file mode 100644 index 0000000..e9e9a51 --- /dev/null +++ b/frontend/src/styles/theme.css @@ -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; +} diff --git a/frontend/src/styles/tui.css b/frontend/src/styles/tui.css deleted file mode 100644 index 3f64f8e..0000000 --- a/frontend/src/styles/tui.css +++ /dev/null @@ -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; -} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4456ef2..94badfb 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -13,8 +13,8 @@ export default defineConfig({ name: 'EatMe — Docházka', short_name: 'EatMe', description: 'Evidence docházky zaměstnanců bistra EatMe', - theme_color: '#000000', - background_color: '#f0f0f0', + theme_color: '#0d0f13', + background_color: '#0d0f13', display: 'standalone', start_url: '/', icons: [ diff --git a/gscript/.clasp.json b/gscript/.clasp.json new file mode 100644 index 0000000..3869702 --- /dev/null +++ b/gscript/.clasp.json @@ -0,0 +1,16 @@ +{ + "scriptId": "1O3j68RUOqdnO4N2-sWgv5seES-Bd-AmZDE5HcDjgz0bTN6-jMwNiVrBj", + "rootDir": "", + "scriptExtensions": [ + ".js", + ".gs" + ], + "htmlExtensions": [ + ".html" + ], + "jsonExtensions": [ + ".json" + ], + "filePushOrder": [], + "skipSubdirectories": false +} \ No newline at end of file diff --git a/gscript/AdminService.js b/gscript/AdminService.js new file mode 100644 index 0000000..4b3c599 --- /dev/null +++ b/gscript/AdminService.js @@ -0,0 +1,1659 @@ +/* ============================================================ + EATME PORTÁL – DASHBOARD SERVICE + Optimalizovaná verze + + Cíle: + - zachovat stejné veřejné funkce + - zrychlit opakované čtení stabilnějších tabulek + - necacheovat živé SHIFTS + - předindexovat PAY_RATES podle zaměstnance +============================================================ */ + + +/* ============================================================ + KRÁTKODOBÁ CACHE PRO STABILNĚJŠÍ TABULKY + + SHIFTS záměrně necachujeme, protože dashboard má ukazovat + příchody/odchody co nejčerstvěji. +============================================================ */ + +const DASHBOARD_CACHE_TTL_SECONDS = 20; + + +/* ============================================================ + CACHE V RÁMCI JEDNOHO APPS SCRIPT BĚHU + + Zapíná se pouze pro agregované read-only requesty. + Díky tomu může více služeb ve stejném requestu použít stejná + data bez dalšího čtení Google Sheets. +============================================================ */ + +let REQUEST_ROW_CACHE_ENABLED = + false; + + +let REQUEST_ROW_CACHE = + {}; + + +function beginRequestRowCache_() { + + REQUEST_ROW_CACHE_ENABLED = + true; + + + REQUEST_ROW_CACHE = + {}; + +} + + +function endRequestRowCache_() { + + REQUEST_ROW_CACHE_ENABLED = + false; + + + REQUEST_ROW_CACHE = + {}; + +} + + +function clearRequestRowCache_( + sheetName +) { + + if ( + !sheetName + ) { + + REQUEST_ROW_CACHE = + {}; + + return; + + } + + + delete REQUEST_ROW_CACHE[ + String( + sheetName + ) + ]; + +} + + +/* ============================================================ + ADMIN DASHBOARD +============================================================ */ + +function getAdminDashboard(token) { + + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + /* + * EMPLOYEES, PAY_RATES a žádosti se nemění každou sekundu, + * proto mohou mít krátkou cache. + * + * SHIFTS vždy čteme čerstvě. + */ + + const employees = + displayRowsCached_( + CFG.SHEETS.EMPLOYEES, + DASHBOARD_CACHE_TTL_SECONDS + ); + + + const shifts = + displayRows_( + CFG.SHEETS.SHIFTS + ); + + + const payRates = + displayRowsCached_( + CFG.SHEETS.PAY_RATES, + DASHBOARD_CACHE_TTL_SECONDS + ); + + + const changeRequests = + displayRowsCached_( + CFG.SHEETS.SHIFT_CHANGE_REQUESTS, + 10 + ); + + + const requests = + displayRowsCached_( + CFG.SHEETS.REQUESTS, + 10 + ); + + + /* ========================================= + AKTIVNÍ ZAMĚSTNANCI + ========================================= */ + + const activeEmployees = + employees.filter( + function(employee) { + + return isActiveValue_( + employee.active + ); + + } + ); + + + /* ========================================= + MAPA ZAMĚSTNANCŮ PODLE ID + ========================================= */ + + const employeeMap = + {}; + + + employees.forEach( + function(employee) { + + employeeMap[ + String( + employee.employee_id + ) + ] = + employee; + + } + ); + + + /* ========================================= + INDEX SAZEB PODLE ZAMĚSTNANCE + + Původní verze pro každou směnu znovu + filtrovala celý PAY_RATES. Tady to + připravíme jednou. + ========================================= */ + + const rateIndex = + buildRateIndex_( + payRates + ); + + + /* ========================================= + AKTUÁLNÍ MĚSÍC + ========================================= */ + + const now = + new Date(); + + + const currentPeriod = + Utilities.formatDate( + now, + CFG.TZ, + 'yyyy-MM' + ); + + + /* ========================================= + OTEVŘENÉ SMĚNY + ========================================= */ + + const openShifts = + []; + + + const monthShifts = + []; + + + /* + * Jediný průchod přes SHIFTS. + * Původně se tabulka filtrovala vícekrát. + */ + + shifts.forEach( + function(shift) { + + const status = + String( + shift.status || + '' + ).trim(); + + + if ( + status === + 'OPEN' + ) { + + openShifts.push( + shift + ); + + } + + + const date = + parseSheetDate_( + shift.clock_in + ); + + + if ( + !date + ) { + + return; + + } + + + const shiftPeriod = + Utilities.formatDate( + date, + CFG.TZ, + 'yyyy-MM' + ); + + + if ( + shiftPeriod === + currentPeriod + ) { + + monthShifts.push( + shift + ); + + } + + } + ); + + + /* ========================================= + SOUČTY + ========================================= */ + + let totalWorkedMinutes = + 0; + + + let estimatedPayroll = + 0; + + + monthShifts.forEach( + function(shift) { + + const workedMinutes = + numberFromSheet_( + shift.worked_minutes + ); + + + totalWorkedMinutes += + workedMinutes; + + + if ( + workedMinutes <= + 0 + ) { + + return; + + } + + + const shiftDate = + parseSheetDate_( + shift.clock_in + ); + + + if ( + !shiftDate + ) { + + return; + + } + + + const hourlyRate = + getIndexedRateForDate_( + rateIndex, + shift.employee_id, + shiftDate + ); + + + estimatedPayroll += + ( + workedMinutes / + 60 + ) * + hourlyRate; + + } + ); + + + /* ========================================= + KDO JE PRÁVĚ V PRÁCI + ========================================= */ + + const nowMs = + Date.now(); + + + const openPeople = + openShifts.map( + function(shift) { + + const employee = + employeeMap[ + String( + shift.employee_id + ) + ]; + + + const clockIn = + parseSheetDate_( + shift.clock_in + ); + + + let currentMinutes = + 0; + + + if ( + clockIn + ) { + + currentMinutes = + Math.max( + 0, + Math.floor( + ( + nowMs - + clockIn.getTime() + ) / + 60000 + ) + ); + + } + + + let employeeName = + String( + shift.employee_id || + '' + ); + + + if ( + employee + ) { + + employeeName = + ( + String( + employee.first_name || + '' + ) + + ' ' + + String( + employee.last_name || + '' + ) + ).trim(); + + } + + + return { + + shift_id: + String( + shift.shift_id || + '' + ), + + employee_id: + String( + shift.employee_id || + '' + ), + + name: + employeeName, + + location_id: + String( + shift.location_id || + '' + ), + + clock_in: + clockIn + ? clockIn.toISOString() + : '', + + current_minutes: + currentMinutes + + }; + + } + ); + + + /* ========================================= + ČEKAJÍCÍ OPRAVY + ========================================= */ + + let pendingChangeRequests = + 0; + + + changeRequests.forEach( + function(request) { + + if ( + String( + request.status || + '' + ).trim() === + 'PENDING' + ) { + + pendingChangeRequests++; + + } + + } + ); + + + /* ========================================= + OSTATNÍ ČEKAJÍCÍ ŽÁDOSTI + ========================================= */ + + let pendingRequests = + 0; + + + requests.forEach( + function(request) { + + if ( + String( + request.status || + '' + ).trim() === + 'PENDING' + ) { + + pendingRequests++; + + } + + } + ); + + + /* ========================================= + PODEZŘELE DLOUHÉ SMĚNY + ========================================= */ + + let staleOpenShifts = + 0; + + + openShifts.forEach( + function(shift) { + + const clockIn = + parseSheetDate_( + shift.clock_in + ); + + + if ( + !clockIn + ) { + + return; + + } + + + const hours = + ( + nowMs - + clockIn.getTime() + ) / + 3600000; + + + if ( + hours > + 14 + ) { + + staleOpenShifts++; + + } + + } + ); + + + /* ========================================= + VÝSLEDEK PRO FRONTEND + ========================================= */ + + return { + + current_period: + currentPeriod, + + + stats: { + + active_employees: + activeEmployees.length, + + open_shifts: + openShifts.length, + + worked_minutes: + Math.round( + totalWorkedMinutes + ), + + estimated_payroll: + Math.round( + estimatedPayroll + ), + + pending_change_requests: + pendingChangeRequests, + + pending_requests: + pendingRequests, + + stale_open_shifts: + staleOpenShifts + + }, + + + open_people: + openPeople + + }; + +} + + + +/* ============================================================ + ČTENÍ TABULKY JAKO TEXTŮ + + Tato funkce zůstává bez cache, protože ji používá celý projekt. + Tím minimalizujeme riziko, že nějaký zápis nebude okamžitě vidět. +============================================================ */ + +function displayRows_(sheetName) { + + const cacheKey = + String( + sheetName + ); + + + if ( + REQUEST_ROW_CACHE_ENABLED && + Object.prototype.hasOwnProperty.call( + REQUEST_ROW_CACHE, + cacheKey + ) + ) { + + return REQUEST_ROW_CACHE[ + cacheKey + ]; + + } + + + const sheet = + sh_( + sheetName + ); + + + const lastRow = + sheet.getLastRow(); + + + const lastColumn = + sheet.getLastColumn(); + + + if ( + lastRow < + 2 || + lastColumn < + 1 + ) { + + if ( + REQUEST_ROW_CACHE_ENABLED + ) { + + REQUEST_ROW_CACHE[ + cacheKey + ] = + []; + + } + + + return []; + + } + + + const values = + sheet + .getRange( + 1, + 1, + lastRow, + lastColumn + ) + .getDisplayValues(); + + + const headers = + values[0] + .map( + function(header) { + + return String( + header + ).trim(); + + } + ); + + + const result = + []; + + + for ( + let rowIndex = 1; + rowIndex < values.length; + rowIndex++ + ) { + + const row = + values[ + rowIndex + ]; + + + let hasValue = + false; + + + for ( + let i = 0; + i < row.length; + i++ + ) { + + if ( + String( + row[i] + ).trim() !== + '' + ) { + + hasValue = + true; + + break; + + } + + } + + + if ( + !hasValue + ) { + + continue; + + } + + + const object = + {}; + + + for ( + let columnIndex = 0; + columnIndex < headers.length; + columnIndex++ + ) { + + const header = + headers[ + columnIndex + ]; + + + if ( + !header + ) { + + continue; + + } + + + object[ + header + ] = + row[ + columnIndex + ]; + + } + + + result.push( + object + ); + + } + + + if ( + REQUEST_ROW_CACHE_ENABLED + ) { + + REQUEST_ROW_CACHE[ + cacheKey + ] = + result; + + } + + + return result; + +} + + + +/* ============================================================ + KRÁTKODOBĚ CACHOVANÉ ČTENÍ + + Používá se pouze tam, kde malé zpoždění nevadí. +============================================================ */ + +function displayRowsCached_( + sheetName, + ttlSeconds +) { + + ttlSeconds = + Number( + ttlSeconds || + DASHBOARD_CACHE_TTL_SECONDS + ); + + + const cache = + CacheService + .getScriptCache(); + + + const key = + 'DISPLAY_ROWS_V1_' + + String( + sheetName + ); + + + try { + + const cached = + cache.get( + key + ); + + + if ( + cached + ) { + + return JSON.parse( + cached + ); + + } + + } + + catch(error) { + + /* + * Cache nesmí nikdy rozbít aplikaci. + * Při chybě pokračujeme normálním čtením. + */ + + } + + + const rows = + displayRows_( + sheetName + ); + + + /* + * CacheService má limit velikosti jedné hodnoty. + * Když je tabulka moc velká, zápis jen přeskočíme. + */ + + try { + + const json = + JSON.stringify( + rows + ); + + + if ( + json.length < + 90000 + ) { + + cache.put( + key, + json, + ttlSeconds + ); + + } + + } + + catch(error) { + + /* + * Opět ignorujeme pouze chybu cache. + */ + + } + + + return rows; + +} + + + +/* ============================================================ + RUČNÍ SMAZÁNÍ DASHBOARD CACHE + + Lze spustit ručně při testování. +============================================================ */ + +function clearDashboardCache() { + + const cache = + CacheService + .getScriptCache(); + + + [ + CFG.SHEETS.EMPLOYEES, + CFG.SHEETS.PAY_RATES, + CFG.SHEETS.SHIFT_CHANGE_REQUESTS, + CFG.SHEETS.REQUESTS + ] + .forEach( + function(sheetName) { + + cache.remove( + 'DISPLAY_ROWS_V1_' + + String( + sheetName + ) + ); + + } + ); + + + Logger.log( + 'Dashboard cache cleared' + ); + +} + + + +/* ============================================================ + INDEX SAZEB +============================================================ */ + +function buildRateIndex_(rates) { + + const index = + {}; + + + rates.forEach( + function(rate) { + + const employeeId = + String( + rate.employee_id || + '' + ); + + + if ( + !employeeId + ) { + + return; + + } + + + if ( + !index[ + employeeId + ] + ) { + + index[ + employeeId + ] = + []; + + } + + + const fromDate = + parseSheetDate_( + rate.valid_from + ); + + + const toDate = + parseSheetDate_( + rate.valid_to + ); + + + index[ + employeeId + ].push( + { + + from: + fromDate + ? Utilities.formatDate( + fromDate, + CFG.TZ, + 'yyyy-MM-dd' + ) + : '0000-00-00', + + to: + toDate + ? Utilities.formatDate( + toDate, + CFG.TZ, + 'yyyy-MM-dd' + ) + : '9999-12-31', + + rate: + numberFromSheet_( + rate.hourly_rate + ) + + } + ); + + } + ); + + + Object.keys( + index + ) + .forEach( + function(employeeId) { + + index[ + employeeId + ].sort( + function(a,b) { + + return b.from.localeCompare( + a.from + ); + + } + ); + + } + ); + + + return index; + +} + + + +/* ============================================================ + SAZBA Z INDEXU +============================================================ */ + +function getIndexedRateForDate_( + rateIndex, + employeeId, + targetDate +) { + + if ( + !targetDate + ) { + + return 0; + + } + + + const employeeRates = + rateIndex[ + String( + employeeId + ) + ] || + []; + + + if ( + !employeeRates.length + ) { + + return 0; + + } + + + const target = + Utilities.formatDate( + targetDate, + CFG.TZ, + 'yyyy-MM-dd' + ); + + + for ( + let i = 0; + i < employeeRates.length; + i++ + ) { + + const rate = + employeeRates[ + i + ]; + + + if ( + rate.from <= + target && + rate.to >= + target + ) { + + return rate.rate; + + } + + } + + + return 0; + +} + + + +/* ============================================================ + AKTIVNÍ HODNOTA +============================================================ */ + +function isActiveValue_(value) { + + const text = + String( + value || + '' + ) + .trim() + .toUpperCase(); + + + return ( + text === + 'TRUE' || + + text === + 'ANO' || + + text === + 'YES' || + + text === + '1' + ); + +} + + + +/* ============================================================ + ČÍSLO Z GOOGLE SHEETS +============================================================ */ + +function numberFromSheet_(value) { + + let text = + String( + value || + '0' + ) + .trim() + .replace( + /\s/g, + '' + ) + .replace( + ',', + '.' + ); + + + text = + text.replace( + /[^0-9.\-]/g, + '' + ); + + + const number = + Number( + text + ); + + + return isNaN( + number + ) + ? 0 + : number; + +} + + + +/* ============================================================ + PARSOVÁNÍ DATUMU +============================================================ */ + +function parseSheetDate_(value) { + + const text = + String( + value || + '' + ).trim(); + + + if ( + !text + ) { + + return null; + + } + + + let match; + + + /* ========================================= + ISO FORMÁT + + 2026-08-11 + 2026-08-11 23:44:56 + ========================================= */ + + match = + text.match( + /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/ + ); + + + if ( + match + ) { + + return new Date( + Number( + match[1] + ), + Number( + match[2] + ) - 1, + Number( + match[3] + ), + Number( + match[4] || + 0 + ), + Number( + match[5] || + 0 + ), + Number( + match[6] || + 0 + ) + ); + + } + + + /* ========================================= + ČESKÝ FORMÁT + + 11.8.2026 + 11.8.2026 23:44:56 + ========================================= */ + + match = + text.match( + /^(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/ + ); + + + if ( + match + ) { + + return new Date( + Number( + match[3] + ), + Number( + match[2] + ) - 1, + Number( + match[1] + ), + Number( + match[4] || + 0 + ), + Number( + match[5] || + 0 + ), + Number( + match[6] || + 0 + ) + ); + + } + + + /* ========================================= + GOOGLE / US FORMÁT + + 8/11/2026 + 8/11/2026 23:44:56 + + měsíc / den / rok + ========================================= */ + + match = + text.match( + /^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/ + ); + + + if ( + match + ) { + + return new Date( + Number( + match[3] + ), + Number( + match[1] + ) - 1, + Number( + match[2] + ), + Number( + match[4] || + 0 + ), + Number( + match[5] || + 0 + ), + Number( + match[6] || + 0 + ) + ); + + } + + + /* ========================================= + POSLEDNÍ POKUS + ========================================= */ + + const date = + new Date( + text + ); + + + if ( + isNaN( + date.getTime() + ) + ) { + + return null; + + } + + + return date; + +} + + + +/* ============================================================ + PŮVODNÍ VEŘEJNÁ FUNKCE PRO SAZBU + + Zachována kvůli kompatibilitě s ostatními moduly. +============================================================ */ + +function getDisplayRateForDate_( + rates, + employeeId, + targetDate +) { + + if ( + !targetDate + ) { + + return 0; + + } + + + const rateIndex = + buildRateIndex_( + rates + ); + + + return getIndexedRateForDate_( + rateIndex, + employeeId, + targetDate + ); + +} + + + +/* ============================================================ + DIAGNOSTIKA DATABÁZE +============================================================ */ + +function testDashboardDatabase() { + + const started = + Date.now(); + + + const employees = + displayRows_( + CFG.SHEETS.EMPLOYEES + ); + + + const afterEmployees = + Date.now(); + + + const shifts = + displayRows_( + CFG.SHEETS.SHIFTS + ); + + + const afterShifts = + Date.now(); + + + Logger.log( + 'EMPLOYEES: ' + + employees.length + + ' | ' + + ( + afterEmployees - + started + ) + + ' ms' + ); + + + Logger.log( + 'SHIFTS: ' + + shifts.length + + ' | ' + + ( + afterShifts - + afterEmployees + ) + + ' ms' + ); + + + Logger.log( + 'TOTAL: ' + + ( + afterShifts - + started + ) + + ' ms' + ); + + + return { + + employees: + employees.length, + + shifts: + shifts.length, + + employees_ms: + afterEmployees - + started, + + shifts_ms: + afterShifts - + afterEmployees, + + total_ms: + afterShifts - + started + + }; + +} + + + +/* ============================================================ + DIAGNOSTIKA ADMIN DASHBOARDU +============================================================ */ + +function testAdminDashboardPerformance() { + + const started = + Date.now(); + + + /* + * Test vyžaduje validní token při volání přes frontend, + * proto zde měříme pouze datovou část. + */ + + const employees = + displayRowsCached_( + CFG.SHEETS.EMPLOYEES, + DASHBOARD_CACHE_TTL_SECONDS + ); + + + const shifts = + displayRows_( + CFG.SHEETS.SHIFTS + ); + + + const payRates = + displayRowsCached_( + CFG.SHEETS.PAY_RATES, + DASHBOARD_CACHE_TTL_SECONDS + ); + + + const rateIndex = + buildRateIndex_( + payRates + ); + + + const finished = + Date.now(); + + + Logger.log( + 'EMPLOYEES: ' + + employees.length + ); + + + Logger.log( + 'SHIFTS: ' + + shifts.length + ); + + + Logger.log( + 'PAY_RATES: ' + + payRates.length + ); + + + Logger.log( + 'RATE INDEX EMPLOYEES: ' + + Object.keys( + rateIndex + ).length + ); + + + Logger.log( + 'TOTAL: ' + + ( + finished - + started + ) + + ' ms' + ); + + + return { + + total_ms: + finished - + started, + + employees: + employees.length, + + shifts: + shifts.length, + + pay_rates: + payRates.length + + }; + +} diff --git a/gscript/App.js b/gscript/App.js new file mode 100644 index 0000000..e7d8810 --- /dev/null +++ b/gscript/App.js @@ -0,0 +1,6 @@ +function doGet() { + return HtmlService.createTemplateFromFile('Index') + .evaluate() + .setTitle(CFG.APP_NAME) + .addMetaTag('viewport','width=device-width, initial-scale=1'); +} \ No newline at end of file diff --git a/gscript/AttendanceCorrectionAdminService.js b/gscript/AttendanceCorrectionAdminService.js new file mode 100644 index 0000000..f3ad646 --- /dev/null +++ b/gscript/AttendanceCorrectionAdminService.js @@ -0,0 +1,1344 @@ +/* ============================================================ + EATME PORTÁL – ATTENDANCE CORRECTION ADMIN SERVICE + + Admin / manager: + - seznam čekajících žádostí o opravu docházky + - schválení -> přepis SHIFTS + přepočet minut + - zamítnutí -> pouze změna stavu žádosti + - uzamčenou směnu nelze měnit +============================================================ */ + + +/* ============================================================ + SEZNAM ŽÁDOSTÍ +============================================================ */ + +function getPendingAttendanceCorrections(token) { + + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + const requests = + displayRows_( + CFG.SHEETS.SHIFT_CHANGE_REQUESTS + ); + + + const shifts = + displayRows_( + CFG.SHEETS.SHIFTS + ); + + + const employees = + displayRows_( + CFG.SHEETS.EMPLOYEES + ); + + + const shiftMap = + {}; + + + shifts.forEach( + function(shift) { + + shiftMap[ + String( + shift.shift_id || + '' + ) + ] = + shift; + + } + ); + + + const employeeMap = + {}; + + + employees.forEach( + function(employee) { + + employeeMap[ + String( + employee.employee_id || + '' + ) + ] = + employee; + + } + ); + + + /* + * Jedna oprava může být v SHIFT_CHANGE_REQUESTS uložena + * jako více řádků (např. clock_in + clock_out). + * Seskládáme je podle request_id. + */ + + const groups = + {}; + + + requests.forEach( + function(request,index) { + + if ( + String( + request.status || + '' + ).trim() !== + 'PENDING' + ) { + + return; + + } + + + const groupId = + String( + request.request_id || + '' + ) || + ( + 'ROW_' + + index + ); + + + if ( + !groups[ + groupId + ] + ) { + + groups[ + groupId + ] = + { + + request_id: + groupId, + + shift_id: + String( + request.shift_id || + '' + ), + + employee_id: + String( + request.employee_id || + '' + ), + + reason: + String( + request.reason || + '' + ), + + created_at: + String( + request.created_at || + '' + ), + + fields: + [], + + rows: + [] + + }; + + } + + + groups[ + groupId + ].rows.push( + request + ); + + + groups[ + groupId + ].fields.push( + { + + field: + String( + request.field || + '' + ), + + old_value: + String( + request.old_value || + '' + ), + + requested_value: + String( + request.requested_value || + '' + ) + + } + ); + + + if ( + !groups[ + groupId + ].reason && + request.reason + ) { + + groups[ + groupId + ].reason = + String( + request.reason + ); + + } + + } + ); + + + const result = + Object.keys( + groups + ) + .map( + function(groupId) { + + const group = + groups[ + groupId + ]; + + + const shift = + shiftMap[ + String( + group.shift_id + ) + ] || + null; + + + const employeeId = + group.employee_id || + ( + shift + ? String( + shift.employee_id || + '' + ) + : '' + ); + + + const employee = + employeeMap[ + employeeId + ] || + null; + + + const requested = + attendanceCorrectionRequestedValues_( + group.fields + ); + + + return { + + request_id: + group.request_id, + + shift_id: + group.shift_id, + + employee_id: + employeeId, + + employee_name: + employee + ? ( + String( + employee.first_name || + '' + ) + + ' ' + + String( + employee.last_name || + '' + ) + ).trim() + : employeeId, + + work_date: + shift + ? String( + shift.work_date || + '' + ) + : '', + + current_clock_in: + shift + ? String( + shift.clock_in || + '' + ) + : '', + + current_clock_out: + shift + ? String( + shift.clock_out || + '' + ) + : '', + + requested_clock_in: + requested.clock_in, + + requested_clock_out: + requested.clock_out, + + reason: + group.reason, + + created_at: + group.created_at, + + shift_status: + shift + ? String( + shift.status || + '' + ) + : '', + + locked: + shift + ? String( + shift.status || + '' + ).toUpperCase() === + 'LOCKED' + : false + + }; + + } + ) + .sort( + function(a,b) { + + return String( + b.created_at || + '' + ).localeCompare( + String( + a.created_at || + '' + ) + ); + + } + ); + + + return { + + count: + result.length, + + rows: + result + + }; + +} + + + +/* ============================================================ + SCHVÁLENÍ ŽÁDOSTI +============================================================ */ + +function approveAttendanceCorrection( + token, + requestId +) { + + const user = + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + const lock = + LockService + .getScriptLock(); + + + lock.waitLock( + 10000 + ); + + + try { + + const requests = + displayRows_( + CFG.SHEETS.SHIFT_CHANGE_REQUESTS + ); + + + const requestRows = + requests.filter( + function(request) { + + return ( + String( + request.request_id || + '' + ) === + String( + requestId + ) && + + String( + request.status || + '' + ).trim() === + 'PENDING' + ); + + } + ); + + + if ( + !requestRows.length + ) { + + throw new Error( + 'Žádost nebyla nalezena nebo už byla vyřešena.' + ); + + } + + + const shiftId = + String( + requestRows[0].shift_id || + '' + ); + + + const shifts = + displayRows_( + CFG.SHEETS.SHIFTS + ); + + + const shift = + shifts.find( + function(row) { + + return ( + String( + row.shift_id || + '' + ) === + shiftId + ); + + } + ); + + + if ( + !shift + ) { + + throw new Error( + 'Směna uvedená v žádosti nebyla nalezena.' + ); + + } + + + if ( + String( + shift.status || + '' + ).toUpperCase() === + 'LOCKED' + ) { + + throw new Error( + 'Tato směna je už uzamčená a předaná do mzdy. Nejdřív je nutné řešit mzdovou uzávěrku.' + ); + + } + + + const requested = + attendanceCorrectionRequestedValues_( + requestRows.map( + function(request) { + + return { + + field: + request.field, + + old_value: + request.old_value, + + requested_value: + request.requested_value + + }; + + } + ) + ); + + + let newClockIn = + requested.clock_in || + String( + shift.clock_in || + '' + ); + + + let newClockOut = + requested.clock_out || + String( + shift.clock_out || + '' + ); + + + const clockInDate = + parseAttendanceCorrectionDate_( + newClockIn + ); + + + const clockOutDate = + parseAttendanceCorrectionDate_( + newClockOut + ); + + + if ( + !clockInDate + ) { + + throw new Error( + 'Navrhovaný čas příchodu není platný.' + ); + + } + + + if ( + newClockOut && + !clockOutDate + ) { + + throw new Error( + 'Navrhovaný čas odchodu není platný.' + ); + + } + + + if ( + clockOutDate && + clockOutDate <= + clockInDate + ) { + + throw new Error( + 'Odchod musí být později než příchod.' + ); + + } + + + const breakMinutes = + correctionNumber_( + shift.break_minutes + ); + + + let workedMinutes = + correctionNumber_( + shift.worked_minutes + ); + + + let nightMinutes = + correctionNumber_( + shift.night_minutes + ); + + + if ( + clockOutDate + ) { + + workedMinutes = + Math.max( + 0, + Math.round( + ( + clockOutDate.getTime() - + clockInDate.getTime() + ) / + 60000 + ) - + breakMinutes + ); + + + nightMinutes = + calculateCorrectionNightMinutes_( + clockInDate, + clockOutDate + ); + + } + + + const workDate = + Utilities.formatDate( + clockInDate, + CFG.TZ, + 'yyyy-MM-dd' + ); + + + const update = + { + + clock_in: + formatAttendanceCorrectionDate_( + clockInDate + ), + + work_date: + workDate, + + worked_minutes: + workedMinutes, + + night_minutes: + nightMinutes, + + updated_at: + now_() + + }; + + + if ( + clockOutDate + ) { + + update.clock_out = + formatAttendanceCorrectionDate_( + clockOutDate + ); + + + /* + * Pokud měla směna odchod a není uzamčená, + * má být po opravě uzavřená. + */ + + if ( + String( + shift.status || + '' + ) === + 'OPEN' + ) { + + update.status = + 'COMPLETED'; + + } + + } + + + updateBy_( + CFG.SHEETS.SHIFTS, + 'shift_id', + shiftId, + update + ); + + + requestRows.forEach( + function(request) { + + updateBy_( + CFG.SHEETS.SHIFT_CHANGE_REQUESTS, + 'request_id', + request.request_id, + { + + status: + 'APPROVED', + + resolved_at: + now_(), + + resolved_by: + user.user_id + + } + ); + + } + ); + + + audit_( + user.user_id, + 'ATTENDANCE_CORRECTION_APPROVED', + 'SHIFT', + shiftId, + { + + clock_in: + shift.clock_in, + + clock_out: + shift.clock_out, + + worked_minutes: + shift.worked_minutes + + }, + { + + clock_in: + update.clock_in, + + clock_out: + update.clock_out || + shift.clock_out, + + worked_minutes: + workedMinutes + + } + ); + + + notifyEmployee_( + shift.employee_id, + 'Oprava docházky schválena', + 'Tvoje žádost o opravu docházky ze dne ' + + workDate + + ' byla schválena.' + ); + + + return { + + ok: + true, + + shift_id: + shiftId, + + worked_minutes: + workedMinutes + + }; + + } + + finally { + + lock.releaseLock(); + + } + +} + + + +/* ============================================================ + ZAMÍTNUTÍ ŽÁDOSTI +============================================================ */ + +function rejectAttendanceCorrection( + token, + requestId +) { + + const user = + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + const requests = + displayRows_( + CFG.SHEETS.SHIFT_CHANGE_REQUESTS + ); + + + const requestRows = + requests.filter( + function(request) { + + return ( + String( + request.request_id || + '' + ) === + String( + requestId + ) && + + String( + request.status || + '' + ).trim() === + 'PENDING' + ); + + } + ); + + + if ( + !requestRows.length + ) { + + throw new Error( + 'Žádost nebyla nalezena nebo už byla vyřešena.' + ); + + } + + + requestRows.forEach( + function(request) { + + updateBy_( + CFG.SHEETS.SHIFT_CHANGE_REQUESTS, + 'request_id', + request.request_id, + { + + status: + 'REJECTED', + + resolved_at: + now_(), + + resolved_by: + user.user_id + + } + ); + + } + ); + + + const shiftId = + String( + requestRows[0].shift_id || + '' + ); + + + const employeeId = + String( + requestRows[0].employee_id || + '' + ); + + + audit_( + user.user_id, + 'ATTENDANCE_CORRECTION_REJECTED', + 'SHIFT', + shiftId, + '', + '' + ); + + + if ( + employeeId + ) { + + notifyEmployee_( + employeeId, + 'Oprava docházky zamítnuta', + 'Tvoje žádost o opravu docházky byla zamítnuta.' + ); + + } + + + return { + ok:true + }; + +} + + + +/* ============================================================ + REQUESTED VALUES – ROBUSTNÍ PARSER +============================================================ */ + +function attendanceCorrectionRequestedValues_( + fields +) { + + const result = + { + + clock_in: + '', + + clock_out: + '' + + }; + + + ( + fields || + [] + ).forEach( + function(item) { + + const field = + String( + item.field || + '' + ) + .trim() + .toLowerCase(); + + + const value = + String( + item.requested_value || + '' + ).trim(); + + + if ( + field === + 'clock_in' + ) { + + result.clock_in = + value; + + return; + + } + + + if ( + field === + 'clock_out' + ) { + + result.clock_out = + value; + + return; + + } + + + /* + * Pro jistotu podporujeme i případ, že requested_value + * obsahuje JSON s oběma časy. + */ + + if ( + value && + ( + value.charAt( + 0 + ) === + '{' + ) + ) { + + try { + + const parsed = + JSON.parse( + value + ); + + + if ( + parsed.clock_in + ) { + + result.clock_in = + String( + parsed.clock_in + ); + + } + + + if ( + parsed.clock_out + ) { + + result.clock_out = + String( + parsed.clock_out + ); + + } + + } + + catch(error) { + + /* + * Není JSON, ignorujeme. + */ + + } + + } + + } + ); + + + return result; + +} + + + +/* ============================================================ + DATUM / ČAS +============================================================ */ + +function parseAttendanceCorrectionDate_( + value +) { + + if ( + !value + ) { + + return null; + + } + + + if ( + value instanceof Date + ) { + + return value; + + } + + + const text = + String( + value + ).trim(); + + + let match = + text.match( + /^(\d{4})-(\d{2})-(\d{2})T(\d{1,2}):(\d{2})(?::(\d{2}))?$/ + ); + + + if ( + match + ) { + + return new Date( + Number( + match[1] + ), + Number( + match[2] + ) - 1, + Number( + match[3] + ), + Number( + match[4] + ), + Number( + match[5] + ), + Number( + match[6] || + 0 + ) + ); + + } + + + match = + text.match( + /^(\d{4})-(\d{2})-(\d{2})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/ + ); + + + if ( + match + ) { + + return new Date( + Number( + match[1] + ), + Number( + match[2] + ) - 1, + Number( + match[3] + ), + Number( + match[4] + ), + Number( + match[5] + ), + Number( + match[6] || + 0 + ) + ); + + } + + + match = + text.match( + /^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/ + ); + + + if ( + match + ) { + + return new Date( + Number( + match[3] + ), + Number( + match[1] + ) - 1, + Number( + match[2] + ), + Number( + match[4] || + 0 + ), + Number( + match[5] || + 0 + ), + Number( + match[6] || + 0 + ) + ); + + } + + + const date = + new Date( + text + ); + + + return isNaN( + date.getTime() + ) + ? null + : date; + +} + + +function formatAttendanceCorrectionDate_( + date +) { + + return Utilities.formatDate( + date, + CFG.TZ, + 'yyyy-MM-dd HH:mm:ss' + ); + +} + + + +/* ============================================================ + NOČNÍ MINUTY 22:00–06:00 +============================================================ */ + +function calculateCorrectionNightMinutes_( + start, + end +) { + + if ( + !start || + !end || + end <= + start + ) { + + return 0; + + } + + + let total = + 0; + + + let cursor = + new Date( + start + ); + + + cursor.setSeconds( + 0, + 0 + ); + + + while ( + cursor < + end + ) { + + const hour = + cursor.getHours(); + + + if ( + hour >= + 22 || + hour < + 6 + ) { + + total++; + + } + + + cursor = + new Date( + cursor.getTime() + + 60000 + ); + + } + + + return total; + +} + + + +/* ============================================================ + ČÍSLO +============================================================ */ + +function correctionNumber_( + value +) { + + const number = + Number( + String( + value == null + ? 0 + : value + ) + .replace( + ',', + '.' + ) + .replace( + /\s/g, + '' + ) + ); + + + return isFinite( + number + ) + ? number + : 0; + +} diff --git a/gscript/AttendanceService.js b/gscript/AttendanceService.js new file mode 100644 index 0000000..14a24a0 --- /dev/null +++ b/gscript/AttendanceService.js @@ -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) + }; +} \ No newline at end of file diff --git a/gscript/AuthService.js b/gscript/AuthService.js new file mode 100644 index 0000000..525ff67 --- /dev/null +++ b/gscript/AuthService.js @@ -0,0 +1,1104 @@ +/* ============================================================ + EATME PORTÁL – AUTH SERVICE +============================================================ */ + + +/* ============================================================ + AKTIVAČNÍ KÓD +============================================================ */ + +function requestActivation(email) { + + email = + emailNorm_( + email + ); + + + const user = + findOne_( + CFG.SHEETS.USERS, + function(row) { + + return ( + emailNorm_( + row.email + ) === + email + ); + + } + ); + + + if ( + !user || + !( + user.active === true || + String( + user.active + ).toUpperCase() === + 'TRUE' + ) + ) { + + /* + * Neprozrazujeme, zda účet existuje. + */ + + return { + ok:true + }; + + } + + + const code = + randomCode_(); + + + append_( + CFG.SHEETS.LOGIN_CODES, + { + + code_id: + uuid_( + 'CODE' + ), + + email: + email, + + purpose: + 'ACTIVATE', + + code_hash: + hashText_( + code + ), + + expires_at: + new Date( + Date.now() + + CFG.LOGIN_CODE_MINUTES * + 60000 + ), + + used_at: + '', + + created_at: + now_() + + } + ); + + + MailApp.sendEmail( + { + + to: + email, + + subject: + CFG.APP_NAME + + ' – aktivace účtu', + + body: + 'Váš aktivační kód je: ' + + code + + '\nPlatí ' + + CFG.LOGIN_CODE_MINUTES + + ' minut.', + + htmlBody: + '

Váš aktivační kód:

' + + '

' + + code + + '

' + + '

Platí ' + + CFG.LOGIN_CODE_MINUTES + + ' minut.

', + + name: + CFG.APP_NAME + + } + ); + + + return { + ok:true + }; + +} + + + +/* ============================================================ + AKTIVACE ÚČTU +============================================================ */ + +function activateAccount( + email, + code, + password +) { + + email = + emailNorm_( + email + ); + + + validatePassword_( + password + ); + + + const user = + findOne_( + CFG.SHEETS.USERS, + function(row) { + + return ( + emailNorm_( + row.email + ) === + email + ); + + } + ); + + + if ( + !user + ) { + + throw new Error( + 'Aktivaci nelze dokončit.' + ); + + } + + + const candidates = + rows_( + CFG.SHEETS.LOGIN_CODES + ) + .filter( + function(row) { + + return ( + emailNorm_( + row.email + ) === + email && + + String( + row.purpose + ) === + 'ACTIVATE' && + + !row.used_at + ); + + } + ) + .sort( + function(a,b) { + + return ( + new Date( + b.created_at + ) - + new Date( + a.created_at + ) + ); + + } + ); + + + const record = + candidates[0]; + + + if ( + !record || + new Date( + record.expires_at + ).getTime() < + Date.now() || + String( + record.code_hash + ) !== + hashText_( + code + ) + ) { + + throw new Error( + 'Kód je neplatný nebo vypršel.' + ); + + } + + + const salt = + Utilities + .getUuid() + .replace( + /-/g, + '' + ); + + + updateBy_( + CFG.SHEETS.USERS, + 'user_id', + user.user_id, + { + + password_salt: + salt, + + password_hash: + passwordHash_( + password, + salt + ) + + } + ); + + + updateBy_( + CFG.SHEETS.LOGIN_CODES, + 'code_id', + record.code_id, + { + + used_at: + now_() + + } + ); + + + audit_( + user.user_id, + 'ACCOUNT_ACTIVATED', + 'USER', + user.user_id, + '', + '' + ); + + + return login( + email, + password + ); + +} + + + +/* ============================================================ + ZAPOMENUTÉ HESLO – POSLAT KÓD + + Odpověď je vždy stejná. Neprozrazujeme existenci účtu. +============================================================ */ + +function requestPasswordReset(email) { + + email = + emailNorm_( + email + ); + + + const genericResult = + { + ok:true + }; + + + if ( + !email + ) { + + return genericResult; + + } + + + const user = + findOne_( + CFG.SHEETS.USERS, + function(row) { + + return ( + emailNorm_( + row.email + ) === + email + ); + + } + ); + + + if ( + !user || + !( + user.active === true || + String( + user.active + ).toUpperCase() === + 'TRUE' + ) + ) { + + return genericResult; + + } + + + const code = + randomCode_(); + + + append_( + CFG.SHEETS.LOGIN_CODES, + { + + code_id: + uuid_( + 'CODE' + ), + + email: + email, + + purpose: + 'RESET_PASSWORD', + + code_hash: + hashText_( + code + ), + + expires_at: + new Date( + Date.now() + + CFG.LOGIN_CODE_MINUTES * + 60000 + ), + + used_at: + '', + + created_at: + now_() + + } + ); + + + MailApp.sendEmail( + { + + to: + email, + + subject: + CFG.APP_NAME + + ' – obnovení hesla', + + body: + 'Obdrželi jsme žádost o změnu hesla k účtu ' + + CFG.APP_NAME + + '.\n\nOvěřovací kód: ' + + code + + '\n\nKód platí ' + + CFG.LOGIN_CODE_MINUTES + + ' minut.\n\nPokud jste o změnu hesla nežádali, tento e-mail ignorujte.', + + htmlBody: + '

Obdrželi jsme žádost o změnu hesla k účtu ' + + CFG.APP_NAME + + '.

' + + + '

Ověřovací kód:

' + + + '

' + + code + + '

' + + + '

Kód platí ' + + CFG.LOGIN_CODE_MINUTES + + ' minut.

' + + + '

Pokud jste o změnu hesla nežádali, tento e-mail ignorujte.

', + + name: + CFG.APP_NAME + + } + ); + + + audit_( + user.user_id, + 'PASSWORD_RESET_REQUESTED', + 'USER', + user.user_id, + '', + '' + ); + + + return genericResult; + +} + + + +/* ============================================================ + ZAPOMENUTÉ HESLO – NASTAVIT NOVÉ HESLO +============================================================ */ + +function resetPassword( + email, + code, + newPassword +) { + + email = + emailNorm_( + email + ); + + + validatePassword_( + newPassword + ); + + + const user = + findOne_( + CFG.SHEETS.USERS, + function(row) { + + return ( + emailNorm_( + row.email + ) === + email + ); + + } + ); + + + /* + * Úmyslně používáme stejnou chybu pro neexistující účet + * i neplatný kód. + */ + + if ( + !user || + !( + user.active === true || + String( + user.active + ).toUpperCase() === + 'TRUE' + ) + ) { + + throw new Error( + 'Kód je neplatný nebo vypršel.' + ); + + } + + + const candidates = + rows_( + CFG.SHEETS.LOGIN_CODES + ) + .filter( + function(row) { + + return ( + emailNorm_( + row.email + ) === + email && + + String( + row.purpose + ) === + 'RESET_PASSWORD' && + + !row.used_at + ); + + } + ) + .sort( + function(a,b) { + + return ( + new Date( + b.created_at + ) - + new Date( + a.created_at + ) + ); + + } + ); + + + const record = + candidates[0]; + + + if ( + !record || + new Date( + record.expires_at + ).getTime() < + Date.now() || + String( + record.code_hash + ) !== + hashText_( + code + ) + ) { + + throw new Error( + 'Kód je neplatný nebo vypršel.' + ); + + } + + + /* + * Nový salt. Používáme stejný passwordHash_ jako dosud, + * takže se nerozbije současná autentizace. + */ + + const salt = + Utilities + .getUuid() + .replace( + /-/g, + '' + ); + + + updateBy_( + CFG.SHEETS.USERS, + 'user_id', + user.user_id, + { + + password_salt: + salt, + + password_hash: + passwordHash_( + newPassword, + salt + ) + + } + ); + + + /* + * Spotřebujeme všechny dosud platné RESET_PASSWORD kódy + * pro tento e-mail, ne pouze poslední. + */ + + candidates.forEach( + function(candidate) { + + updateBy_( + CFG.SHEETS.LOGIN_CODES, + 'code_id', + candidate.code_id, + { + + used_at: + now_() + + } + ); + + } + ); + + + /* + * Zneplatníme VŠECHNY existující sessions uživatele. + * Po resetu se musí všechna zařízení přihlásit znovu. + */ + + rows_( + CFG.SHEETS.SESSIONS + ) + .filter( + function(session) { + + return ( + String( + session.user_id + ) === + String( + user.user_id + ) + ); + + } + ) + .forEach( + function(session) { + + updateBy_( + CFG.SHEETS.SESSIONS, + 'session_id', + session.session_id, + { + + expires_at: + new Date( + 0 + ) + + } + ); + + } + ); + + + audit_( + user.user_id, + 'PASSWORD_RESET_COMPLETED', + 'USER', + user.user_id, + '', + '' + ); + + + /* + * Informační e-mail po změně hesla. + */ + + try { + + MailApp.sendEmail( + { + + to: + email, + + subject: + CFG.APP_NAME + + ' – heslo bylo změněno', + + body: + 'Heslo k vašemu účtu ' + + CFG.APP_NAME + + ' bylo právě změněno.\n\nVšechny předchozí relace byly odhlášeny.\n\nPokud jste tuto změnu neprovedli vy, kontaktujte administrátora.', + + htmlBody: + '

Heslo k vašemu účtu ' + + CFG.APP_NAME + + ' bylo právě změněno.

' + + + '

Všechny předchozí relace byly odhlášeny.

' + + + '

Pokud jste tuto změnu neprovedli vy, kontaktujte administrátora.

', + + name: + CFG.APP_NAME + + } + ); + + } + + catch(error) { + + /* + * Selhání informačního e-mailu nesmí vrátit zpět + * už úspěšně změněné heslo. + */ + + console.error( + 'PASSWORD RESET CONFIRMATION EMAIL ERROR:', + error + ); + + } + + + return { + ok:true + }; + +} + + + +/* ============================================================ + LOGIN +============================================================ */ + +function login( + email, + password +) { + + email = + emailNorm_( + email + ); + + + const user = + findOne_( + CFG.SHEETS.USERS, + function(row) { + + return ( + emailNorm_( + row.email + ) === + email + ); + + } + ); + + + if ( + !user || + !user.password_salt || + !user.password_hash + ) { + + throw new Error( + 'Neplatný e-mail nebo heslo.' + ); + + } + + + const calculatedHash = + passwordHash_( + password, + user.password_salt + ); + + + if ( + calculatedHash !== + String( + user.password_hash + ) + ) { + + throw new Error( + 'Neplatný e-mail nebo heslo.' + ); + + } + + + let employee = + null; + + + if ( + user.employee_id + ) { + + employee = + findOne_( + CFG.SHEETS.EMPLOYEES, + function(row) { + + return ( + String( + row.employee_id + ) === + String( + user.employee_id + ) + ); + + } + ); + + } + + + const token = + randomToken_(); + + + const loginTime = + now_(); + + + append_( + CFG.SHEETS.SESSIONS, + { + + session_id: + uuid_( + 'SES' + ), + + user_id: + user.user_id, + + token_hash: + hashText_( + token + ), + + expires_at: + new Date( + Date.now() + + CFG.SESSION_DAYS * + 86400000 + ), + + created_at: + loginTime, + + last_seen_at: + loginTime + + } + ); + + + updateBy_( + CFG.SHEETS.USERS, + 'user_id', + user.user_id, + { + + last_login: + loginTime + + } + ); + + + return { + + ok: + true, + + token: + token, + + role: + user.role, + + user_id: + user.user_id, + + user: { + + user_id: + user.user_id, + + email: + user.email, + + role: + user.role, + + employee: + employee + ? { + + employee_id: + employee.employee_id, + + first_name: + employee.first_name, + + last_name: + employee.last_name, + + company_id: + employee.company_id, + + location_id: + employee.location_id, + + position: + employee.position + + } + : null + + } + + }; + +} + + + +/* ============================================================ + LOGOUT +============================================================ */ + +function logout(token) { + + const tokenHash = + hashText_( + String( + token || + '' + ) + ); + + + const session = + findOne_( + CFG.SHEETS.SESSIONS, + function(row) { + + return ( + String( + row.token_hash + ) === + tokenHash + ); + + } + ); + + + if ( + session + ) { + + updateBy_( + CFG.SHEETS.SESSIONS, + 'session_id', + session.session_id, + { + + expires_at: + new Date( + 0 + ) + + } + ); + + } + + + return { + ok:true + }; + +} + + + +/* ============================================================ + AKTUÁLNÍ UŽIVATEL +============================================================ */ + +function me(token) { + + const user = + requireUser_( + token + ); + + + const employee = + user.employee_id + + ? findOne_( + CFG.SHEETS.EMPLOYEES, + function(row) { + + return ( + String( + row.employee_id + ) === + String( + user.employee_id + ) + ); + + } + ) + + : null; + + + return { + + user_id: + user.user_id, + + email: + user.email, + + role: + user.role, + + employee: + employee + ? { + + employee_id: + employee.employee_id, + + first_name: + employee.first_name, + + last_name: + employee.last_name, + + company_id: + employee.company_id, + + location_id: + employee.location_id, + + position: + employee.position + + } + : null + + }; + +} diff --git a/gscript/Automation.js b/gscript/Automation.js new file mode 100644 index 0000000..e368518 --- /dev/null +++ b/gscript/Automation.js @@ -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' + }); + }); +} \ No newline at end of file diff --git a/gscript/ClosureService.js b/gscript/ClosureService.js new file mode 100644 index 0000000..d1ace45 --- /dev/null +++ b/gscript/ClosureService.js @@ -0,0 +1,2048 @@ +function getMyMonthClosure(token, period) { + + const user = + requireUser_( + token, + [ + 'EMPLOYEE', + 'MANAGER', + 'ADMIN' + ] + ); + + + if ( + !user.employee_id + ) { + + throw new Error( + 'Účet není propojen se zaměstnancem.' + ); + + } + + + period = + String( + period || + Utilities.formatDate( + new Date(), + CFG.TZ, + 'yyyy-MM' + ) + ).trim(); + + + if ( + !/^\d{4}-\d{2}$/.test( + period + ) + ) { + + throw new Error( + 'Neplatné období.' + ); + + } + + + /* + * Načteme uzávěrky jako TEXT. + * Žádné Date objekty posílané do browseru. + */ + + let closures = + displayRows_( + CFG.SHEETS.MONTH_CLOSURES + ); + + + let closure = + closures.find( + function(row) { + + return ( + String( + row.employee_id + ) === + String( + user.employee_id + ) && + String( + row.period + ) === + period + ); + + } + ); + + + /* + * Pokud ještě uzávěrka neexistuje, + * automaticky ji založíme. + */ + + if ( + !closure + ) { + + const closureId = + uuid_( + 'CLOSE' + ); + + + append_( + CFG.SHEETS.MONTH_CLOSURES, + { + + closure_id: + closureId, + + employee_id: + user.employee_id, + + period: + period, + + employee_confirmed_at: + '', + + manager_approved_at: + '', + + manager_approved_by: + '', + + locked_at: + '', + + status: + 'WAITING_EMPLOYEE' + + } + ); + + + /* + * Znovu načteme už jako text. + */ + + closures = + displayRows_( + CFG.SHEETS.MONTH_CLOSURES + ); + + + closure = + closures.find( + function(row) { + + return ( + String( + row.closure_id + ) === + String( + closureId + ) + ); + + } + ); + + } + + + if ( + !closure + ) { + + throw new Error( + 'Nepodařilo se vytvořit měsíční uzávěrku.' + ); + + } + + + const summary = + getClosureSummary_( + user.employee_id, + period + ); + + + /* + * Do browseru pouze primitivní typy. + */ + + return { + + closure_id: + String( + closure.closure_id || + '' + ), + + employee_id: + String( + closure.employee_id || + '' + ), + + period: + String( + closure.period || + period + ), + + status: + String( + closure.status || + 'WAITING_EMPLOYEE' + ), + + employee_confirmed_at: + String( + closure.employee_confirmed_at || + '' + ), + + manager_approved_at: + String( + closure.manager_approved_at || + '' + ), + + locked_at: + String( + closure.locked_at || + '' + ), + + summary: { + + worked_minutes: + Number( + summary.worked_minutes || + 0 + ), + + shift_count: + Number( + summary.shift_count || + 0 + ), + + earned_estimate: + Number( + summary.earned_estimate || + 0 + ), + + night_minutes: + Number( + summary.night_minutes || + 0 + ), + + weekend_minutes: + Number( + summary.weekend_minutes || + 0 + ) + + } + + }; + +} + + + +/* ============================================================ + POTVRZENÍ DOCHÁZKY ZAMĚSTNANCEM +============================================================ */ + +function confirmMyMonthClosure( + token, + period +) { + + const user = + requireUser_( + token, + [ + 'EMPLOYEE', + 'MANAGER', + 'ADMIN' + ] + ); + + + if ( + !user.employee_id + ) { + + throw new Error( + 'Účet není propojen se zaměstnancem.' + ); + + } + + + period = + String( + period || '' + ).trim(); + + + if ( + !/^\d{4}-\d{2}$/.test( + period + ) + ) { + + throw new Error( + 'Neplatné období.' + ); + + } + + + const lock = + LockService + .getScriptLock(); + + + lock.waitLock( + 10000 + ); + + + try { + + /* + * Nejdřív si uzávěrku vyžádáme. + * Tím zajistíme, že existuje. + */ + + const closureData = + getMyMonthClosure( + token, + period + ); + + + const closureId = + closureData.closure_id; + + + const currentStatus = + String( + closureData.status || + '' + ); + + + if ( + currentStatus === + 'LOCKED' + ) { + + throw new Error( + 'Docházka je už uzamčena.' + ); + + } + + + if ( + currentStatus === + 'MANAGER_APPROVED' + ) { + + throw new Error( + 'Docházka už byla schválena vedoucím.' + ); + + } + + + if ( + currentStatus === + 'EMPLOYEE_CONFIRMED' + ) { + + return { + ok:true + }; + + } + + + /* + * Kontrola otevřené směny v daném měsíci. + */ + + const shifts = + displayRows_( + CFG.SHEETS.SHIFTS + ); + + + const openShift = + shifts.find( + function(shift) { + + if ( + String( + shift.employee_id + ) !== + String( + user.employee_id + ) + ) { + + return false; + + } + + + if ( + String( + shift.status + ) !== + 'OPEN' + ) { + + return false; + + } + + + const date = + parseEmployeeDate_( + shift.clock_in + ); + + + if ( + !date + ) { + + return false; + + } + + + return ( + Utilities.formatDate( + date, + CFG.TZ, + 'yyyy-MM' + ) === + period + ); + + } + ); + + + if ( + openShift + ) { + + throw new Error( + 'V tomto měsíci máš stále otevřenou směnu.' + ); + + } + + + /* + * Nevyřešené žádosti o opravu. + */ + + const requests = + displayRows_( + CFG.SHEETS.SHIFT_CHANGE_REQUESTS + ); + + + const pendingRequest = + requests.find( + function(request) { + + return ( + String( + request.employee_id + ) === + String( + user.employee_id + ) && + String( + request.status + ) === + 'PENDING' + ); + + } + ); + + + if ( + pendingRequest + ) { + + throw new Error( + 'Nejdřív je potřeba vyřešit žádost o opravu docházky.' + ); + + } + + + updateBy_( + CFG.SHEETS.MONTH_CLOSURES, + 'closure_id', + closureId, + { + + employee_confirmed_at: + now_(), + + status: + 'EMPLOYEE_CONFIRMED' + + } + ); + + + setMonthShiftStatus_( + user.employee_id, + period, + 'EMPLOYEE_CONFIRMED' + ); + + + audit_( + user.user_id, + 'MONTH_CONFIRMED', + 'MONTH_CLOSURE', + closureId, + currentStatus, + 'EMPLOYEE_CONFIRMED' + ); + + + notifyManagers_( + user.employee_id, + 'Docházka čeká na schválení', + 'Zaměstnanec potvrdil docházku za ' + + period + + '.' + ); + + + return { + + ok:true + + }; + + } + + finally { + + lock.releaseLock(); + + } + +} + + + +/* ============================================================ + SOUHRN MĚSÍCE +============================================================ */ + +function getClosureSummary_( + employeeId, + period +) { + + const shifts = + displayRows_( + CFG.SHEETS.SHIFTS + ); + + + const rates = + displayRows_( + CFG.SHEETS.PAY_RATES + ); + + + let workedMinutes = + 0; + + + let shiftCount = + 0; + + + let earnedEstimate = + 0; + + + let nightMinutes = + 0; + + + let weekendMinutes = + 0; + + + shifts.forEach( + function(shift) { + + if ( + String( + shift.employee_id + ) !== + String( + employeeId + ) + ) { + + return; + + } + + + const date = + parseEmployeeDate_( + shift.clock_in + ); + + + if ( + !date + ) { + + return; + + } + + + const shiftPeriod = + Utilities.formatDate( + date, + CFG.TZ, + 'yyyy-MM' + ); + + + if ( + shiftPeriod !== + period + ) { + + return; + + } + + + /* + * Otevřenou směnu nepočítáme. + */ + + if ( + String( + shift.status + ) === + 'OPEN' + ) { + + return; + + } + + + const minutes = + numberFromSheet_( + shift.worked_minutes + ); + + + const night = + numberFromSheet_( + shift.night_minutes + ); + + + const weekend = + numberFromSheet_( + shift.weekend_minutes + ); + + + workedMinutes += + minutes; + + + nightMinutes += + night; + + + weekendMinutes += + weekend; + + + if ( + minutes > + 0 + ) { + + shiftCount++; + + } + + + const hourlyRate = + employeeRateAt_( + rates, + employeeId, + date + ); + + + earnedEstimate += + ( + minutes / + 60 + ) * + hourlyRate; + + } + ); + + + return { + + worked_minutes: + Math.round( + workedMinutes + ), + + shift_count: + shiftCount, + + earned_estimate: + Math.round( + earnedEstimate + ), + + night_minutes: + Math.round( + nightMinutes + ), + + weekend_minutes: + Math.round( + weekendMinutes + ) + + }; + +} + + + +/* ============================================================ + ŽÁDOST O OPRAVU SMĚNY +============================================================ */ + +function createAttendanceCorrectionRequest( + token, + shiftId, + requestedClockIn, + requestedClockOut, + reason +) { + + const user = + requireUser_( + token, + [ + 'EMPLOYEE', + 'MANAGER', + 'ADMIN' + ] + ); + + + if ( + !user.employee_id + ) { + + throw new Error( + 'Účet není propojen se zaměstnancem.' + ); + + } + + + const shifts = + displayRows_( + CFG.SHEETS.SHIFTS + ); + + + const shift = + shifts.find( + function(row) { + + return ( + String( + row.shift_id + ) === + String( + shiftId + ) && + String( + row.employee_id + ) === + String( + user.employee_id + ) + ); + + } + ); + + + if ( + !shift + ) { + + throw new Error( + 'Směna nebyla nalezena.' + ); + + } + + + if ( + String( + shift.status + ) === + 'LOCKED' + ) { + + throw new Error( + 'Uzamčenou směnu už nelze upravit.' + ); + + } + + + reason = + String( + reason || + '' + ).trim(); + + + if ( + reason.length < + 3 + ) { + + throw new Error( + 'Doplň důvod opravy.' + ); + + } + + + const requestId = + uuid_( + 'FIX' + ); + + + append_( + CFG.SHEETS.SHIFT_CHANGE_REQUESTS, + { + + request_id: + requestId, + + shift_id: + shift.shift_id, + + employee_id: + user.employee_id, + + field: + 'SHIFT_TIME', + + old_value: + JSON.stringify({ + + clock_in: + shift.clock_in, + + clock_out: + shift.clock_out + + }), + + requested_value: + JSON.stringify({ + + clock_in: + requestedClockIn || + '', + + clock_out: + requestedClockOut || + '' + + }), + + reason: + reason, + + status: + 'PENDING', + + created_at: + now_(), + + resolved_at: + '', + + resolved_by: + '' + + } + ); + + + audit_( + user.user_id, + 'ATTENDANCE_CORRECTION_REQUESTED', + 'SHIFT', + shift.shift_id, + '', + requestId + ); + + + notifyManagers_( + user.employee_id, + 'Nová žádost o opravu docházky', + 'Zaměstnanec požádal o opravu směny.' + ); + + + return { + + ok:true + + }; + +} + + + +/* ============================================================ + ADMIN – ČEKAJÍCÍ UZÁVĚRKY +============================================================ */ + +function getPendingClosures(token) { + + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + const closures = + displayRows_( + CFG.SHEETS.MONTH_CLOSURES + ); + + + const employees = + displayRows_( + CFG.SHEETS.EMPLOYEES + ); + + + const employeeMap = + {}; + + + employees.forEach( + function(employee) { + + employeeMap[ + String( + employee.employee_id + ) + ] = + employee; + + } + ); + + + return closures + .filter( + function(closure) { + + return [ + 'EMPLOYEE_CONFIRMED', + 'MANAGER_APPROVED' + ].includes( + String( + closure.status + ) + ); + + } + ) + .map( + function(closure) { + + const employee = + employeeMap[ + String( + closure.employee_id + ) + ]; + + + const summary = + getClosureSummary_( + closure.employee_id, + closure.period + ); + + + return { + + closure_id: + String( + closure.closure_id || + '' + ), + + employee_id: + String( + closure.employee_id || + '' + ), + + employee_name: + employee + ? ( + String( + employee.first_name || + '' + ) + + ' ' + + String( + employee.last_name || + '' + ) + ).trim() + : String( + closure.employee_id || + '' + ), + + period: + String( + closure.period || + '' + ), + + status: + String( + closure.status || + '' + ), + + worked_minutes: + Number( + summary.worked_minutes || + 0 + ), + + shift_count: + Number( + summary.shift_count || + 0 + ), + + earned_estimate: + Number( + summary.earned_estimate || + 0 + ) + + }; + + } + ); + +} + + + +/* ============================================================ + ADMIN / MANAGER – SCHVÁLENÍ +============================================================ */ + +function managerApproveClosure( + token, + closureId +) { + + const user = + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + const closures = + displayRows_( + CFG.SHEETS.MONTH_CLOSURES + ); + + + const closure = + closures.find( + function(row) { + + return ( + String( + row.closure_id + ) === + String( + closureId + ) + ); + + } + ); + + + if ( + !closure + ) { + + throw new Error( + 'Uzávěrka nebyla nalezena.' + ); + + } + + + if ( + String( + closure.status + ) !== + 'EMPLOYEE_CONFIRMED' + ) { + + throw new Error( + 'Docházku zatím nepotvrdil zaměstnanec.' + ); + + } + + + const requests = + displayRows_( + CFG.SHEETS.SHIFT_CHANGE_REQUESTS + ); + + + const pending = + requests.find( + function(request) { + + return ( + String( + request.employee_id + ) === + String( + closure.employee_id + ) && + String( + request.status + ) === + 'PENDING' + ); + + } + ); + + + if ( + pending + ) { + + throw new Error( + 'Zaměstnanec má nevyřešenou žádost o opravu docházky.' + ); + + } + + + updateBy_( + CFG.SHEETS.MONTH_CLOSURES, + 'closure_id', + closureId, + { + + manager_approved_at: + now_(), + + manager_approved_by: + user.user_id, + + status: + 'MANAGER_APPROVED' + + } + ); + + + setMonthShiftStatus_( + closure.employee_id, + closure.period, + 'MANAGER_APPROVED' + ); + + + audit_( + user.user_id, + 'MONTH_APPROVED', + 'MONTH_CLOSURE', + closureId, + 'EMPLOYEE_CONFIRMED', + 'MANAGER_APPROVED' + ); + + + notifyEmployee_( + closure.employee_id, + 'Docházka schválena', + 'Docházka za ' + + closure.period + + ' byla schválena.' + ); + + + return { + + ok:true + + }; + +} + + + +/* ============================================================ + ADMIN – UZAMČENÍ +============================================================ */ + +function lockMonthClosure( + token, + closureId +) { + + const user = + requireUser_( + token, + [ + 'ADMIN' + ] + ); + + + const closures = + displayRows_( + CFG.SHEETS.MONTH_CLOSURES + ); + + + const closure = + closures.find( + function(row) { + + return ( + String( + row.closure_id + ) === + String( + closureId + ) + ); + + } + ); + + + if ( + !closure + ) { + + throw new Error( + 'Uzávěrka nebyla nalezena.' + ); + + } + + + if ( + String( + closure.status + ) !== + 'MANAGER_APPROVED' + ) { + + throw new Error( + 'Uzávěrku lze zamknout až po schválení.' + ); + + } + + + updateBy_( + CFG.SHEETS.MONTH_CLOSURES, + 'closure_id', + closureId, + { + + locked_at: + now_(), + + status: + 'LOCKED' + + } + ); + + + setMonthShiftStatus_( + closure.employee_id, + closure.period, + 'LOCKED' + ); + + + createPayrollDraft_( + closure.employee_id, + closure.period + ); + + + audit_( + user.user_id, + 'MONTH_LOCKED', + 'MONTH_CLOSURE', + closureId, + 'MANAGER_APPROVED', + 'LOCKED' + ); + + + notifyEmployee_( + closure.employee_id, + 'Docházka uzavřena', + 'Docházka za ' + + closure.period + + ' byla uzavřena a předána ke zpracování.' + ); + + + return { + + ok:true + + }; + +} + + + +/* ============================================================ + PŘEPIS STAVU SMĚN V MĚSÍCI +============================================================ */ + +function setMonthShiftStatus_( + employeeId, + period, + newStatus +) { + + const sheet = + sh_( + CFG.SHEETS.SHIFTS + ); + + + const range = + sheet.getDataRange(); + + + const values = + range.getValues(); + + + if ( + values.length < + 2 + ) { + + return; + + } + + + const headers = + values[0] + .map( + function(value) { + + return String( + value + ); + + } + ); + + + const employeeIndex = + headers.indexOf( + 'employee_id' + ); + + + const clockInIndex = + headers.indexOf( + 'clock_in' + ); + + + const statusIndex = + headers.indexOf( + 'status' + ); + + + const updatedIndex = + headers.indexOf( + 'updated_at' + ); + + + if ( + employeeIndex < + 0 || + clockInIndex < + 0 || + statusIndex < + 0 + ) { + + throw new Error( + 'Tabulka SHIFTS nemá potřebné sloupce.' + ); + + } + + + for ( + let i = 1; + i < values.length; + i++ + ) { + + const row = + values[i]; + + + if ( + String( + row[ + employeeIndex + ] + ) !== + String( + employeeId + ) + ) { + + continue; + + } + + + const date = + parseEmployeeDate_( + row[ + clockInIndex + ] + ); + + + if ( + !date + ) { + + continue; + + } + + + const rowPeriod = + Utilities.formatDate( + date, + CFG.TZ, + 'yyyy-MM' + ); + + + if ( + rowPeriod !== + period + ) { + + continue; + + } + + + /* + * Otevřenou směnu nesmíme + * přepsat na potvrzenou. + */ + + if ( + String( + row[ + statusIndex + ] + ) === + 'OPEN' + ) { + + continue; + + } + + + sheet + .getRange( + i + 1, + statusIndex + 1 + ) + .setValue( + newStatus + ); + + + if ( + updatedIndex >= + 0 + ) { + + sheet + .getRange( + i + 1, + updatedIndex + 1 + ) + .setValue( + now_() + ); + + } + + } + +} + + + +/* ============================================================ + PAYROLL DRAFT +============================================================ */ + +function createPayrollDraft_( + employeeId, + period +) { + + const payrolls = + displayRows_( + CFG.SHEETS.PAYROLL + ); + + + const existing = + payrolls.find( + function(row) { + + return ( + String( + row.employee_id + ) === + String( + employeeId + ) && + String( + row.period + ) === + String( + period + ) + ); + + } + ); + + + const summary = + getClosureSummary_( + employeeId, + period + ); + + + if ( + existing + ) { + + updateBy_( + CFG.SHEETS.PAYROLL, + 'payroll_id', + existing.payroll_id, + { + + approved_minutes: + summary.worked_minutes, + + base_amount: + summary.earned_estimate, + + final_amount: + summary.earned_estimate, + + status: + 'READY_FOR_ACCOUNTANT', + + updated_at: + now_() + + } + ); + + + return; + + } + + + append_( + CFG.SHEETS.PAYROLL, + { + + payroll_id: + uuid_( + 'PAY' + ), + + employee_id: + employeeId, + + period: + period, + + approved_minutes: + summary.worked_minutes, + + base_amount: + summary.earned_estimate, + + bonus_amount: + 0, + + tips_amount: + 0, + + other_amount: + 0, + + final_amount: + summary.earned_estimate, + + status: + 'READY_FOR_ACCOUNTANT', + + payment_date: + '', + + updated_at: + now_() + + } + ); + +} + + + +/* ============================================================ + NOTIFIKACE ZAMĚSTNANCI +============================================================ */ + +function notifyEmployee_( + employeeId, + title, + message +) { + + const users = + displayRows_( + CFG.SHEETS.USERS + ); + + + const user = + users.find( + function(row) { + + return ( + String( + row.employee_id + ) === + String( + employeeId + ) + ); + + } + ); + + + if ( + !user + ) { + + return; + + } + + + append_( + CFG.SHEETS.NOTIFICATIONS, + { + + notification_id: + uuid_( + 'NOT' + ), + + user_id: + user.user_id, + + type: + 'ATTENDANCE', + + title: + title, + + message: + message, + + url: + '', + + read_at: + '', + + created_at: + now_(), + + email_sent_at: + '' + + } + ); + + + if ( + user.email + ) { + + try { + + MailApp.sendEmail({ + + to: + user.email, + + subject: + CFG.APP_NAME + + ' – ' + + title, + + body: + message, + + name: + CFG.APP_NAME + + }); + + } + + catch(error) { + + /* + * E-mail nesmí shodit + * samotnou uzávěrku. + */ + + Logger.log( + 'EMAIL ERROR: ' + + error + ); + + } + + } + +} + + + +/* ============================================================ + NOTIFIKACE ADMINŮM / MANAGERŮM +============================================================ */ + +function notifyManagers_( + employeeId, + title, + message +) { + + const users = + displayRows_( + CFG.SHEETS.USERS + ); + + + users + .filter( + function(user) { + + return [ + 'ADMIN', + 'MANAGER' + ].includes( + String( + user.role + ) + ); + + } + ) + .forEach( + function(user) { + + append_( + CFG.SHEETS.NOTIFICATIONS, + { + + notification_id: + uuid_( + 'NOT' + ), + + user_id: + user.user_id, + + type: + 'ATTENDANCE', + + title: + title, + + message: + message, + + url: + '', + + read_at: + '', + + created_at: + now_(), + + email_sent_at: + '' + + } + ); + + } + ); + +} + + + +/* ============================================================ + DIAGNOSTICKÝ TEST + + Tuhle funkci můžeš ručně spustit. +============================================================ */ + +function testClosureDatabase() { + + Logger.log( + 'MONTH_CLOSURES: ' + + JSON.stringify( + displayRows_( + CFG.SHEETS.MONTH_CLOSURES + ) + ) + ); + + + Logger.log( + 'SHIFTS: ' + + JSON.stringify( + displayRows_( + CFG.SHEETS.SHIFTS + ) + ) + ); + + + Logger.log( + 'PAY_RATES: ' + + JSON.stringify( + displayRows_( + CFG.SHEETS.PAY_RATES + ) + ) + ); + + + Logger.log( + 'ClosureService OK' + ); + +} +function testCreateClosureForEmployee() { + + const employeeId = + 'EMP_6C233E6102D94CE4'; + + const period = + '2026-08'; + + + let closures = + displayRows_( + CFG.SHEETS.MONTH_CLOSURES + ); + + + let closure = + closures.find( + function(row) { + + return ( + String( + row.employee_id + ) === + employeeId && + String( + row.period + ) === + period + ); + + } + ); + + + if ( + !closure + ) { + + append_( + CFG.SHEETS.MONTH_CLOSURES, + { + + closure_id: + uuid_( + 'CLOSE' + ), + + employee_id: + employeeId, + + period: + period, + + employee_confirmed_at: + '', + + manager_approved_at: + '', + + manager_approved_by: + '', + + locked_at: + '', + + status: + 'WAITING_EMPLOYEE' + + } + ); + + } + + + Logger.log( + JSON.stringify( + displayRows_( + CFG.SHEETS.MONTH_CLOSURES + ) + ) + ); + +} \ No newline at end of file diff --git a/gscript/Config.js b/gscript/Config.js new file mode 100644 index 0000000..37940c8 --- /dev/null +++ b/gscript/Config.js @@ -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' + ] + +}); \ No newline at end of file diff --git a/gscript/DailySalesService.js b/gscript/DailySalesService.js new file mode 100644 index 0000000..4998022 --- /dev/null +++ b/gscript/DailySalesService.js @@ -0,0 +1,3278 @@ +/* ============================================================ + EATME PORTÁL – DAILY SALES SERVICE + + Denní výkaz tržeb + automatický bonus ze směny. + + Výpočty: + hotovost k odevzdání = + hotovost na místě + hotovost Bolt + hotovost Foodora + + celková tržba = + hotovost na místě + kartou na místě + + celkem Bolt + celkem Wolt + celkem Foodora + + "Přijato kartou" je kontrolní údaj a do tržby se nepřičítá + podruhé. + + Bonus: + Ne–Čt: 10 % z částky nad 5 000 Kč + Pá–So: 10 % z částky nad 10 000 Kč + + Bonus se dělí rovným dílem mezi všechny unikátní zaměstnance + přihlášené na plánované směny daného dne. +============================================================ */ + +const SALES_SHEETS = Object.freeze({ + REPORTS: 'DAILY_SALES_REPORTS', + BONUSES: 'DAILY_SALES_BONUSES' +}); + + +/* ============================================================ + SETUP – SPUSŤ JEDNOU +============================================================ */ + +function setupDailySales() { + + ensureSalesSheet_( + SALES_SHEETS.REPORTS, + [ + 'report_id', + 'work_date', + 'location_id', + 'cash_on_site', + 'card_on_site', + 'card_received', + 'cash_bolt', + 'cash_foodora', + 'total_bolt', + 'total_wolt', + 'total_foodora', + 'cash_to_handover', + 'total_revenue', + 'card_tip_gross', + 'card_tip_net', + 'bonus_threshold', + 'bonus_pool', + 'submitted_by', + 'submitted_at', + 'updated_at' + ] + ); + + + ensureSalesSheet_( + SALES_SHEETS.BONUSES, + [ + 'allocation_id', + 'report_id', + 'work_date', + 'employee_id', + 'bonus_amount', + 'card_tip_amount', + 'created_at', + 'updated_at' + ] + ); + + + Logger.log( + 'Daily sales setup OK' + ); + +} + + + +/* ============================================================ + ZAMĚSTNANEC – DNEŠNÍ VÝKAZ +============================================================ */ + +function getMyDailySalesReport( + token, + dateValue +) { + + const user = + requireUser_( + token, + [ + 'EMPLOYEE', + 'MANAGER', + 'ADMIN' + ] + ); + + + if ( + !user.employee_id + ) { + + throw new Error( + 'Účet není propojen se zaměstnancem.' + ); + + } + + + const requestedDate = + normalizeSalesDate_( + dateValue + ); + + + const employeeId = + String( + user.employee_id + ); + + + /* + * Nepracujeme slepě s dnešním kalendářním datem. + * U směn přes půlnoc je provozní datum vždy datum ZAČÁTKU směny. + * + * Příklad: + * pátek 18:00 -> sobota 02:00 + * work_date zůstává pátek. + * + * Výkaz je zaměstnanci dostupný od začátku směny + * až 12 hodin po jejím skončení. + */ + const resolved = + resolveEmployeeSalesBusinessDate_( + employeeId, + requestedDate + ); + + + if ( + !resolved + ) { + + return { + scheduled:false, + work_date:requestedDate + }; + + } + + + const date = + resolved.work_date; + + + const context = + getSalesPlanningContext_( + date + ); + + + const scheduledEmployees = + context.employee_ids; + + + const isScheduled = + scheduledEmployees.includes( + employeeId + ); + + + if ( + !isScheduled + ) { + + return { + scheduled:false, + work_date:date + }; + + } + + + const locationId = + context.location_id || + 'LOCATION_MAIN'; + + + const reports = + safeSalesRows_( + SALES_SHEETS.REPORTS + ); + + + const report = + reports.find( + function(row) { + + return ( + String( + row.work_date + ) === + date && + + String( + row.location_id || + '' + ) === + String( + locationId + ) + ); + + } + ) || + null; + + + const values = + report + ? salesReportValues_( + report + ) + : emptySalesValues_(); + + + const calculation = + calculateSalesReport_( + date, + values, + scheduledEmployees.length + ); + + + return { + + scheduled:true, + + work_date: + date, + + location_id: + locationId, + + report_id: + report + ? String( + report.report_id || + '' + ) + : '', + + values: + values, + + cash_to_handover: + calculation.cash_to_handover, + + total_revenue: + calculation.total_revenue, + + bonus_threshold: + calculation.bonus_threshold, + + bonus_pool: + calculation.bonus_pool, + + card_tip_gross: + calculation.card_tip_gross, + + card_tip_net: + calculation.card_tip_net, + + employee_count: + scheduledEmployees.length, + + my_bonus: + calculation.employee_count > 0 + ? roundMoney_( + calculation.bonus_pool / + calculation.employee_count + ) + : 0, + + my_card_tip: + calculation.employee_count > 0 + ? roundMoney_( + calculation.card_tip_net / + calculation.employee_count + ) + : 0, + + submitted_at: + report + ? String( + report.submitted_at || + '' + ) + : '', + + employee_ids: + scheduledEmployees + + }; + +} + + + +/* ============================================================ + ULOŽENÍ VÝKAZU +============================================================ */ + +function saveMyDailySalesReport( + token, + dateValue, + payload +) { + + const user = + requireUser_( + token, + [ + 'EMPLOYEE', + 'MANAGER', + 'ADMIN' + ] + ); + + + if ( + !user.employee_id + ) { + + throw new Error( + 'Účet není propojen se zaměstnancem.' + ); + + } + + + const date = + normalizeSalesDate_( + dateValue + ); + + + const context = + getSalesPlanningContext_( + date + ); + + + const employeeId = + String( + user.employee_id + ); + + + if ( + !context.employee_ids.includes( + employeeId + ) + ) { + + throw new Error( + 'Na tento den nejsi přihlášený na směnu.' + ); + + } + + + const values = + sanitizeSalesPayload_( + payload + ); + + + const calculation = + calculateSalesReport_( + date, + values, + context.employee_ids.length + ); + + + const locationId = + context.location_id || + 'LOCATION_MAIN'; + + + const reports = + safeSalesRows_( + SALES_SHEETS.REPORTS + ); + + + let report = + reports.find( + function(row) { + + return ( + String( + row.work_date + ) === + date && + + String( + row.location_id || + '' + ) === + String( + locationId + ) + ); + + } + ) || + null; + + + const now = + now_(); + + + if ( + report + ) { + + updateBy_( + SALES_SHEETS.REPORTS, + 'report_id', + report.report_id, + { + + cash_on_site: + values.cash_on_site, + + card_on_site: + values.card_on_site, + + card_received: + values.card_received, + + cash_bolt: + values.cash_bolt, + + cash_foodora: + values.cash_foodora, + + total_bolt: + values.total_bolt, + + total_wolt: + values.total_wolt, + + total_foodora: + values.total_foodora, + + cash_to_handover: + calculation.cash_to_handover, + + total_revenue: + calculation.total_revenue, + + card_tip_gross: + calculation.card_tip_gross, + + card_tip_net: + calculation.card_tip_net, + + bonus_threshold: + calculation.bonus_threshold, + + bonus_pool: + calculation.bonus_pool, + + submitted_by: + user.user_id, + + submitted_at: + now, + + updated_at: + now + + } + ); + + } else { + + report = + { + + report_id: + uuid_( + 'SALE' + ), + + work_date: + date, + + location_id: + locationId, + + cash_on_site: + values.cash_on_site, + + card_on_site: + values.card_on_site, + + card_received: + values.card_received, + + cash_bolt: + values.cash_bolt, + + cash_foodora: + values.cash_foodora, + + total_bolt: + values.total_bolt, + + total_wolt: + values.total_wolt, + + total_foodora: + values.total_foodora, + + cash_to_handover: + calculation.cash_to_handover, + + total_revenue: + calculation.total_revenue, + + card_tip_gross: + calculation.card_tip_gross, + + card_tip_net: + calculation.card_tip_net, + + bonus_threshold: + calculation.bonus_threshold, + + bonus_pool: + calculation.bonus_pool, + + submitted_by: + user.user_id, + + submitted_at: + now, + + updated_at: + now + + }; + + + append_( + SALES_SHEETS.REPORTS, + report + ); + + } + + + replaceSalesBonusAllocations_( + report.report_id, + date, + context.employee_ids, + calculation.bonus_pool, + calculation.card_tip_net + ); + + + audit_( + user.user_id, + 'DAILY_SALES_SAVED', + 'DAILY_SALES', + report.report_id, + '', + JSON.stringify({ + + date: + date, + + total_revenue: + calculation.total_revenue, + + cash_to_handover: + calculation.cash_to_handover, + + bonus_pool: + calculation.bonus_pool, + + card_tip_net: + calculation.card_tip_net, + + employees: + context.employee_ids + + }) + ); + + + return getMyDailySalesReport( + token, + date + ); + +} + + + +/* ============================================================ + ADMIN – HISTORIE TRŽEB +============================================================ */ + +function getAdminDailySalesReports( + token, + dateFrom, + dateTo +) { + + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + const range = + normalizeSalesRange_( + dateFrom, + dateTo + ); + + + const reports = + safeSalesRows_( + SALES_SHEETS.REPORTS + ) + .filter( + function(row) { + + const date = + String( + row.work_date || + '' + ).substring( + 0, + 10 + ); + + + return ( + date >= + range.from && + date <= + range.to + ); + + } + ) + .sort( + function(a,b) { + + return String( + b.work_date + ).localeCompare( + String( + a.work_date + ) + ); + + } + ); + + + const rows = + reports.map( + function(row) { + + return { + + report_id: + String( + row.report_id || + '' + ), + + work_date: + String( + row.work_date || + '' + ).substring( + 0, + 10 + ), + + cash_on_site: + salesNumber_( + row.cash_on_site + ), + + card_on_site: + salesNumber_( + row.card_on_site + ), + + card_received: + salesNumber_( + row.card_received + ), + + cash_bolt: + salesNumber_( + row.cash_bolt + ), + + cash_foodora: + salesNumber_( + row.cash_foodora + ), + + total_bolt: + salesNumber_( + row.total_bolt + ), + + total_wolt: + salesNumber_( + row.total_wolt + ), + + total_foodora: + salesNumber_( + row.total_foodora + ), + + cash_to_handover: + salesNumber_( + row.cash_to_handover + ), + + total_revenue: + salesNumber_( + row.total_revenue + ), + + bonus_pool: + salesNumber_( + row.bonus_pool + ), + + card_tip_gross: + salesNumber_( + row.card_tip_gross + ), + + card_tip_net: + salesNumber_( + row.card_tip_net + ), + + submitted_at: + String( + row.submitted_at || + '' + ) + + }; + + } + ); + + + const stats = + { + + days: + rows.length, + + total_revenue: + 0, + + cash_to_handover: + 0, + + bonus_pool: + 0, + + card_tip_net: + 0, + + card_on_site: + 0 + + }; + + + rows.forEach( + function(row) { + + stats.total_revenue += + row.total_revenue; + + stats.cash_to_handover += + row.cash_to_handover; + + stats.bonus_pool += + row.bonus_pool; + + stats.card_tip_net += + row.card_tip_net; + + stats.card_on_site += + row.card_on_site; + + } + ); + + + Object.keys( + stats + ).forEach( + function(key) { + + if ( + typeof stats[ + key + ] === + 'number' + ) { + + stats[ + key + ] = + roundMoney_( + stats[ + key + ] + ); + + } + + } + ); + + + return { + + date_from: + range.from, + + date_to: + range.to, + + stats: + stats, + + rows: + rows + + }; + +} + + + +/* ============================================================ + ADMIN / MANAGER – ULOŽIT TRŽBU ZA LIBOVOLNÝ DEN + + Admin nemusí být na směně. + Do rozdělení bonusu a karetního spropitného vstupují pouze + zaměstnanci skutečně přihlášení na plánované směny daného dne. +============================================================ */ + +function saveAdminDailySalesReport( + token, + dateValue, + payload +) { + + const user = + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + const date = + normalizeSalesDate_( + dateValue + ); + + + const values = + sanitizeSalesPayload_( + payload + ); + + + const context = + getSalesPlanningContext_( + date + ); + + + const calculation = + calculateSalesReport_( + date, + values, + context.employee_ids.length + ); + + + const locationId = + context.location_id || + 'LOCATION_MAIN'; + + + const reports = + safeSalesRows_( + SALES_SHEETS.REPORTS + ); + + + let report = + reports.find( + function(row) { + + return ( + String( + row.work_date || + '' + ).substring( + 0, + 10 + ) === + date && + + String( + row.location_id || + '' + ) === + String( + locationId + ) + ); + + } + ) || + null; + + + const now = + now_(); + + + const reportData = + { + + cash_on_site: + values.cash_on_site, + + card_on_site: + values.card_on_site, + + card_received: + values.card_received, + + cash_bolt: + values.cash_bolt, + + cash_foodora: + values.cash_foodora, + + total_bolt: + values.total_bolt, + + total_wolt: + values.total_wolt, + + total_foodora: + values.total_foodora, + + cash_to_handover: + calculation.cash_to_handover, + + total_revenue: + calculation.total_revenue, + + card_tip_gross: + calculation.card_tip_gross, + + card_tip_net: + calculation.card_tip_net, + + bonus_threshold: + calculation.bonus_threshold, + + bonus_pool: + calculation.bonus_pool, + + submitted_by: + user.user_id, + + submitted_at: + now, + + updated_at: + now + + }; + + + let action = + 'DAILY_SALES_ADMIN_CREATED'; + + + let oldValue = + ''; + + + if ( + report + ) { + + action = + 'DAILY_SALES_ADMIN_UPDATED'; + + + oldValue = + { + + total_revenue: + salesNumber_( + report.total_revenue + ), + + cash_to_handover: + salesNumber_( + report.cash_to_handover + ), + + card_tip_net: + salesNumber_( + report.card_tip_net + ), + + bonus_pool: + salesNumber_( + report.bonus_pool + ) + + }; + + + updateBy_( + SALES_SHEETS.REPORTS, + 'report_id', + report.report_id, + reportData + ); + + } else { + + report = + { + + report_id: + uuid_( + 'SALE' + ), + + work_date: + date, + + location_id: + locationId, + + cash_on_site: + reportData.cash_on_site, + + card_on_site: + reportData.card_on_site, + + card_received: + reportData.card_received, + + cash_bolt: + reportData.cash_bolt, + + cash_foodora: + reportData.cash_foodora, + + total_bolt: + reportData.total_bolt, + + total_wolt: + reportData.total_wolt, + + total_foodora: + reportData.total_foodora, + + cash_to_handover: + reportData.cash_to_handover, + + total_revenue: + reportData.total_revenue, + + card_tip_gross: + reportData.card_tip_gross, + + card_tip_net: + reportData.card_tip_net, + + bonus_threshold: + reportData.bonus_threshold, + + bonus_pool: + reportData.bonus_pool, + + submitted_by: + reportData.submitted_by, + + submitted_at: + reportData.submitted_at, + + updated_at: + reportData.updated_at + + }; + + + append_( + SALES_SHEETS.REPORTS, + report + ); + + } + + + replaceSalesBonusAllocations_( + report.report_id, + date, + context.employee_ids, + calculation.bonus_pool, + calculation.card_tip_net + ); + + + audit_( + user.user_id, + action, + 'DAILY_SALES', + report.report_id, + oldValue, + { + + work_date: + date, + + total_revenue: + calculation.total_revenue, + + cash_to_handover: + calculation.cash_to_handover, + + card_tip_net: + calculation.card_tip_net, + + bonus_pool: + calculation.bonus_pool, + + scheduled_employee_ids: + context.employee_ids + + } + ); + + + return { + + ok: + true, + + report_id: + report.report_id, + + work_date: + date, + + employee_count: + context.employee_ids.length, + + employee_ids: + context.employee_ids, + + total_revenue: + calculation.total_revenue, + + cash_to_handover: + calculation.cash_to_handover, + + card_tip_net: + calculation.card_tip_net, + + bonus_pool: + calculation.bonus_pool + + }; + +} + + +/* ============================================================ + ADMIN – DETAIL JEDNOHO VÝKAZU TRŽEB +============================================================ */ + +function getAdminDailySalesReportDetail( + token, + reportId +) { + + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + const reports = + safeSalesRows_( + SALES_SHEETS.REPORTS + ); + + + const report = + reports.find( + function(row) { + + return ( + String( + row.report_id || + '' + ) === + String( + reportId || + '' + ) + ); + + } + ); + + + if ( + !report + ) { + + throw new Error( + 'Výkaz tržeb nebyl nalezen.' + ); + + } + + + return { + + report_id: + String( + report.report_id || + '' + ), + + work_date: + String( + report.work_date || + '' + ).substring( + 0, + 10 + ), + + location_id: + String( + report.location_id || + '' + ), + + values: + salesReportValues_( + report + ), + + cash_to_handover: + salesNumber_( + report.cash_to_handover + ), + + total_revenue: + salesNumber_( + report.total_revenue + ), + + card_tip_gross: + salesNumber_( + report.card_tip_gross + ), + + card_tip_net: + salesNumber_( + report.card_tip_net + ), + + bonus_threshold: + salesNumber_( + report.bonus_threshold + ), + + bonus_pool: + salesNumber_( + report.bonus_pool + ), + + submitted_at: + String( + report.submitted_at || + '' + ), + + updated_at: + String( + report.updated_at || + '' + ) + + }; + +} + + + +/* ============================================================ + ADMIN – ÚPRAVA VÝKAZU TRŽEB + + Po změně se znovu přepočítá: + - hotovost k odevzdání + - celková tržba + - karetní spropitné po odečtení 21 % + - bonus z tržeb + - rozdělení dýšek a bonusů mezi zaměstnance +============================================================ */ + +function updateAdminDailySalesReport( + token, + reportId, + payload +) { + + const user = + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + const reports = + safeSalesRows_( + SALES_SHEETS.REPORTS + ); + + + const report = + reports.find( + function(row) { + + return ( + String( + row.report_id || + '' + ) === + String( + reportId || + '' + ) + ); + + } + ); + + + if ( + !report + ) { + + throw new Error( + 'Výkaz tržeb nebyl nalezen.' + ); + + } + + + const date = + String( + report.work_date || + '' + ).substring( + 0, + 10 + ); + + + const values = + sanitizeSalesPayload_( + payload + ); + + + const context = + getSalesPlanningContext_( + date + ); + + + const calculation = + calculateSalesReport_( + date, + values, + context.employee_ids.length + ); + + + const oldValue = + { + + cash_on_site: + salesNumber_( + report.cash_on_site + ), + + card_on_site: + salesNumber_( + report.card_on_site + ), + + card_received: + salesNumber_( + report.card_received + ), + + cash_bolt: + salesNumber_( + report.cash_bolt + ), + + cash_foodora: + salesNumber_( + report.cash_foodora + ), + + total_bolt: + salesNumber_( + report.total_bolt + ), + + total_wolt: + salesNumber_( + report.total_wolt + ), + + total_foodora: + salesNumber_( + report.total_foodora + ), + + total_revenue: + salesNumber_( + report.total_revenue + ), + + card_tip_net: + salesNumber_( + report.card_tip_net + ), + + bonus_pool: + salesNumber_( + report.bonus_pool + ) + + }; + + + updateBy_( + SALES_SHEETS.REPORTS, + 'report_id', + report.report_id, + { + + cash_on_site: + values.cash_on_site, + + card_on_site: + values.card_on_site, + + card_received: + values.card_received, + + cash_bolt: + values.cash_bolt, + + cash_foodora: + values.cash_foodora, + + total_bolt: + values.total_bolt, + + total_wolt: + values.total_wolt, + + total_foodora: + values.total_foodora, + + cash_to_handover: + calculation.cash_to_handover, + + total_revenue: + calculation.total_revenue, + + card_tip_gross: + calculation.card_tip_gross, + + card_tip_net: + calculation.card_tip_net, + + bonus_threshold: + calculation.bonus_threshold, + + bonus_pool: + calculation.bonus_pool, + + updated_at: + now_() + + } + ); + + + replaceSalesBonusAllocations_( + report.report_id, + date, + context.employee_ids, + calculation.bonus_pool, + calculation.card_tip_net + ); + + + audit_( + user.user_id, + 'DAILY_SALES_ADMIN_UPDATED', + 'DAILY_SALES', + report.report_id, + oldValue, + { + + cash_on_site: + values.cash_on_site, + + card_on_site: + values.card_on_site, + + card_received: + values.card_received, + + cash_bolt: + values.cash_bolt, + + cash_foodora: + values.cash_foodora, + + total_bolt: + values.total_bolt, + + total_wolt: + values.total_wolt, + + total_foodora: + values.total_foodora, + + total_revenue: + calculation.total_revenue, + + card_tip_net: + calculation.card_tip_net, + + bonus_pool: + calculation.bonus_pool + + } + ); + + + return getAdminDailySalesReportDetail( + token, + report.report_id + ); + +} + + +/* ============================================================ + BONUS PRO TÝDENNÍ MZDU +============================================================ */ + +function getSalesBonusForEmployeeWeek_( + employeeId, + weekStart, + weekEnd +) { + + try { + + const rows = + safeSalesRows_( + SALES_SHEETS.BONUSES + ); + + + return roundMoney_( + rows + .filter( + function(row) { + + const date = + String( + row.work_date || + '' + ); + + + return ( + String( + row.employee_id + ) === + String( + employeeId + ) && + + date >= + String( + weekStart + ) && + + date <= + String( + weekEnd + ) + ); + + } + ) + .reduce( + function(sum,row) { + + return ( + sum + + salesNumber_( + row.bonus_amount + ) + ); + + }, + 0 + ) + ); + + } + + catch(error) { + + return 0; + + } + +} + +function getCardTipForEmployeeWeek_( + employeeId, + weekStart, + weekEnd +) { + + try { + + const rows = + safeSalesRows_( + SALES_SHEETS.BONUSES + ); + + + return roundMoney_( + rows + .filter( + function(row) { + + const date = + String( + row.work_date || + '' + ); + + + return ( + String( + row.employee_id + ) === + String( + employeeId + ) && + + date >= + String( + weekStart + ) && + + date <= + String( + weekEnd + ) + ); + + } + ) + .reduce( + function(sum,row) { + + return ( + sum + + salesNumber_( + row.card_tip_amount + ) + ); + + }, + 0 + ) + ); + + } + + catch(error) { + + return 0; + + } + +} + + + + +/* ============================================================ + VÝPOČTY +============================================================ */ + +function calculateSalesReport_( + date, + values, + employeeCount +) { + + const cashToHandover = + roundMoney_( + values.cash_on_site + + values.cash_bolt + + values.cash_foodora + ); + + + const totalRevenue = + roundMoney_( + values.cash_on_site + + values.card_on_site + + values.total_bolt + + values.total_wolt + + values.total_foodora + ); + + + const cardTipGross = + roundMoney_( + Math.max( + 0, + values.card_received - + values.card_on_site + ) + ); + + + /* + * Ze spropitného placeného kartou se odečte 21 %. + * Zaměstnancům se tedy rozděluje 79 % rozdílu. + */ + const cardTipNet = + roundMoney_( + cardTipGross * + 0.79 + ); + + + const day = + salesDayOfWeek_( + date + ); + + + const isWeekend = + ( + day === + 5 || + day === + 6 + ); + + + const threshold = + isWeekend + ? 10000 + : 5000; + + + const bonusPool = + roundMoney_( + Math.max( + 0, + totalRevenue - + threshold + ) * + 0.10 + ); + + + return { + + cash_to_handover: + cashToHandover, + + total_revenue: + totalRevenue, + + bonus_threshold: + threshold, + + bonus_pool: + bonusPool, + + card_tip_gross: + cardTipGross, + + card_tip_net: + cardTipNet, + + employee_count: + Number( + employeeCount || + 0 + ) + + }; + +} + + + +/* ============================================================ + PROVOZNÍ DATUM TRŽBY PRO ZAMĚSTNANCE + + Hledáme jeho skutečně přihlášenou plánovanou směnu. + U směny přes půlnoc patří celý výkaz k datu začátku směny. + + Dostupnost: + - od začátku směny + - do 12 hodin po jejím plánovaném konci + + Pokud je více relevantních směn, vybereme tu nejnovější. +============================================================ */ + +function resolveEmployeeSalesBusinessDate_( + employeeId, + requestedDate +) { + + employeeId = + String( + employeeId || + '' + ); + + + if ( + !employeeId + ) { + + return null; + + } + + + const slots = + displayRows_( + 'SHIFT_SLOTS' + ); + + + const signups = + displayRows_( + 'SHIFT_SIGNUPS' + ); + + + const employeeSlotIds = + {}; + + + signups.forEach( + function(signup) { + + if ( + String( + signup.employee_id || + '' + ) !== + employeeId + ) { + + return; + + } + + + if ( + String( + signup.status || + '' + ) !== + 'APPROVED' + ) { + + return; + + } + + + employeeSlotIds[ + String( + signup.slot_id || + '' + ) + ] = + true; + + } + ); + + + const now = + new Date(); + + + /* + * Pro jistotu pracujeme s requestedDate +/- 1 den, + * aby frontend mohl stále posílat dnešní datum. + */ + const requested = + salesDateOnlyToDate_( + requestedDate + ); + + + const allowedDates = + {}; + + + if ( + requested + ) { + + [ + -1, + 0, + 1 + ].forEach( + function(offset) { + + const date = + new Date( + requested + ); + + + date.setDate( + date.getDate() + + offset + ); + + + allowedDates[ + Utilities.formatDate( + date, + CFG.TZ, + 'yyyy-MM-dd' + ) + ] = + true; + + } + ); + + } + + + const candidates = + []; + + + slots.forEach( + function(slot) { + + if ( + !employeeSlotIds[ + String( + slot.slot_id || + '' + ) + ] + ) { + + return; + + } + + + const workDate = + String( + slot.date || + '' + ).substring( + 0, + 10 + ); + + + if ( + !workDate + ) { + + return; + + } + + + if ( + requested && + !allowedDates[ + workDate + ] + ) { + + return; + + } + + + const start = + salesSlotDateTime_( + workDate, + slot.start_time + ); + + + let end = + salesSlotDateTime_( + workDate, + slot.end_time + ); + + + if ( + !start || + !end + ) { + + return; + + } + + + /* + * Pokud je konec <= začátek, směna přechází přes půlnoc. + */ + if ( + end.getTime() <= + start.getTime() + ) { + + end = + new Date( + end.getTime() + + 24 * + 60 * + 60 * + 1000 + ); + + } + + + const availableFrom = + start; + + + const availableUntil = + new Date( + end.getTime() + + 12 * + 60 * + 60 * + 1000 + ); + + + if ( + now.getTime() < + availableFrom.getTime() || + now.getTime() > + availableUntil.getTime() + ) { + + return; + + } + + + candidates.push( + { + + work_date: + workDate, + + start_at: + start, + + end_at: + end, + + slot_id: + String( + slot.slot_id || + '' + ) + + } + ); + + } + ); + + + if ( + !candidates.length + ) { + + return null; + + } + + + candidates.sort( + function(a,b) { + + return ( + b.start_at.getTime() - + a.start_at.getTime() + ); + + } + ); + + + return candidates[0]; + +} + + +function salesDateOnlyToDate_( + value +) { + + const match = + String( + value || + '' + ).match( + /^(\d{4})-(\d{2})-(\d{2})$/ + ); + + + if ( + !match + ) { + + return null; + + } + + + return new Date( + Number( + match[1] + ), + Number( + match[2] + ) - 1, + Number( + match[3] + ), + 12, + 0, + 0, + 0 + ); + +} + + +function salesSlotDateTime_( + workDate, + timeValue +) { + + const dateMatch = + String( + workDate || + '' + ).match( + /^(\d{4})-(\d{2})-(\d{2})$/ + ); + + + const timeMatch = + String( + timeValue || + '' + ).match( + /(\d{1,2}):(\d{2})/ + ); + + + if ( + !dateMatch || + !timeMatch + ) { + + return null; + + } + + + return new Date( + Number( + dateMatch[1] + ), + Number( + dateMatch[2] + ) - 1, + Number( + dateMatch[3] + ), + Number( + timeMatch[1] + ), + Number( + timeMatch[2] + ), + 0, + 0 + ); + +} + + +/* ============================================================ + PLÁNOVANÍ LIDÉ PRO DEN +============================================================ */ + +function getSalesPlanningContext_( + date +) { + + const slots = + displayRows_( + 'SHIFT_SLOTS' + ); + + + const signups = + displayRows_( + 'SHIFT_SIGNUPS' + ); + + + const daySlots = + slots.filter( + function(slot) { + + return ( + String( + slot.date || + '' + ).substring( + 0, + 10 + ) === + date + ); + + } + ); + + + const slotMap = + {}; + + + let locationId = + ''; + + + daySlots.forEach( + function(slot) { + + slotMap[ + String( + slot.slot_id + ) + ] = + true; + + + if ( + !locationId && + slot.location_id + ) { + + locationId = + String( + slot.location_id + ); + + } + + } + ); + + + const employeeMap = + {}; + + + signups.forEach( + function(signup) { + + if ( + String( + signup.status + ) !== + 'APPROVED' + ) { + + return; + + } + + + if ( + !slotMap[ + String( + signup.slot_id + ) + ] + ) { + + return; + + } + + + const employeeId = + String( + signup.employee_id || + '' + ); + + + if ( + employeeId + ) { + + employeeMap[ + employeeId + ] = + true; + + } + + } + ); + + + return { + + location_id: + locationId, + + employee_ids: + Object.keys( + employeeMap + ) + + }; + +} + + + +/* ============================================================ + ROZDĚLENÍ BONUSU +============================================================ */ + +function replaceSalesBonusAllocations_( + reportId, + date, + employeeIds, + bonusPool, + cardTipNet +) { + + const existing = + safeSalesRows_( + SALES_SHEETS.BONUSES + ) + .filter( + function(row) { + + return ( + String( + row.report_id + ) === + String( + reportId + ) + ); + + } + ); + + + /* + * Existující alokace nulujeme. + * Tím zůstává auditovatelná historie v jednom listu a + * následně přepíšeme / doplníme správné řádky. + */ + + existing.forEach( + function(row) { + + updateBy_( + SALES_SHEETS.BONUSES, + 'allocation_id', + row.allocation_id, + { + + bonus_amount: + 0, + + card_tip_amount: + 0, + + updated_at: + now_() + + } + ); + + } + ); + + + if ( + !employeeIds.length + ) { + + return; + + } + + + const share = + roundMoney_( + bonusPool / + employeeIds.length + ); + + + const tipShare = + roundMoney_( + cardTipNet / + employeeIds.length + ); + + + employeeIds.forEach( + function(employeeId,index) { + + const old = + existing.find( + function(row) { + + return ( + String( + row.employee_id + ) === + String( + employeeId + ) + ); + + } + ); + + + /* + * Kvůli haléřovému zaokrouhlení dostane poslední člověk + * případný rozdíl tak, aby součet přesně seděl. + */ + + const amount = + index === + employeeIds.length - + 1 + ? roundMoney_( + bonusPool - + share * + ( + employeeIds.length - + 1 + ) + ) + : share; + + + const tipAmount = + index === + employeeIds.length - + 1 + ? roundMoney_( + cardTipNet - + tipShare * + ( + employeeIds.length - + 1 + ) + ) + : tipShare; + + + if ( + old + ) { + + updateBy_( + SALES_SHEETS.BONUSES, + 'allocation_id', + old.allocation_id, + { + + bonus_amount: + amount, + + card_tip_amount: + tipAmount, + + updated_at: + now_() + + } + ); + + } else { + + append_( + SALES_SHEETS.BONUSES, + { + + allocation_id: + uuid_( + 'SBON' + ), + + report_id: + reportId, + + work_date: + date, + + employee_id: + employeeId, + + bonus_amount: + amount, + + card_tip_amount: + tipAmount, + + created_at: + now_(), + + updated_at: + now_() + + } + ); + + } + + } + ); + +} + + + +/* ============================================================ + HELPERS +============================================================ */ + +function emptySalesValues_() { + + return { + + cash_on_site:0, + card_on_site:0, + card_received:0, + cash_bolt:0, + cash_foodora:0, + total_bolt:0, + total_wolt:0, + total_foodora:0 + + }; + +} + + +function salesReportValues_( + row +) { + + return { + + cash_on_site: + salesNumber_( + row.cash_on_site + ), + + card_on_site: + salesNumber_( + row.card_on_site + ), + + card_received: + salesNumber_( + row.card_received + ), + + cash_bolt: + salesNumber_( + row.cash_bolt + ), + + cash_foodora: + salesNumber_( + row.cash_foodora + ), + + total_bolt: + salesNumber_( + row.total_bolt + ), + + total_wolt: + salesNumber_( + row.total_wolt + ), + + total_foodora: + salesNumber_( + row.total_foodora + ) + + }; + +} + + +function sanitizeSalesPayload_( + payload +) { + + payload = + payload || + {}; + + + return { + + cash_on_site: + salesNumber_( + payload.cash_on_site + ), + + card_on_site: + salesNumber_( + payload.card_on_site + ), + + card_received: + salesNumber_( + payload.card_received + ), + + cash_bolt: + salesNumber_( + payload.cash_bolt + ), + + cash_foodora: + salesNumber_( + payload.cash_foodora + ), + + total_bolt: + salesNumber_( + payload.total_bolt + ), + + total_wolt: + salesNumber_( + payload.total_wolt + ), + + total_foodora: + salesNumber_( + payload.total_foodora + ) + + }; + +} + + +function salesNumber_( + value +) { + + const number = + Number( + String( + value == null + ? 0 + : value + ) + .replace( + /\s/g, + '' + ) + .replace( + ',', + '.' + ) + ); + + + if ( + !isFinite( + number + ) || + number < + 0 + ) { + + return 0; + + } + + + return number; + +} + + +function roundMoney_( + value +) { + + return Math.round( + Number( + value || + 0 + ) * + 100 + ) / + 100; + +} + + +function salesDayOfWeek_( + date +) { + + const parts = + String( + date + ).split( + '-' + ); + + + return new Date( + Number( + parts[0] + ), + Number( + parts[1] + ) - + 1, + Number( + parts[2] + ) + ).getDay(); + +} + + +function normalizeSalesRange_( + dateFrom, + dateTo +) { + + const today = + Utilities.formatDate( + new Date(), + CFG.TZ, + 'yyyy-MM-dd' + ); + + + const now = + new Date(); + + + const monthStart = + Utilities.formatDate( + new Date( + now.getFullYear(), + now.getMonth(), + 1 + ), + CFG.TZ, + 'yyyy-MM-dd' + ); + + + let from = + String( + dateFrom || + '' + ).trim(); + + + let to = + String( + dateTo || + '' + ).trim(); + + + if ( + !/^\d{4}-\d{2}-\d{2}$/.test( + from + ) + ) { + + from = + monthStart; + + } + + + if ( + !/^\d{4}-\d{2}-\d{2}$/.test( + to + ) + ) { + + to = + today; + + } + + + if ( + from > + to + ) { + + const swap = + from; + + from = + to; + + to = + swap; + + } + + + return { + + from: + from, + + to: + to + + }; + +} + + +function normalizeSalesDate_( + dateValue +) { + + const value = + String( + dateValue || + '' + ).trim(); + + + if ( + /^\d{4}-\d{2}-\d{2}$/.test( + value + ) + ) { + + return value; + + } + + + return Utilities.formatDate( + new Date(), + CFG.TZ, + 'yyyy-MM-dd' + ); + +} + + +function safeSalesRows_( + sheetName +) { + + try { + + return displayRows_( + sheetName + ); + + } + + catch(error) { + + return []; + + } + +} + + +/* ============================================================ + DATABÁZOVÝ SPREADSHEET PRO TRŽBY + + Nepoužíváme getPlanningSpreadsheet_(). + Nejprve využijeme existující sh_(), které už celý portál + používá pro přístup k databázi. + + Díky tomu není potřeba ručně nastavovat SPREADSHEET_ID. +============================================================ */ + +function getSalesSpreadsheet_() { + + /* + * 1) Nejspolehlivější varianta: + * vezmeme parent spreadsheet existujícího databázového listu. + */ + + try { + + const usersSheet = + sh_( + CFG.SHEETS.USERS + ); + + + if ( + usersSheet + ) { + + const spreadsheet = + usersSheet.getParent(); + + + if ( + spreadsheet + ) { + + return spreadsheet; + + } + + } + + } + + catch(error) { + + /* + * Pokračujeme další možností. + */ + + } + + + /* + * 2) Zkusíme EMPLOYEES. + */ + + try { + + const employeesSheet = + sh_( + CFG.SHEETS.EMPLOYEES + ); + + + if ( + employeesSheet + ) { + + const spreadsheet = + employeesSheet.getParent(); + + + if ( + spreadsheet + ) { + + return spreadsheet; + + } + + } + + } + + catch(error) { + + /* + * Pokračujeme další možností. + */ + + } + + + /* + * 3) Pokud je Apps Script přímo navázaný na tabulku. + */ + + const active = + SpreadsheetApp + .getActiveSpreadsheet(); + + + if ( + active + ) { + + return active; + + } + + + /* + * 4) Poslední kompatibilní fallback pro standalone projekt. + */ + + const propertyId = + PropertiesService + .getScriptProperties() + .getProperty( + 'SPREADSHEET_ID' + ); + + + if ( + propertyId + ) { + + return SpreadsheetApp + .openById( + propertyId + ); + + } + + + throw new Error( + 'Nepodařilo se otevřít databázový spreadsheet pro tržby.' + ); + +} + + +function ensureSalesSheet_( + sheetName, + headers +) { + + const ss = + getSalesSpreadsheet_(); + + + let sheet = + ss.getSheetByName( + sheetName + ); + + + if ( + !sheet + ) { + + sheet = + ss.insertSheet( + sheetName + ); + + } + + + const currentColumns = + Math.max( + sheet.getLastColumn(), + 1 + ); + + + let existing = + sheet + .getRange( + 1, + 1, + 1, + currentColumns + ) + .getDisplayValues()[0] + .map( + function(value) { + + return String( + value + ).trim(); + + } + ); + + + if ( + existing.length === + 1 && + !existing[0] + ) { + + existing = + []; + + } + + + headers.forEach( + function(header) { + + if ( + existing.includes( + header + ) + ) { + + return; + + } + + + const col = + existing.length + + 1; + + + sheet + .getRange( + 1, + col + ) + .setValue( + header + ); + + + existing.push( + header + ); + + } + ); + + + sheet.setFrozenRows( + 1 + ); + +} diff --git a/gscript/DashboardService.js b/gscript/DashboardService.js new file mode 100644 index 0000000..1acecd2 --- /dev/null +++ b/gscript/DashboardService.js @@ -0,0 +1,1099 @@ +function getDashboard( + token, + fromDate, + toDate +) { + + const user = + requireUser_( + token + ); + + + if ( + !user.employee_id + ) { + + return getRoleDashboard_( + user + ); + + } + + + /* ========================================= + ZAMĚSTNANCI + ========================================= */ + + const employees = + displayRows_( + CFG.SHEETS.EMPLOYEES + ); + + + const employee = + employees.find( + function(item) { + + return ( + String( + item.employee_id + ) === + String( + user.employee_id + ) + ); + + } + ); + + + if ( + !employee + ) { + + throw new Error( + 'Zaměstnanec nebyl nalezen.' + ); + + } + + + /* ========================================= + DATUMOVÝ ROZSAH + ========================================= */ + + const now = + new Date(); + + + let from; + + + if ( + fromDate + ) { + + from = + new Date( + fromDate + + 'T00:00:00' + ); + + } else { + + from = + new Date( + now.getFullYear(), + now.getMonth(), + 1, + 0, + 0, + 0 + ); + + } + + + let to; + + + if ( + toDate + ) { + + to = + new Date( + toDate + + 'T23:59:59' + ); + + } else { + + to = + now; + + } + + + /* ========================================= + SMĚNY + ========================================= */ + + const allShifts = + displayRows_( + CFG.SHEETS.SHIFTS + ); + + + const employeeShifts = + allShifts + .filter( + function(shift) { + + return ( + String( + shift.employee_id + ) === + String( + employee.employee_id + ) + ); + + } + ) + .filter( + function(shift) { + + const clockIn = + parseEmployeeDate_( + shift.clock_in + ); + + + if ( + !clockIn + ) { + + return false; + + } + + + return ( + clockIn >= from && + clockIn <= to + ); + + } + ) + .sort( + function(a,b) { + + const aDate = + parseEmployeeDate_( + a.clock_in + ); + + + const bDate = + parseEmployeeDate_( + b.clock_in + ); + + + return ( + ( + bDate + ? bDate.getTime() + : 0 + ) + - + ( + aDate + ? aDate.getTime() + : 0 + ) + ); + + } + ); + + + /* ========================================= + SAZBY + ========================================= */ + + const payRates = + displayRows_( + CFG.SHEETS.PAY_RATES + ); + + + /* ========================================= + VÝPOČET HODIN + PENĚZ + ========================================= */ + + let workedMinutes = + 0; + + + let earnedEstimate = + 0; + + + let shiftCount = + 0; + + + employeeShifts.forEach( + function(shift) { + + const status = + String( + shift.status || '' + ).trim(); + + + /* + * Do výdělku počítáme pouze + * dokončené směny. + */ + + if ( + ![ + 'COMPLETED', + 'EMPLOYEE_CONFIRMED', + 'MANAGER_APPROVED', + 'LOCKED' + ].includes( + status + ) + ) { + + return; + + } + + + const minutes = + numberFromSheet_( + shift.worked_minutes + ); + + + if ( + minutes <= 0 + ) { + + return; + + } + + + const shiftDate = + parseEmployeeDate_( + shift.clock_in + ); + + + const rate = + employeeRateAt_( + payRates, + employee.employee_id, + shiftDate + ); + + + workedMinutes += + minutes; + + + shiftCount++; + + + earnedEstimate += + ( + minutes / + 60 + ) * + rate; + + } + ); + + + /* ========================================= + OTEVŘENÁ SMĚNA + ========================================= */ + + const openShift = + allShifts + .filter( + function(shift) { + + return ( + String( + shift.employee_id + ) === + String( + employee.employee_id + ) && + String( + shift.status + ).trim() === + 'OPEN' + ); + + } + ) + .sort( + function(a,b) { + + const aDate = + parseEmployeeDate_( + a.clock_in + ); + + + const bDate = + parseEmployeeDate_( + b.clock_in + ); + + + return ( + ( + bDate + ? bDate.getTime() + : 0 + ) + - + ( + aDate + ? aDate.getTime() + : 0 + ) + ); + + } + )[0] || null; + + + let currentShiftMinutes = + 0; + + + if ( + openShift + ) { + + const clockIn = + parseEmployeeDate_( + openShift.clock_in + ); + + + if ( + clockIn + ) { + + currentShiftMinutes = + Math.max( + 0, + Math.floor( + ( + Date.now() - + clockIn.getTime() + ) / + 60000 + ) + ); + + } + + } + + + /* ========================================= + AKTUALITY + ========================================= */ + + let news = + []; + + + try { + + news = + getNewsForUser_( + user, + employee + ); + + + /* + * Bezpečný převod pro frontend. + */ + + news = + news + .slice( + 0, + 8 + ) + .map( + function(item) { + + return { + + news_id: + String( + item.news_id || '' + ), + + title: + String( + item.title || '' + ), + + content: + String( + item.content || '' + ), + + category: + String( + item.category || '' + ), + + require_confirmation: + item.require_confirmation === true || + String( + item.require_confirmation + ).toUpperCase() === + 'TRUE' + + }; + + } + ); + + } catch(error) { + + news = + []; + + } + + + /* ========================================= + BEZPEČNÝ SEZNAM SMĚN + ========================================= */ + + const safeShifts = + employeeShifts + .slice( + 0, + 20 + ) + .map( + function(shift) { + + const clockIn = + parseEmployeeDate_( + shift.clock_in + ); + + + const clockOut = + parseEmployeeDate_( + shift.clock_out + ); + + + return { + + shift_id: + String( + shift.shift_id || '' + ), + + employee_id: + String( + shift.employee_id || '' + ), + + work_date: + String( + shift.work_date || '' + ), + + clock_in: + clockIn + ? clockIn.toISOString() + : '', + + clock_out: + clockOut + ? clockOut.toISOString() + : '', + + break_minutes: + numberFromSheet_( + shift.break_minutes + ), + + worked_minutes: + numberFromSheet_( + shift.worked_minutes + ), + + status: + String( + shift.status || '' + ), + + location_id: + String( + shift.location_id || '' + ) + + }; + + } + ); + + + /* ========================================= + DEBUG INFO + + Zatím schválně vracíme i sazbu, + ať přesně vidíme, co systém používá. + ========================================= */ + + const currentRate = + employeeRateAt_( + payRates, + employee.employee_id, + now + ); + + + /* ========================================= + VÝSLEDEK + ========================================= */ + + return { + + role: + String( + user.role || '' + ), + + + employee: { + + employee_id: + String( + employee.employee_id || '' + ), + + name: + ( + String( + employee.first_name || '' + ) + + ' ' + + String( + employee.last_name || '' + ) + ).trim(), + + position: + String( + employee.position || '' + ) + + }, + + + stats: { + + worked_minutes: + Math.round( + workedMinutes + ), + + earned_estimate: + Math.round( + earnedEstimate + ), + + shift_count: + shiftCount, + + current_shift_minutes: + currentShiftMinutes, + + hourly_rate: + currentRate + + }, + + + open_shift: + openShift + ? { + + shift_id: + String( + openShift.shift_id || '' + ), + + clock_in: + parseEmployeeDate_( + openShift.clock_in + ) + ? parseEmployeeDate_( + openShift.clock_in + ).toISOString() + : '', + + status: + 'OPEN' + + } + : null, + + + shifts: + safeShifts, + + + schedules: + [], + + + news: + news + + }; + +} + + + +/* ============================================================ + SAZBA PRO KONKRÉTNÍ DATUM +============================================================ */ + +function employeeRateAt_( + rates, + employeeId, + targetDate +) { + + if ( + !targetDate + ) { + + return 0; + + } + + + const target = + Utilities.formatDate( + targetDate, + CFG.TZ, + 'yyyy-MM-dd' + ); + + + const employeeRates = + rates + .filter( + function(rate) { + + return ( + String( + rate.employee_id + ) === + String( + employeeId + ) + ); + + } + ) + .filter( + function(rate) { + + const fromDate = + parseEmployeeDate_( + rate.valid_from + ); + + + const toDate = + parseEmployeeDate_( + rate.valid_to + ); + + + const from = + fromDate + ? Utilities.formatDate( + fromDate, + CFG.TZ, + 'yyyy-MM-dd' + ) + : '0000-00-00'; + + + const to = + toDate + ? Utilities.formatDate( + toDate, + CFG.TZ, + 'yyyy-MM-dd' + ) + : '9999-12-31'; + + + return ( + from <= target && + to >= target + ); + + } + ); + + + if ( + !employeeRates.length + ) { + + return 0; + + } + + + employeeRates.sort( + function(a,b) { + + const aDate = + parseEmployeeDate_( + a.valid_from + ); + + + const bDate = + parseEmployeeDate_( + b.valid_from + ); + + + return ( + ( + bDate + ? bDate.getTime() + : 0 + ) + - + ( + aDate + ? aDate.getTime() + : 0 + ) + ); + + } + ); + + + return numberFromSheet_( + employeeRates[0] + .hourly_rate + ); + +} + + + +/* ============================================================ + DATUM Z GOOGLE SHEETS +============================================================ */ + +function parseEmployeeDate_( + value +) { + + const text = + String( + value || '' + ).trim(); + + + if ( + !text + ) { + + return null; + + } + + + let match; + + + /* + * 2026-08-11 + * 2026-08-11 23:45:00 + */ + + match = + text.match( + /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/ + ); + + + if ( + match + ) { + + return new Date( + Number( + match[1] + ), + Number( + match[2] + ) - 1, + Number( + match[3] + ), + Number( + match[4] || 0 + ), + Number( + match[5] || 0 + ), + Number( + match[6] || 0 + ) + ); + + } + + + /* + * 11.8.2026 23:45:00 + */ + + match = + text.match( + /^(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/ + ); + + + if ( + match + ) { + + return new Date( + Number( + match[3] + ), + Number( + match[2] + ) - 1, + Number( + match[1] + ), + Number( + match[4] || 0 + ), + Number( + match[5] || 0 + ), + Number( + match[6] || 0 + ) + ); + + } + + + /* + * 8/11/2026 23:45:00 + */ + + match = + text.match( + /^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/ + ); + + + if ( + match + ) { + + return new Date( + Number( + match[3] + ), + Number( + match[1] + ) - 1, + Number( + match[2] + ), + Number( + match[4] || 0 + ), + Number( + match[5] || 0 + ), + Number( + match[6] || 0 + ) + ); + + } + + + const date = + new Date( + text + ); + + + if ( + isNaN( + date.getTime() + ) + ) { + + return null; + + } + + + return date; + +} + + + +/* ============================================================ + ROLE DASHBOARD +============================================================ */ + +function getRoleDashboard_( + user +) { + + return { + + role: + String( + user.role || '' + ) + + }; + +} + + + +/* ============================================================ + TEST VÝDĚLKU + + Spustitelné přímo z editoru. +============================================================ */ + +function testEmployeeEarnings() { + + const employees = + displayRows_( + CFG.SHEETS.EMPLOYEES + ); + + + const shifts = + displayRows_( + CFG.SHEETS.SHIFTS + ); + + + const rates = + displayRows_( + CFG.SHEETS.PAY_RATES + ); + + + employees.forEach( + function(employee) { + + const employeeId = + String( + employee.employee_id + ); + + + const employeeShifts = + shifts.filter( + function(shift) { + + return ( + String( + shift.employee_id + ) === + employeeId + ); + + } + ); + + + Logger.log( + '--------------------------------' + ); + + + Logger.log( + 'ZAMĚSTNANEC: ' + + employee.first_name + + ' ' + + employee.last_name + ); + + + employeeShifts.forEach( + function(shift) { + + const date = + parseEmployeeDate_( + shift.clock_in + ); + + + const rate = + employeeRateAt_( + rates, + employeeId, + date + ); + + + const minutes = + numberFromSheet_( + shift.worked_minutes + ); + + + Logger.log( + 'SMĚNA: ' + + shift.shift_id + + ' | stav=' + + shift.status + + ' | minuty=' + + minutes + + ' | sazba=' + + rate + + ' | výdělek=' + + ( + minutes / + 60 * + rate + ) + ); + + } + ); + + } + ); + +} \ No newline at end of file diff --git a/gscript/Database.js b/gscript/Database.js new file mode 100644 index 0000000..6df9d9c --- /dev/null +++ b/gscript/Database.js @@ -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); } +} \ No newline at end of file diff --git a/gscript/EmployeeEditService.js b/gscript/EmployeeEditService.js new file mode 100644 index 0000000..d94bd8f --- /dev/null +++ b/gscript/EmployeeEditService.js @@ -0,0 +1,1175 @@ +/* ============================================================ + MESTAFF – EMPLOYEE EDIT SERVICE + Bez zásahu do existujícího EmployeeService.gs. +============================================================ */ + + +function getEmployeeDetail( + token, + employeeId +) { + + const currentUser = + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + const employees = + displayRows_( + CFG.SHEETS.EMPLOYEES + ); + + + const employee = + employees.find( + function(row) { + + return ( + String( + row.employee_id + ) === + String( + employeeId + ) + ); + + } + ); + + + if ( + !employee + ) { + + throw new Error( + 'Zaměstnanec nebyl nalezen.' + ); + + } + + + const users = + displayRows_( + CFG.SHEETS.USERS + ); + + + const employeeUser = + users.find( + function(row) { + + return ( + String( + row.employee_id + ) === + String( + employeeId + ) + ); + + } + ); + + + if ( + currentUser.role !== + 'ADMIN' && + employeeUser && + employeeUser.role === + 'ADMIN' + ) { + + throw new Error( + 'Vedoucí nemůže upravovat administrátora.' + ); + + } + + + const rates = + displayRows_( + CFG.SHEETS.PAY_RATES + ); + + + const currentRate = + employeeEditCurrentRate_( + rates, + employeeId, + new Date() + ); + + + return { + + employee_id: + String( + employee.employee_id || + '' + ), + + first_name: + String( + employee.first_name || + '' + ), + + last_name: + String( + employee.last_name || + '' + ), + + email: + String( + employee.email || + ( + employeeUser + ? employeeUser.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: + employeeEditDateOnly_( + employee.start_date + ), + + end_date: + employeeEditDateOnly_( + employee.end_date + ), + + active: + employeeEditBool_( + employee.active + ), + + role: + employeeUser + ? String( + employeeUser.role || + 'EMPLOYEE' + ) + : 'EMPLOYEE', + + hourly_rate: + currentRate + ? numberFromSheet_( + currentRate.hourly_rate + ) + : 0 + + }; + +} + + + +function updateEmployee( + token, + data +) { + + const currentUser = + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + data = + data || + {}; + + + const employeeId = + String( + data.employee_id || + '' + ).trim(); + + + if ( + !employeeId + ) { + + throw new Error( + 'Chybí employee_id.' + ); + + } + + + const firstName = + String( + data.first_name || + '' + ).trim(); + + + const lastName = + String( + data.last_name || + '' + ).trim(); + + + const email = + String( + data.email || + '' + ) + .trim() + .toLowerCase(); + + + const role = + String( + data.role || + 'EMPLOYEE' + ) + .trim() + .toUpperCase(); + + + if ( + !firstName || + !lastName + ) { + + throw new Error( + 'Jméno a příjmení jsou povinné.' + ); + + } + + + if ( + !email || + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( + email + ) + ) { + + throw new Error( + 'Zadej platný e-mail.' + ); + + } + + + if ( + ![ + 'EMPLOYEE', + 'MANAGER', + 'ACCOUNTANT', + 'ADMIN' + ].includes( + role + ) + ) { + + throw new Error( + 'Neplatná role.' + ); + + } + + + if ( + currentUser.role !== + 'ADMIN' && + role === + 'ADMIN' + ) { + + throw new Error( + 'Pouze administrátor může přidělit roli ADMIN.' + ); + + } + + + const employees = + displayRows_( + CFG.SHEETS.EMPLOYEES + ); + + + const employee = + employees.find( + function(row) { + + return ( + String( + row.employee_id + ) === + employeeId + ); + + } + ); + + + if ( + !employee + ) { + + throw new Error( + 'Zaměstnanec nebyl nalezen.' + ); + + } + + + const users = + displayRows_( + CFG.SHEETS.USERS + ); + + + const employeeUser = + users.find( + function(row) { + + return ( + String( + row.employee_id + ) === + employeeId + ); + + } + ); + + + if ( + currentUser.role !== + 'ADMIN' && + employeeUser && + String( + employeeUser.role + ) === + 'ADMIN' + ) { + + throw new Error( + 'Vedoucí nemůže upravovat administrátora.' + ); + + } + + + /* + * Kontrola e-mailové duplicity. + */ + + const duplicateEmployee = + employees.find( + function(row) { + + return ( + String( + row.employee_id + ) !== + employeeId && + String( + row.email || + '' + ) + .trim() + .toLowerCase() === + email + ); + + } + ); + + + const duplicateUser = + users.find( + function(row) { + + return ( + String( + row.employee_id || + '' + ) !== + employeeId && + String( + row.email || + '' + ) + .trim() + .toLowerCase() === + email + ); + + } + ); + + + if ( + duplicateEmployee || + duplicateUser + ) { + + throw new Error( + 'Tento e-mail už používá jiný účet.' + ); + + } + + + const pin = + String( + data.terminal_pin || + '' + ).trim(); + + + if ( + pin && + !/^\d{4,6}$/.test( + pin + ) + ) { + + throw new Error( + 'PIN musí obsahovat 4 až 6 číslic.' + ); + + } + + + if ( + pin + ) { + + const duplicatePin = + employees.find( + function(row) { + + return ( + String( + row.employee_id + ) !== + employeeId && + employeeEditBool_( + row.active + ) && + String( + row.terminal_pin || + '' + ).trim() === + pin + ); + + } + ); + + + if ( + duplicatePin + ) { + + throw new Error( + 'Tento PIN už používá jiný aktivní zaměstnanec.' + ); + + } + + } + + + const active = + data.active === true || + String( + data.active + ).toUpperCase() === + 'TRUE'; + + + /* + * Základní data zaměstnance. + */ + + updateBy_( + CFG.SHEETS.EMPLOYEES, + 'employee_id', + employeeId, + { + + first_name: + firstName, + + last_name: + lastName, + + email: + email, + + phone: + String( + data.phone || + '' + ).trim(), + + company_id: + String( + data.company_id || + '' + ).trim(), + + location_id: + String( + data.location_id || + '' + ).trim(), + + position: + String( + data.position || + '' + ).trim(), + + employment_type: + String( + data.employment_type || + '' + ).trim(), + + terminal_pin: + pin, + + start_date: + String( + data.start_date || + '' + ).trim(), + + end_date: + String( + data.end_date || + '' + ).trim(), + + active: + active + + } + ); + + + /* + * Synchronizace přihlašovacího účtu. + */ + + if ( + employeeUser + ) { + + updateBy_( + CFG.SHEETS.USERS, + 'user_id', + employeeUser.user_id, + { + + email: + email, + + role: + role, + + active: + active + + } + ); + + } + + + /* + * Historická změna hodinové sazby. + */ + + let rateChanged = + false; + + + const newRate = + employeeEditNumber_( + data.hourly_rate + ); + + + const validFrom = + String( + data.rate_valid_from || + Utilities.formatDate( + new Date(), + CFG.TZ, + 'yyyy-MM-dd' + ) + ).trim(); + + + if ( + !/^\d{4}-\d{2}-\d{2}$/.test( + validFrom + ) + ) { + + throw new Error( + 'Neplatné datum účinnosti nové sazby.' + ); + + } + + + const rates = + displayRows_( + CFG.SHEETS.PAY_RATES + ); + + + const currentRate = + employeeEditCurrentRate_( + rates, + employeeId, + employeeEditParseDate_( + validFrom + ) + ); + + + const currentRateAmount = + currentRate + ? numberFromSheet_( + currentRate.hourly_rate + ) + : 0; + + + if ( + Math.abs( + newRate - + currentRateAmount + ) > + 0.0001 + ) { + + /* + * Zavřeme předchozí sazbu den před účinností nové. + */ + + if ( + currentRate && + currentRate.pay_rate_id + ) { + + const previousDay = + employeeEditAddDays_( + validFrom, + -1 + ); + + + const currentValidFrom = + employeeEditDateOnly_( + currentRate.valid_from + ); + + + if ( + !currentValidFrom || + currentValidFrom < + validFrom + ) { + + updateBy_( + CFG.SHEETS.PAY_RATES, + 'pay_rate_id', + currentRate.pay_rate_id, + { + + valid_to: + previousDay + + } + ); + + } + + } + + + append_( + CFG.SHEETS.PAY_RATES, + { + + pay_rate_id: + uuid_( + 'RATE' + ), + + employee_id: + employeeId, + + valid_from: + validFrom, + + valid_to: + '', + + hourly_rate: + newRate, + + created_by: + currentUser.user_id, + + created_at: + now_() + + } + ); + + + rateChanged = + true; + + } + + + audit_( + currentUser.user_id, + 'EMPLOYEE_UPDATED', + 'EMPLOYEE', + employeeId, + '', + JSON.stringify({ + + email: + email, + + role: + role, + + active: + active, + + hourly_rate: + newRate, + + rate_changed: + rateChanged + + }) + ); + + + return { + + ok:true, + + rate_changed: + rateChanged + + }; + +} + + + +/* ============================================================ + HELPERS +============================================================ */ + +function employeeEditBool_( + value +) { + + if ( + value === + true + ) { + + return true; + + } + + + return [ + 'TRUE', + 'ANO', + 'YES', + '1' + ].includes( + String( + value || + '' + ) + .trim() + .toUpperCase() + ); + +} + + +function employeeEditNumber_( + value +) { + + const number = + Number( + String( + value == null + ? 0 + : value + ) + .trim() + .replace( + ',', + '.' + ) + ); + + + if ( + !isFinite( + number + ) || + number < + 0 + ) { + + throw new Error( + 'Hodinová sazba musí být nezáporné číslo.' + ); + + } + + + return Math.round( + number * + 100 + ) / + 100; + +} + + +function employeeEditParseDate_( + value +) { + + const match = + String( + value || + '' + ) + .trim() + .match( + /^(\d{4})-(\d{2})-(\d{2})$/ + ); + + + if ( + !match + ) { + + return null; + + } + + + return new Date( + Number( + match[1] + ), + Number( + match[2] + ) - + 1, + Number( + match[3] + ) + ); + +} + + +function employeeEditDateOnly_( + value +) { + + if ( + !value + ) { + + return ''; + + } + + + if ( + /^\d{4}-\d{2}-\d{2}$/.test( + String( + value + ).trim() + ) + ) { + + return String( + value + ).trim(); + + } + + + const date = + typeof parseEmployeeDate_ === + 'function' + ? parseEmployeeDate_( + value + ) + : new Date( + value + ); + + + if ( + !date || + isNaN( + date.getTime() + ) + ) { + + return ''; + + } + + + return Utilities.formatDate( + date, + CFG.TZ, + 'yyyy-MM-dd' + ); + +} + + +function employeeEditCurrentRate_( + rates, + employeeId, + targetDate +) { + + if ( + !targetDate + ) { + + return null; + + } + + + const target = + Utilities.formatDate( + targetDate, + CFG.TZ, + 'yyyy-MM-dd' + ); + + + const valid = + rates + .filter( + function(rate) { + + if ( + String( + rate.employee_id + ) !== + String( + employeeId + ) + ) { + + return false; + + } + + + const from = + employeeEditDateOnly_( + rate.valid_from + ) || + '0000-00-00'; + + + const to = + employeeEditDateOnly_( + rate.valid_to + ) || + '9999-12-31'; + + + return ( + from <= + target && + to >= + target + ); + + } + ) + .sort( + function(a,b) { + + return employeeEditDateOnly_( + b.valid_from + ).localeCompare( + employeeEditDateOnly_( + a.valid_from + ) + ); + + } + ); + + + return valid.length + ? valid[0] + : null; + +} + + +function employeeEditAddDays_( + yyyyMmDd, + days +) { + + const date = + employeeEditParseDate_( + yyyyMmDd + ); + + + if ( + !date + ) { + + return ''; + + } + + + date.setDate( + date.getDate() + + Number( + days || + 0 + ) + ); + + + return Utilities.formatDate( + date, + CFG.TZ, + 'yyyy-MM-dd' + ); + +} + + + +/* ============================================================ + DIAGNOSTIKA +============================================================ */ + +function testEmployeeEditService() { + + Logger.log( + 'EMPLOYEES: ' + + JSON.stringify( + displayRows_( + CFG.SHEETS.EMPLOYEES + ) + ) + ); + + + Logger.log( + 'USERS: ' + + JSON.stringify( + displayRows_( + CFG.SHEETS.USERS + ) + ) + ); + + + Logger.log( + 'PAY_RATES: ' + + JSON.stringify( + displayRows_( + CFG.SHEETS.PAY_RATES + ) + ) + ); + + + Logger.log( + 'EmployeeEditService OK' + ); + +} diff --git a/gscript/EmployeeService.js b/gscript/EmployeeService.js new file mode 100644 index 0000000..95b791e --- /dev/null +++ b/gscript/EmployeeService.js @@ -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' + ) + }; +} \ No newline at end of file diff --git a/gscript/Index.html b/gscript/Index.html new file mode 100644 index 0000000..5601c66 --- /dev/null +++ b/gscript/Index.html @@ -0,0 +1,18711 @@ + + + + + + + + + EatMe Portál + + + + + + + + + + + + + + + + + +
+ +
+ +
+ + + +
+ EatMe Portál +
+ +
+ zaměstnanci • směny • docházka • mzdy +
+ +
+ + +
+ +

+ Přihlášení +

+ + + + + + + + + + + + + + + + + +
+
+ +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/gscript/LoginPerformanceService.js b/gscript/LoginPerformanceService.js new file mode 100644 index 0000000..e66ab41 --- /dev/null +++ b/gscript/LoginPerformanceService.js @@ -0,0 +1,15 @@ +/* ============================================================ + EATME PORTÁL – LOGIN PERFORMANCE SERVICE +============================================================ */ + +function loginWithUser( + email, + password +) { + + return login( + email, + password + ); + +} diff --git a/gscript/NewsService.js b/gscript/NewsService.js new file mode 100644 index 0000000..05d8429 --- /dev/null +++ b/gscript/NewsService.js @@ -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}; +} \ No newline at end of file diff --git a/gscript/NewsService_Roles.js b/gscript/NewsService_Roles.js new file mode 100644 index 0000000..1e941e9 --- /dev/null +++ b/gscript/NewsService_Roles.js @@ -0,0 +1,1575 @@ +/* ============================================================ + EATME PORTÁL – NEWS SERVICE + + Role: + - EMPLOYEE: čte ALL + EMPLOYEE + - MANAGER: čte ALL + MANAGER, může spravovat aktuality + - ACCOUNTANT: čte ALL + ACCOUNTANT + - ADMIN: čte všechny aktuality, může je spravovat + + Využívá existující: + - NEWS + - NEWS_READS +============================================================ */ + + +/* ============================================================ + SETUP +============================================================ */ + +function setupNews() { + + ensureNewsSheet_( + CFG.SHEETS.NEWS, + HEADERS.NEWS + ); + + + ensureNewsSheet_( + CFG.SHEETS.NEWS_READS, + HEADERS.NEWS_READS + ); + + + Logger.log( + 'News setup OK' + ); + +} + + + +/* ============================================================ + AKTUALITY PRO PŘIHLÁŠENÉHO UŽIVATELE +============================================================ */ + +function getMyNews(token) { + + const user = + requireUser_( + token + ); + + + const role = + String( + user.role || + '' + ).toUpperCase(); + + + const now = + new Date(); + + + const newsRows = + rows_( + CFG.SHEETS.NEWS + ); + + + const readRows = + rows_( + CFG.SHEETS.NEWS_READS + ) + .filter( + function(row) { + + return ( + String( + row.user_id + ) === + String( + user.user_id + ) + ); + + } + ); + + + const readsByNews = + {}; + + + readRows.forEach( + function(row) { + + readsByNews[ + String( + row.news_id + ) + ] = + row; + + } + ); + + + return newsRows + .filter( + function(row) { + + if ( + !newsBool_( + row.active + ) + ) { + + return false; + + } + + + const target = + String( + row.target_role || + 'ALL' + ) + .trim() + .toUpperCase(); + + + /* + * ADMIN vidí všechno. + * Ostatní role pouze ALL + svou roli. + */ + + if ( + role !== + 'ADMIN' && + target !== + 'ALL' && + target !== + role + ) { + + return false; + + } + + + const from = + newsDate_( + row.published_from + ); + + + const to = + newsDate_( + row.published_to + ); + + + if ( + from && + from.getTime() > + now.getTime() + ) { + + return false; + + } + + + if ( + to && + to.getTime() < + now.getTime() + ) { + + return false; + + } + + + return true; + + } + ) + .map( + function(row) { + + const read = + readsByNews[ + String( + row.news_id + ) + ] || + null; + + + return { + + news_id: + String( + row.news_id || + '' + ), + + title: + String( + row.title || + '' + ), + + content: + String( + row.content || + '' + ), + + category: + String( + row.category || + '' + ), + + target_role: + String( + row.target_role || + 'ALL' + ), + + published_from: + row.published_from || + '', + + published_to: + row.published_to || + '', + + require_confirmation: + newsBool_( + row.require_confirmation + ), + + read_at: + read + ? read.read_at || + '' + : '', + + confirmed_at: + read + ? read.confirmed_at || + '' + : '', + + created_at: + row.created_at || + '' + + }; + + } + ) + .sort( + function(a,b) { + + const aImportant = + String( + a.category + ).toUpperCase() === + 'DŮLEŽITÉ' + ? 1 + : 0; + + + const bImportant = + String( + b.category + ).toUpperCase() === + 'DŮLEŽITÉ' + ? 1 + : 0; + + + if ( + aImportant !== + bImportant + ) { + + return ( + bImportant - + aImportant + ); + + } + + + return ( + newsTime_( + b.created_at + ) - + newsTime_( + a.created_at + ) + ); + + } + ); + +} + + + +/* ============================================================ + PŘEČTENO / POTVRZENO +============================================================ */ + +function markNewsRead( + token, + newsId, + confirm +) { + + const user = + requireUser_( + token + ); + + + const news = + findOne_( + CFG.SHEETS.NEWS, + function(row) { + + return ( + String( + row.news_id + ) === + String( + newsId + ) + ); + + } + ); + + + if ( + !news + ) { + + throw new Error( + 'Aktualita nebyla nalezena.' + ); + + } + + + const existing = + findOne_( + CFG.SHEETS.NEWS_READS, + function(row) { + + return ( + String( + row.news_id + ) === + String( + newsId + ) && + + String( + row.user_id + ) === + String( + user.user_id + ) + ); + + } + ); + + + const stamp = + now_(); + + + if ( + existing + ) { + + const update = + {}; + + + if ( + !existing.read_at + ) { + + update.read_at = + stamp; + + } + + + if ( + confirm + ) { + + update.confirmed_at = + stamp; + + } + + + if ( + Object.keys( + update + ).length + ) { + + updateBy_( + CFG.SHEETS.NEWS_READS, + 'news_read_id', + existing.news_read_id, + update + ); + + } + + } else { + + append_( + CFG.SHEETS.NEWS_READS, + { + + news_read_id: + uuid_( + 'NR' + ), + + news_id: + newsId, + + user_id: + user.user_id, + + read_at: + stamp, + + confirmed_at: + confirm + ? stamp + : '' + + } + ); + + } + + + return { + ok:true + }; + +} + + +/* ============================================================ + HROMADNÉ OZNAČENÍ AKTUALIT JAKO PŘEČTENÉ + + Jeden request pro všechny zobrazené aktuality. +============================================================ */ + +function markNewsListRead( + token, + newsIds +) { + + const user = + requireUser_( + token + ); + + + newsIds = + Array.isArray( + newsIds + ) + ? newsIds + : []; + + + const uniqueIds = + Array.from( + new Set( + newsIds + .map( + function(id) { + + return String( + id || + '' + ).trim(); + + } + ) + .filter( + Boolean + ) + ) + ); + + + if ( + !uniqueIds.length + ) { + + return { + ok:true + }; + + } + + + const existingRows = + rows_( + CFG.SHEETS.NEWS_READS + ) + .filter( + function(row) { + + return ( + String( + row.user_id + ) === + String( + user.user_id + ) + ); + + } + ); + + + const existingByNews = + {}; + + + existingRows.forEach( + function(row) { + + existingByNews[ + String( + row.news_id + ) + ] = + row; + + } + ); + + + const stamp = + now_(); + + + uniqueIds.forEach( + function(newsId) { + + const existing = + existingByNews[ + newsId + ]; + + + if ( + existing + ) { + + if ( + !existing.read_at + ) { + + updateBy_( + CFG.SHEETS.NEWS_READS, + 'news_read_id', + existing.news_read_id, + { + read_at: + stamp + } + ); + + } + + } else { + + append_( + CFG.SHEETS.NEWS_READS, + { + + news_read_id: + uuid_( + 'NR' + ), + + news_id: + newsId, + + user_id: + user.user_id, + + read_at: + stamp, + + confirmed_at: + '' + + } + ); + + } + + } + ); + + + return { + ok:true + }; + +} + + + + +/* ============================================================ + ADMIN / MANAGER – SEZNAM AKTUALIT +============================================================ */ + +function getAdminNews(token) { + + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + const newsRows = + rows_( + CFG.SHEETS.NEWS + ); + + + const readRows = + rows_( + CFG.SHEETS.NEWS_READS + ); + + + const users = + rows_( + CFG.SHEETS.USERS + ) + .filter( + function(user) { + + return newsBool_( + user.active + ); + + } + ); + + + return newsRows + .map( + function(row) { + + const newsId = + String( + row.news_id || + '' + ); + + + const target = + String( + row.target_role || + 'ALL' + ).toUpperCase(); + + + const audience = + users.filter( + function(user) { + + return ( + target === + 'ALL' || + target === + String( + user.role || + '' + ).toUpperCase() + ); + + } + ); + + + const reads = + readRows.filter( + function(read) { + + return ( + String( + read.news_id + ) === + newsId + ); + + } + ); + + + return { + + news_id: + newsId, + + title: + String( + row.title || + '' + ), + + content: + String( + row.content || + '' + ), + + category: + String( + row.category || + '' + ), + + target_role: + String( + row.target_role || + 'ALL' + ), + + published_from: + row.published_from || + '', + + published_to: + row.published_to || + '', + + require_confirmation: + newsBool_( + row.require_confirmation + ), + + active: + newsBool_( + row.active + ), + + created_at: + row.created_at || + '', + + audience_count: + audience.length, + + read_count: + reads.filter( + function(read) { + + return !!read.read_at; + + } + ).length, + + confirmed_count: + reads.filter( + function(read) { + + return !!read.confirmed_at; + + } + ).length + + }; + + } + ) + .sort( + function(a,b) { + + return ( + newsTime_( + b.created_at + ) - + newsTime_( + a.created_at + ) + ); + + } + ); + +} + + + +/* ============================================================ + ADMIN / MANAGER – ULOŽIT AKTUALITU +============================================================ */ + +function saveAdminNews( + token, + payload +) { + + const user = + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + payload = + payload || + {}; + + + const title = + String( + payload.title || + '' + ).trim(); + + + const content = + String( + payload.content || + '' + ).trim(); + + + if ( + !title + ) { + + throw new Error( + 'Vyplň nadpis aktuality.' + ); + + } + + + if ( + !content + ) { + + throw new Error( + 'Vyplň text aktuality.' + ); + + } + + + const allowedTargets = + [ + 'ALL', + 'EMPLOYEE', + 'MANAGER', + 'ACCOUNTANT', + 'ADMIN' + ]; + + + const targetRole = + String( + payload.target_role || + 'ALL' + ).toUpperCase(); + + + if ( + !allowedTargets.includes( + targetRole + ) + ) { + + throw new Error( + 'Neplatná cílová role.' + ); + + } + + + const newsId = + String( + payload.news_id || + '' + ).trim(); + + + const data = + { + + title: + title, + + content: + content, + + category: + String( + payload.category || + 'Provoz' + ), + + company_id: + String( + payload.company_id || + '' + ), + + location_id: + String( + payload.location_id || + '' + ), + + target_role: + targetRole, + + published_from: + payload.published_from || + now_(), + + published_to: + payload.published_to || + '', + + require_confirmation: + !!payload.require_confirmation, + + active: + payload.active !== + false + + }; + + + if ( + newsId + ) { + + const old = + findOne_( + CFG.SHEETS.NEWS, + function(row) { + + return ( + String( + row.news_id + ) === + newsId + ); + + } + ); + + + if ( + !old + ) { + + throw new Error( + 'Aktualita nebyla nalezena.' + ); + + } + + + updateBy_( + CFG.SHEETS.NEWS, + 'news_id', + newsId, + data + ); + + + audit_( + user.user_id, + 'NEWS_UPDATED', + 'NEWS', + newsId, + old, + data + ); + + + return { + ok:true, + news_id:newsId + }; + + } + + + const newId = + uuid_( + 'NEWS' + ); + + + append_( + CFG.SHEETS.NEWS, + { + + news_id: + newId, + + title: + data.title, + + content: + data.content, + + category: + data.category, + + company_id: + data.company_id, + + location_id: + data.location_id, + + target_role: + data.target_role, + + published_from: + data.published_from, + + published_to: + data.published_to, + + require_confirmation: + data.require_confirmation, + + active: + data.active, + + created_by: + user.user_id, + + created_at: + now_() + + } + ); + + + audit_( + user.user_id, + 'NEWS_CREATED', + 'NEWS', + newId, + '', + data + ); + + + return { + ok:true, + news_id:newId + }; + +} + + + +/* ============================================================ + SKRÝT / ZOBRAZIT +============================================================ */ + +function setAdminNewsActive( + token, + newsId, + active +) { + + const user = + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + const news = + findOne_( + CFG.SHEETS.NEWS, + function(row) { + + return ( + String( + row.news_id + ) === + String( + newsId + ) + ); + + } + ); + + + if ( + !news + ) { + + throw new Error( + 'Aktualita nebyla nalezena.' + ); + + } + + + updateBy_( + CFG.SHEETS.NEWS, + 'news_id', + newsId, + { + active: + !!active + } + ); + + + audit_( + user.user_id, + active + ? 'NEWS_SHOWN' + : 'NEWS_HIDDEN', + 'NEWS', + newsId, + news.active, + !!active + ); + + + return { + ok:true + }; + +} + + + +/* ============================================================ + ADMIN – KDO PŘEČETL / POTVRDIL +============================================================ */ + +function getAdminNewsReaders( + token, + newsId +) { + + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + const news = + findOne_( + CFG.SHEETS.NEWS, + function(row) { + + return ( + String( + row.news_id + ) === + String( + newsId + ) + ); + + } + ); + + + if ( + !news + ) { + + throw new Error( + 'Aktualita nebyla nalezena.' + ); + + } + + + const target = + String( + news.target_role || + 'ALL' + ).toUpperCase(); + + + const users = + rows_( + CFG.SHEETS.USERS + ) + .filter( + function(user) { + + if ( + !newsBool_( + user.active + ) + ) { + + return false; + + } + + + return ( + target === + 'ALL' || + target === + String( + user.role || + '' + ).toUpperCase() + ); + + } + ); + + + const employees = + rows_( + CFG.SHEETS.EMPLOYEES + ); + + + const reads = + rows_( + CFG.SHEETS.NEWS_READS + ) + .filter( + function(row) { + + return ( + String( + row.news_id + ) === + String( + newsId + ) + ); + + } + ); + + + return users + .map( + function(user) { + + const read = + reads.find( + function(row) { + + return ( + String( + row.user_id + ) === + String( + user.user_id + ) + ); + + } + ) || + null; + + + const employee = + employees.find( + function(row) { + + return ( + String( + row.employee_id + ) === + String( + user.employee_id || + '' + ) + ); + + } + ) || + null; + + + return { + + user_id: + String( + user.user_id || + '' + ), + + name: + employee + ? ( + String( + employee.first_name || + '' + ) + + ' ' + + String( + employee.last_name || + '' + ) + ).trim() + : String( + user.email || + '' + ), + + email: + String( + user.email || + '' + ), + + role: + String( + user.role || + '' + ), + + read_at: + read + ? read.read_at || + '' + : '', + + confirmed_at: + read + ? read.confirmed_at || + '' + : '' + + }; + + } + ); + +} + + + +/* ============================================================ + HELPERS +============================================================ */ + +function newsBool_(value) { + + return ( + value === + true || + String( + value + ).toUpperCase() === + 'TRUE' || + String( + value + ) === + '1' + ); + +} + + +function newsDate_(value) { + + if ( + !value + ) { + + return null; + + } + + + const date = + value instanceof Date + ? value + : new Date( + value + ); + + + return isNaN( + date.getTime() + ) + ? null + : date; + +} + + +function newsTime_(value) { + + const date = + newsDate_( + value + ); + + + return date + ? date.getTime() + : 0; + +} + + +function ensureNewsSheet_( + sheetName, + headers +) { + + try { + + const existing = + sh_( + sheetName + ); + + + if ( + existing + ) { + + return existing; + + } + + } + + catch(error) { + + /* + * List ještě neexistuje. + */ + + } + + + let spreadsheet = + null; + + + try { + + spreadsheet = + sh_( + CFG.SHEETS.USERS + ).getParent(); + + } + + catch(error) { + + /* + * Fallback níže. + */ + + } + + + if ( + !spreadsheet + ) { + + spreadsheet = + SpreadsheetApp + .getActiveSpreadsheet(); + + } + + + if ( + !spreadsheet + ) { + + throw new Error( + 'Nepodařilo se otevřít databázový spreadsheet.' + ); + + } + + + let sheet = + spreadsheet.getSheetByName( + sheetName + ); + + + if ( + !sheet + ) { + + sheet = + spreadsheet.insertSheet( + sheetName + ); + + + sheet + .getRange( + 1, + 1, + 1, + headers.length + ) + .setValues( + [ + headers + ] + ); + + + sheet.setFrozenRows( + 1 + ); + + } + + + return sheet; + +} diff --git a/gscript/PayrollService.js b/gscript/PayrollService.js new file mode 100644 index 0000000..8edd283 --- /dev/null +++ b/gscript/PayrollService.js @@ -0,0 +1,1554 @@ +/* ============================================================ + EATME PORTÁL – PAYROLL SERVICE + + Role: + - MANAGER / ADMIN: spropitné, bonusy, jiné položky před uzamčením + - ACCOUNTANT / ADMIN: účetní zpracování mzdy +============================================================ */ + + +/* ============================================================ + MANAGER / ADMIN – NAČTENÍ ODMĚN PRO KONKRÉTNÍ MĚSÍC +============================================================ */ + +function getPayrollAdjustments( + token, + employeeId, + period +) { + + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + period = + String( + period || '' + ).trim(); + + + if ( + !/^\d{4}-\d{2}$/.test( + period + ) + ) { + + throw new Error( + 'Neplatné období.' + ); + + } + + + const payrolls = + displayRows_( + CFG.SHEETS.PAYROLL + ); + + + const payroll = + payrolls.find( + function(row) { + + return ( + String( + row.employee_id + ) === + String( + employeeId + ) && + String( + row.period + ) === + period + ); + + } + ); + + + const summary = + getClosureSummary_( + employeeId, + period + ); + + + return { + + payroll_id: + payroll + ? String( + payroll.payroll_id || '' + ) + : '', + + employee_id: + String( + employeeId || '' + ), + + period: + period, + + base_amount: + Number( + summary.earned_estimate || + 0 + ), + + tips_amount: + payroll + ? numberFromSheet_( + payroll.tips_amount + ) + : 0, + + bonus_amount: + payroll + ? numberFromSheet_( + payroll.bonus_amount + ) + : 0, + + other_amount: + payroll + ? numberFromSheet_( + payroll.other_amount + ) + : 0, + + final_amount: + payroll + ? numberFromSheet_( + payroll.final_amount + ) + : Number( + summary.earned_estimate || + 0 + ), + + status: + payroll + ? String( + payroll.status || '' + ) + : 'DRAFT_MANAGER' + + }; + +} + + + +/* ============================================================ + MANAGER / ADMIN – ULOŽENÍ SPROPITNÉHO A ODMĚN +============================================================ */ + +function savePayrollAdjustments( + token, + employeeId, + period, + tipsAmount, + bonusAmount, + otherAmount +) { + + const user = + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + period = + String( + period || '' + ).trim(); + + + if ( + !/^\d{4}-\d{2}$/.test( + period + ) + ) { + + throw new Error( + 'Neplatné období.' + ); + + } + + + tipsAmount = + payrollNumber_( + tipsAmount + ); + + + bonusAmount = + payrollNumber_( + bonusAmount + ); + + + otherAmount = + payrollNumber_( + otherAmount + ); + + + const closures = + displayRows_( + CFG.SHEETS.MONTH_CLOSURES + ); + + + const closure = + closures.find( + function(row) { + + return ( + String( + row.employee_id + ) === + String( + employeeId + ) && + String( + row.period + ) === + period + ); + + } + ); + + + if ( + !closure + ) { + + throw new Error( + 'Měsíční uzávěrka nebyla nalezena.' + ); + + } + + + if ( + String( + closure.status + ) === + 'LOCKED' + ) { + + throw new Error( + 'Uzamčenou mzdu už nelze měnit.' + ); + + } + + + if ( + ![ + 'EMPLOYEE_CONFIRMED', + 'MANAGER_APPROVED' + ].includes( + String( + closure.status + ) + ) + ) { + + throw new Error( + 'Odměny lze zadat až po potvrzení docházky zaměstnancem.' + ); + + } + + + const summary = + getClosureSummary_( + employeeId, + period + ); + + + const baseAmount = + Number( + summary.earned_estimate || + 0 + ); + + + const finalAmount = + baseAmount + + tipsAmount + + bonusAmount + + otherAmount; + + + const payrolls = + displayRows_( + CFG.SHEETS.PAYROLL + ); + + + const existing = + payrolls.find( + function(row) { + + return ( + String( + row.employee_id + ) === + String( + employeeId + ) && + String( + row.period + ) === + period + ); + + } + ); + + + if ( + existing && + [ + 'READY_FOR_ACCOUNTANT', + 'PROCESSING', + 'PROCESSED', + 'PAID' + ].includes( + String( + existing.status + ) + ) + ) { + + throw new Error( + 'Mzdový podklad už byl předán účetní a nelze jej tímto způsobem měnit.' + ); + + } + + + if ( + existing + ) { + + updateBy_( + CFG.SHEETS.PAYROLL, + 'payroll_id', + existing.payroll_id, + { + + approved_minutes: + summary.worked_minutes, + + base_amount: + baseAmount, + + tips_amount: + tipsAmount, + + bonus_amount: + bonusAmount, + + other_amount: + otherAmount, + + final_amount: + finalAmount, + + status: + 'DRAFT_MANAGER', + + updated_at: + now_() + + } + ); + + } else { + + append_( + CFG.SHEETS.PAYROLL, + { + + payroll_id: + uuid_( + 'PAY' + ), + + employee_id: + employeeId, + + period: + period, + + approved_minutes: + summary.worked_minutes, + + base_amount: + baseAmount, + + bonus_amount: + bonusAmount, + + tips_amount: + tipsAmount, + + other_amount: + otherAmount, + + final_amount: + finalAmount, + + status: + 'DRAFT_MANAGER', + + payment_date: + '', + + updated_at: + now_() + + } + ); + + } + + + audit_( + user.user_id, + 'PAYROLL_ADJUSTMENTS_SAVED', + 'PAYROLL', + String( + employeeId + ) + + ':' + + period, + '', + JSON.stringify({ + + tips_amount: + tipsAmount, + + bonus_amount: + bonusAmount, + + other_amount: + otherAmount, + + final_amount: + finalAmount + + }) + ); + + + return { + + ok: + true, + + final_amount: + finalAmount + + }; + +} + + + +/* ============================================================ + ADMIN – UZAMČENÍ A PŘEDÁNÍ ÚČETNÍ + Tato funkce nahrazuje původní lockMonthClosure z frontendu. +============================================================ */ + +function adminLockClosureAndPreparePayroll( + token, + closureId +) { + + const user = + requireUser_( + token, + [ + 'ADMIN' + ] + ); + + + const lock = + LockService + .getScriptLock(); + + + lock.waitLock( + 10000 + ); + + + try { + + const closures = + displayRows_( + CFG.SHEETS.MONTH_CLOSURES + ); + + + const closure = + closures.find( + function(row) { + + return ( + String( + row.closure_id + ) === + String( + closureId + ) + ); + + } + ); + + + if ( + !closure + ) { + + throw new Error( + 'Uzávěrka nebyla nalezena.' + ); + + } + + + if ( + String( + closure.status + ) !== + 'MANAGER_APPROVED' + ) { + + throw new Error( + 'Uzávěrku lze předat účetní až po schválení.' + ); + + } + + + const summary = + getClosureSummary_( + closure.employee_id, + closure.period + ); + + + const payrolls = + displayRows_( + CFG.SHEETS.PAYROLL + ); + + + const existing = + payrolls.find( + function(row) { + + return ( + String( + row.employee_id + ) === + String( + closure.employee_id + ) && + String( + row.period + ) === + String( + closure.period + ) + ); + + } + ); + + + const tipsAmount = + existing + ? numberFromSheet_( + existing.tips_amount + ) + : 0; + + + const bonusAmount = + existing + ? numberFromSheet_( + existing.bonus_amount + ) + : 0; + + + const otherAmount = + existing + ? numberFromSheet_( + existing.other_amount + ) + : 0; + + + const baseAmount = + Number( + summary.earned_estimate || + 0 + ); + + + const finalAmount = + baseAmount + + tipsAmount + + bonusAmount + + otherAmount; + + + if ( + existing + ) { + + updateBy_( + CFG.SHEETS.PAYROLL, + 'payroll_id', + existing.payroll_id, + { + + approved_minutes: + summary.worked_minutes, + + base_amount: + baseAmount, + + tips_amount: + tipsAmount, + + bonus_amount: + bonusAmount, + + other_amount: + otherAmount, + + final_amount: + finalAmount, + + status: + 'READY_FOR_ACCOUNTANT', + + updated_at: + now_() + + } + ); + + } else { + + append_( + CFG.SHEETS.PAYROLL, + { + + payroll_id: + uuid_( + 'PAY' + ), + + employee_id: + closure.employee_id, + + period: + closure.period, + + approved_minutes: + summary.worked_minutes, + + base_amount: + baseAmount, + + bonus_amount: + bonusAmount, + + tips_amount: + tipsAmount, + + other_amount: + otherAmount, + + final_amount: + finalAmount, + + status: + 'READY_FOR_ACCOUNTANT', + + payment_date: + '', + + updated_at: + now_() + + } + ); + + } + + + updateBy_( + CFG.SHEETS.MONTH_CLOSURES, + 'closure_id', + closureId, + { + + locked_at: + now_(), + + status: + 'LOCKED' + + } + ); + + + setMonthShiftStatus_( + closure.employee_id, + closure.period, + 'LOCKED' + ); + + + audit_( + user.user_id, + 'MONTH_LOCKED_AND_SENT_TO_ACCOUNTANT', + 'MONTH_CLOSURE', + closureId, + 'MANAGER_APPROVED', + 'LOCKED' + ); + + + notifyEmployee_( + closure.employee_id, + 'Docházka uzavřena', + 'Docházka za ' + + closure.period + + ' byla uzavřena a předána ke mzdovému zpracování.' + ); + + + return { + + ok: + true, + + final_amount: + finalAmount + + }; + + } + + finally { + + lock.releaseLock(); + + } + +} + + + +/* ============================================================ + ACCOUNTANT / ADMIN – MZDOVÝ DASHBOARD +============================================================ */ + +function getPayrollDashboard( + token, + period +) { + + const user = + requireUser_( + token, + [ + 'ADMIN', + 'ACCOUNTANT' + ] + ); + + + period = + String( + period || '' + ).trim(); + + + const employees = + displayRows_( + CFG.SHEETS.EMPLOYEES + ); + + + const employeeMap = + {}; + + + employees.forEach( + function(employee) { + + employeeMap[ + String( + employee.employee_id + ) + ] = + employee; + + } + ); + + + let payrolls = + displayRows_( + CFG.SHEETS.PAYROLL + ); + + + if ( + period + ) { + + payrolls = + payrolls.filter( + function(row) { + + return ( + String( + row.period + ) === + period + ); + + } + ); + + } + + + payrolls = + payrolls.filter( + function(row) { + + return [ + 'READY_FOR_ACCOUNTANT', + 'PROCESSING', + 'PROCESSED', + 'PAID' + ].includes( + String( + row.status + ) + ); + + } + ); + + + const rows = + payrolls + .map( + function(payroll) { + + const employee = + employeeMap[ + String( + payroll.employee_id + ) + ]; + + + const baseAmount = + numberFromSheet_( + payroll.base_amount + ); + + + const tipsAmount = + numberFromSheet_( + payroll.tips_amount + ); + + + const bonusAmount = + numberFromSheet_( + payroll.bonus_amount + ); + + + const otherAmount = + numberFromSheet_( + payroll.other_amount + ); + + + const finalAmount = + baseAmount + + tipsAmount + + bonusAmount + + otherAmount; + + + return { + + payroll_id: + String( + payroll.payroll_id || + '' + ), + + employee_id: + String( + payroll.employee_id || + '' + ), + + employee_name: + employee + ? ( + String( + employee.first_name || + '' + ) + + ' ' + + String( + employee.last_name || + '' + ) + ).trim() + : String( + payroll.employee_id || + '' + ), + + employment_type: + employee + ? String( + employee.employment_type || + '' + ) + : '', + + period: + String( + payroll.period || + '' + ), + + approved_minutes: + numberFromSheet_( + payroll.approved_minutes + ), + + base_amount: + baseAmount, + + tips_amount: + tipsAmount, + + bonus_amount: + bonusAmount, + + other_amount: + otherAmount, + + final_amount: + finalAmount, + + status: + String( + payroll.status || + '' + ), + + payment_date: + String( + payroll.payment_date || + '' + ) + + }; + + } + ) + .sort( + function(a,b) { + + if ( + a.period !== + b.period + ) { + + return b.period.localeCompare( + a.period + ); + + } + + + return a.employee_name.localeCompare( + b.employee_name, + 'cs' + ); + + } + ); + + + const stats = { + + count: + rows.length, + + total_base: + 0, + + total_tips: + 0, + + total_bonus: + 0, + + total_other: + 0, + + total_final: + 0, + + ready: + 0, + + processing: + 0, + + processed: + 0, + + paid: + 0 + + }; + + + rows.forEach( + function(row) { + + stats.total_base += + row.base_amount; + + + stats.total_tips += + row.tips_amount; + + + stats.total_bonus += + row.bonus_amount; + + + stats.total_other += + row.other_amount; + + + stats.total_final += + row.final_amount; + + + if ( + row.status === + 'READY_FOR_ACCOUNTANT' + ) { + + stats.ready++; + + } + + + if ( + row.status === + 'PROCESSING' + ) { + + stats.processing++; + + } + + + if ( + row.status === + 'PROCESSED' + ) { + + stats.processed++; + + } + + + if ( + row.status === + 'PAID' + ) { + + stats.paid++; + + } + + } + ); + + + return { + + role: + String( + user.role || + '' + ), + + period: + period, + + stats: + stats, + + rows: + rows + + }; + +} + + + +/* ============================================================ + ACCOUNTANT / ADMIN – ZMĚNA STAVU +============================================================ */ + +function setPayrollStatus( + token, + payrollId, + newStatus +) { + + const user = + requireUser_( + token, + [ + 'ADMIN', + 'ACCOUNTANT' + ] + ); + + + newStatus = + String( + newStatus || '' + ).trim(); + + + const allowed = + [ + 'READY_FOR_ACCOUNTANT', + 'PROCESSING', + 'PROCESSED', + 'PAID' + ]; + + + if ( + !allowed.includes( + newStatus + ) + ) { + + throw new Error( + 'Neplatný stav mzdy.' + ); + + } + + + const payrolls = + displayRows_( + CFG.SHEETS.PAYROLL + ); + + + const payroll = + payrolls.find( + function(row) { + + return ( + String( + row.payroll_id + ) === + String( + payrollId + ) + ); + + } + ); + + + if ( + !payroll + ) { + + throw new Error( + 'Mzdový podklad nebyl nalezen.' + ); + + } + + + const currentStatus = + String( + payroll.status || + '' + ); + + + const order = { + + READY_FOR_ACCOUNTANT: + 1, + + PROCESSING: + 2, + + PROCESSED: + 3, + + PAID: + 4 + + }; + + + if ( + order[ + newStatus + ] < + order[ + currentStatus + ] + ) { + + throw new Error( + 'Stav mzdy nelze vracet zpět.' + ); + + } + + + const update = { + + status: + newStatus, + + updated_at: + now_() + + }; + + + if ( + newStatus === + 'PAID' + ) { + + update.payment_date = + Utilities.formatDate( + new Date(), + CFG.TZ, + 'yyyy-MM-dd' + ); + + } + + + updateBy_( + CFG.SHEETS.PAYROLL, + 'payroll_id', + payrollId, + update + ); + + + audit_( + user.user_id, + 'PAYROLL_STATUS_CHANGED', + 'PAYROLL', + payrollId, + currentStatus, + newStatus + ); + + + if ( + newStatus === + 'PROCESSED' + ) { + + notifyEmployee_( + payroll.employee_id, + 'Mzda byla zpracována', + 'Mzda za ' + + payroll.period + + ' byla zpracována účetní.' + ); + + } + + + if ( + newStatus === + 'PAID' + ) { + + notifyEmployee_( + payroll.employee_id, + 'Mzda byla označena jako vyplacená', + 'Mzda za ' + + payroll.period + + ' byla označena jako vyplacená.' + ); + + } + + + return { + + ok: + true + + }; + +} + + + +/* ============================================================ + ACCOUNTANT / ADMIN – CSV EXPORT +============================================================ */ + +function exportPayrollCsv( + token, + period +) { + + requireUser_( + token, + [ + 'ADMIN', + 'ACCOUNTANT' + ] + ); + + + const data = + getPayrollDashboard( + token, + period + ); + + + const rows = + [ + [ + 'Zaměstnanec', + 'Typ vztahu', + 'Období', + 'Schválené hodiny', + 'Základ', + 'Spropitné', + 'Bonusy', + 'Ostatní', + 'Celkem', + 'Stav', + 'Datum výplaty' + ] + ]; + + + data.rows.forEach( + function(row) { + + rows.push( + [ + row.employee_name, + row.employment_type, + row.period, + ( + row.approved_minutes / + 60 + ).toFixed( + 2 + ), + row.base_amount, + row.tips_amount, + row.bonus_amount, + row.other_amount, + row.final_amount, + row.status, + row.payment_date + ] + ); + + } + ); + + + const csv = + rows + .map( + function(row) { + + return row + .map( + function(value) { + + const text = + String( + value == null + ? '' + : value + ); + + + return ( + '"' + + text.replace( + /"/g, + '""' + ) + + '"' + ); + + } + ) + .join( + ';' + ); + + } + ) + .join( + '\r\n' + ); + + + return { + + filename: + 'EatMe_Portal_mzdy_' + + ( + period || + 'vse' + ) + + '.csv', + + content: + '\uFEFF' + + csv + + }; + +} + + + +/* ============================================================ + POMOCNÁ FUNKCE – ČÍSELNÁ HODNOTA +============================================================ */ + +function payrollNumber_( + value +) { + + const number = + Number( + String( + value == null + ? 0 + : value + ) + .replace( + ',', + '.' + ) + ); + + + if ( + !isFinite( + number + ) + ) { + + throw new Error( + 'Částka musí být číslo.' + ); + + } + + + return Math.round( + number * + 100 + ) / + 100; + +} + + + +/* ============================================================ + DIAGNOSTIKA +============================================================ */ + +function testPayrollDatabase() { + + Logger.log( + 'PAYROLL: ' + + JSON.stringify( + displayRows_( + CFG.SHEETS.PAYROLL + ) + ) + ); + + + Logger.log( + 'PayrollService OK' + ); + +} \ No newline at end of file diff --git a/gscript/Security.js b/gscript/Security.js new file mode 100644 index 0000000..3c2f0c5 --- /dev/null +++ b/gscript/Security.js @@ -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_() + + } + ); + +} \ No newline at end of file diff --git a/gscript/Setup.js b/gscript/Setup.js new file mode 100644 index 0000000..a18487c --- /dev/null +++ b/gscript/Setup.js @@ -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' + ); +} \ No newline at end of file diff --git a/gscript/ShiftPlanningService.js b/gscript/ShiftPlanningService.js new file mode 100644 index 0000000..56d3ff7 --- /dev/null +++ b/gscript/ShiftPlanningService.js @@ -0,0 +1,2866 @@ +/* ============================================================ + MESTAFF – SHIFT PLANNING SERVICE + Plánované směny jsou oddělené od skutečné docházky. + + Výchozí pravidla: + NE–ČT 18:00–23:00, kapacita 1 + PÁ–SO 18:00–02:00, kapacita 1 + 20:00–00:00, kapacita 1 + + Automatické vytvoření dalšího měsíce: + každý 10. den předchozího měsíce kolem 17:00 +============================================================ */ + +const SHIFT_PLAN_SHEETS = { + SLOTS: 'SHIFT_SLOTS', + SIGNUPS: 'SHIFT_SIGNUPS' +}; + + +/* ============================================================ + JEDNORÁZOVÝ SETUP +============================================================ */ + +function setupShiftPlanning() { + + ensureShiftPlanningSheet_( + SHIFT_PLAN_SHEETS.SLOTS, + [ + 'slot_id', + 'date', + 'start_time', + 'end_time', + 'capacity', + 'location_id', + 'status', + 'note', + 'generated', + 'created_at', + 'updated_at' + ] + ); + + ensureShiftPlanningSheet_( + SHIFT_PLAN_SHEETS.SIGNUPS, + [ + 'signup_id', + 'slot_id', + 'employee_id', + 'status', + 'created_at', + 'cancelled_at', + 'cancelled_by' + ] + ); + + createShiftPlanningTrigger_(); + + Logger.log('Shift planning setup OK'); + +} + + +/* ============================================================ + AUTOMATICKÝ TRIGGER + + Apps Script měsíční trigger neumí garantovat přesně 17:00:00. + atHour(17) znamená spuštění v hodinovém okně 17:00–18:00. +============================================================ */ + +function createShiftPlanningTrigger_() { + + const handler = + 'generateNextMonthShiftSlots'; + + + ScriptApp + .getProjectTriggers() + .filter( + function(trigger) { + + return ( + trigger.getHandlerFunction() === + handler + ); + + } + ) + .forEach( + function(trigger) { + + ScriptApp.deleteTrigger( + trigger + ); + + } + ); + + + ScriptApp + .newTrigger( + handler + ) + .timeBased() + .onMonthDay( + 10 + ) + .atHour( + 17 + ) + .create(); + +} + + +/* ============================================================ + GENERÁTOR DALŠÍHO MĚSÍCE +============================================================ */ + +function generateNextMonthShiftSlots() { + + const now = + new Date(); + + + let year = + now.getFullYear(); + + + let month = + now.getMonth() + 1; + + + if ( + month > 11 + ) { + + month = + 0; + + year++; + + } + + + return generateShiftSlotsForMonth_( + year, + month + ); + +} + + +/* ============================================================ + RUČNÝ GENERÁTOR PRO ADMINA + + monthNumber = 1–12 +============================================================ */ + +function adminGenerateShiftMonth( + token, + year, + monthNumber +) { + + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + year = + Number( + year + ); + + + monthNumber = + Number( + monthNumber + ); + + + if ( + !year || + monthNumber < 1 || + monthNumber > 12 + ) { + + throw new Error( + 'Neplatný měsíc.' + ); + + } + + + return generateShiftSlotsForMonth_( + year, + monthNumber - 1 + ); + +} + + +/* ============================================================ + VLASTNÍ GENERÁTOR + + monthIndex = JS měsíc 0–11 +============================================================ */ + +function generateShiftSlotsForMonth_( + year, + monthIndex +) { + + const slots = + displayRows_( + SHIFT_PLAN_SHEETS.SLOTS + ); + + + const existingKeys = + {}; + + + slots.forEach( + function(slot) { + + existingKeys[ + shiftSlotKey_( + slot.date, + slot.start_time, + slot.end_time, + slot.location_id + ) + ] = + true; + + } + ); + + + const defaultLocation = + getDefaultPlanningLocation_(); + + + const days = + new Date( + year, + monthIndex + 1, + 0 + ).getDate(); + + + let created = + 0; + + + for ( + let day = 1; + day <= days; + day++ + ) { + + const date = + new Date( + year, + monthIndex, + day + ); + + + const dow = + date.getDay(); + + + const dateText = + Utilities.formatDate( + date, + CFG.TZ, + 'yyyy-MM-dd' + ); + + + let templates = + []; + + + /* + * neděle = 0 + * pondělí = 1 + * ... + * čtvrtek = 4 + */ + + if ( + dow === 0 || + ( + dow >= 1 && + dow <= 4 + ) + ) { + + templates = + [ + { + start:'18:00', + end:'23:00', + capacity:1 + } + ]; + + } + + + /* + * pátek + sobota + */ + + if ( + dow === 5 || + dow === 6 + ) { + + templates = + [ + { + start:'18:00', + end:'02:00', + capacity:1 + }, + { + start:'20:00', + end:'00:00', + capacity:1 + } + ]; + + } + + + templates.forEach( + function(template) { + + const key = + shiftSlotKey_( + dateText, + template.start, + template.end, + defaultLocation + ); + + + /* + * idempotence: + * při opakovaném spuštění nevznikne duplicita + */ + + if ( + existingKeys[ + key + ] + ) { + + return; + + } + + + append_( + SHIFT_PLAN_SHEETS.SLOTS, + { + + slot_id: + uuid_( + 'SLOT' + ), + + date: + dateText, + + start_time: + template.start, + + end_time: + template.end, + + capacity: + template.capacity, + + location_id: + defaultLocation, + + status: + 'OPEN_FOR_SIGNUP', + + note: + '', + + generated: + true, + + created_at: + now_(), + + updated_at: + now_() + + } + ); + + + existingKeys[ + key + ] = + true; + + + created++; + + } + ); + + } + + + return { + + ok:true, + + year: + year, + + month: + monthIndex + 1, + + created: + created + + }; + +} + + +/* ============================================================ + ADMIN – MĚSÍČNÍ PLÁN +============================================================ */ + +function getAdminShiftPlan( + token, + period +) { + + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + period = + normalizePlanningPeriod_( + period + ); + + + const slots = + displayRows_( + SHIFT_PLAN_SHEETS.SLOTS + ); + + + const signups = + displayRows_( + SHIFT_PLAN_SHEETS.SIGNUPS + ); + + + const employees = + displayRows_( + CFG.SHEETS.EMPLOYEES + ); + + + const employeeMap = + {}; + + + employees.forEach( + function(employee) { + + employeeMap[ + String( + employee.employee_id + ) + ] = + employee; + + } + ); + + + const signupsBySlot = + buildApprovedSignupsBySlot_( + signups + ); + + + return slots + .filter( + function(slot) { + + return ( + String( + slot.date || + '' + ).substring( + 0, + 7 + ) === + period + ); + + } + ) + .map( + function(slot) { + + const slotSignups = + signupsBySlot[ + String( + slot.slot_id + ) + ] || + []; + + + return { + + slot_id: + String( + slot.slot_id || + '' + ), + + date: + String( + slot.date || + '' + ), + + start_time: + String( + slot.start_time || + '' + ), + + end_time: + String( + slot.end_time || + '' + ), + + capacity: + planningNumber_( + slot.capacity + ), + + location_id: + String( + slot.location_id || + '' + ), + + status: + String( + slot.status || + '' + ), + + note: + String( + slot.note || + '' + ), + + occupied: + slotSignups.length, + + employees: + slotSignups.map( + function(signup) { + + const employee = + employeeMap[ + String( + signup.employee_id + ) + ]; + + + return { + + employee_id: + String( + signup.employee_id || + '' + ), + + name: + employee + ? ( + String( + employee.first_name || + '' + ) + + ' ' + + String( + employee.last_name || + '' + ) + ).trim() + : String( + signup.employee_id || + '' + ) + + }; + + } + ) + + }; + + } + ) + .sort( + shiftSlotSort_ + ); + +} + + +/* ============================================================ + ADMIN – ÚPRAVA KONKRÉTNÍ SMĚNY +============================================================ */ + +function updateShiftSlot( + token, + slotId, + data +) { + + const user = + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + const slots = + displayRows_( + SHIFT_PLAN_SHEETS.SLOTS + ); + + + const slot = + slots.find( + function(row) { + + return ( + String( + row.slot_id + ) === + String( + slotId + ) + ); + + } + ); + + + if ( + !slot + ) { + + throw new Error( + 'Směna nebyla nalezena.' + ); + + } + + + const capacity = + Number( + data.capacity + ); + + + if ( + !Number.isInteger( + capacity + ) || + capacity < 0 + ) { + + throw new Error( + 'Kapacita musí být celé číslo 0 nebo vyšší.' + ); + + } + + + const signups = + getApprovedSlotSignups_( + slotId + ); + + + if ( + capacity < + signups.length + ) { + + throw new Error( + 'Kapacitu nelze snížit pod počet již přihlášených zaměstnanců.' + ); + + } + + + const status = + capacity === 0 + ? 'CLOSED' + : ( + signups.length >= + capacity + ? 'FULL' + : 'OPEN_FOR_SIGNUP' + ); + + + updateBy_( + SHIFT_PLAN_SHEETS.SLOTS, + 'slot_id', + slotId, + { + + date: + String( + data.date || + slot.date + ), + + start_time: + normalizePlanningTime_( + data.start_time + ), + + end_time: + normalizePlanningTime_( + data.end_time + ), + + capacity: + capacity, + + status: + status, + + note: + String( + data.note || + '' + ), + + updated_at: + now_() + + } + ); + + + audit_( + user.user_id, + 'SHIFT_SLOT_UPDATED', + 'SHIFT_SLOT', + slotId, + JSON.stringify( + slot + ), + JSON.stringify( + data + ) + ); + + + return { + + ok:true + + }; + +} + + +/* ============================================================ + ADMIN – PŘIDÁNÍ MIMOŘÁDNÉ SMĚNY +============================================================ */ + +function createShiftSlot( + token, + data +) { + + const user = + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + const date = + String( + data.date || + '' + ); + + + if ( + !/^\d{4}-\d{2}-\d{2}$/.test( + date + ) + ) { + + throw new Error( + 'Neplatné datum.' + ); + + } + + + const startTime = + normalizePlanningTime_( + data.start_time + ); + + + const endTime = + normalizePlanningTime_( + data.end_time + ); + + + const capacity = + Number( + data.capacity + ); + + + if ( + !Number.isInteger( + capacity + ) || + capacity < 1 + ) { + + throw new Error( + 'Kapacita musí být alespoň 1.' + ); + + } + + + const locationId = + String( + data.location_id || + getDefaultPlanningLocation_() + ); + + + const existing = + displayRows_( + SHIFT_PLAN_SHEETS.SLOTS + ) + .find( + function(slot) { + + return ( + shiftSlotKey_( + slot.date, + slot.start_time, + slot.end_time, + slot.location_id + ) === + shiftSlotKey_( + date, + startTime, + endTime, + locationId + ) + ); + + } + ); + + + if ( + existing + ) { + + throw new Error( + 'Stejná směna už existuje.' + ); + + } + + + const slotId = + uuid_( + 'SLOT' + ); + + + append_( + SHIFT_PLAN_SHEETS.SLOTS, + { + + slot_id: + slotId, + + date: + date, + + start_time: + startTime, + + end_time: + endTime, + + capacity: + capacity, + + location_id: + locationId, + + status: + 'OPEN_FOR_SIGNUP', + + note: + String( + data.note || + '' + ), + + generated: + false, + + created_at: + now_(), + + updated_at: + now_() + + } + ); + + + audit_( + user.user_id, + 'SHIFT_SLOT_CREATED', + 'SHIFT_SLOT', + slotId, + '', + JSON.stringify( + data + ) + ); + + + return { + + ok:true, + + slot_id: + slotId + + }; + +} + + +/* ============================================================ + ADMIN – RUČNÍ PŘIDÁNÍ ČLOVĚKA NA SMĚNU +============================================================ */ + +function adminAssignEmployeeToSlot( + token, + slotId, + employeeId +) { + + const user = + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + return signupEmployeeToSlot_( + slotId, + employeeId, + user.user_id + ); + +} + + +/* ============================================================ + ADMIN – ODEBRÁNÍ ČLOVĚKA ZE SMĚNY +============================================================ */ + +function adminRemoveEmployeeFromSlot( + token, + slotId, + employeeId +) { + + const user = + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + const signups = + displayRows_( + SHIFT_PLAN_SHEETS.SIGNUPS + ); + + + const signup = + signups.find( + function(row) { + + return ( + String( + row.slot_id + ) === + String( + slotId + ) && + String( + row.employee_id + ) === + String( + employeeId + ) && + String( + row.status + ) === + 'APPROVED' + ); + + } + ); + + + if ( + !signup + ) { + + throw new Error( + 'Přihlášení na směnu nebylo nalezeno.' + ); + + } + + + updateBy_( + SHIFT_PLAN_SHEETS.SIGNUPS, + 'signup_id', + signup.signup_id, + { + + status: + 'CANCELLED', + + cancelled_at: + now_(), + + cancelled_by: + user.user_id + + } + ); + + + refreshSlotStatus_( + slotId + ); + + + audit_( + user.user_id, + 'SHIFT_SIGNUP_REMOVED', + 'SHIFT_SLOT', + slotId, + employeeId, + '' + ); + + + return { + + ok:true + + }; + +} + + +/* ============================================================ + RYCHLÝ BUNDLE PRO ZAMĚSTNANECKÉ SMĚNY + + Jeden request + jedno čtení SHIFT_SLOTS + jedno čtení + SHIFT_SIGNUPS. +============================================================ */ + +function getEmployeeShiftPlanningBundle( + token, + period +) { + + const user = + requireUser_( + token, + [ + 'EMPLOYEE', + 'MANAGER', + 'ADMIN' + ] + ); + + + if ( + !user.employee_id + ) { + + throw new Error( + 'Účet není propojen se zaměstnancem.' + ); + + } + + + period = + normalizePlanningPeriod_( + period + ); + + + const slots = + displayRows_( + SHIFT_PLAN_SHEETS.SLOTS + ); + + + const signups = + displayRows_( + SHIFT_PLAN_SHEETS.SIGNUPS + ); + + + return { + + available: + buildAvailableShiftSlotsFromRows_( + user.employee_id, + period, + slots, + signups + ), + + mine: + buildMyPlannedShiftsFromRows_( + user.employee_id, + period, + slots, + signups + ) + + }; + +} + + + +/* ============================================================ + INDEX PŘIHLÁŠEK PODLE SLOTU +============================================================ */ + +function buildApprovedSignupsBySlot_( + signups +) { + + const result = + {}; + + + signups.forEach( + function(signup) { + + if ( + String( + signup.status + ) !== + 'APPROVED' + ) { + + return; + + } + + + const slotId = + String( + signup.slot_id || + '' + ); + + + if ( + !slotId + ) { + + return; + + } + + + if ( + !result[ + slotId + ] + ) { + + result[ + slotId + ] = + []; + + } + + + result[ + slotId + ].push( + signup + ); + + } + ); + + + return result; + +} + + + +/* ============================================================ + VOLNÉ SMĚNY Z JIŽ NAČTENÝCH ŘÁDKŮ +============================================================ */ + +function buildAvailableShiftSlotsFromRows_( + employeeId, + period, + slots, + signups +) { + + const employeeKey = + String( + employeeId + ); + + + const signupsBySlot = + buildApprovedSignupsBySlot_( + signups + ); + + + const mySlotMap = + {}; + + + signups.forEach( + function(signup) { + + if ( + String( + signup.employee_id + ) === + employeeKey && + String( + signup.status + ) === + 'APPROVED' + ) { + + mySlotMap[ + String( + signup.slot_id + ) + ] = + true; + + } + + } + ); + + + const result = + []; + + + slots.forEach( + function(slot) { + + if ( + String( + slot.date || + '' + ).substring( + 0, + 7 + ) !== + period + ) { + + return; + + } + + + if ( + String( + slot.status + ) !== + 'OPEN_FOR_SIGNUP' + ) { + + return; + + } + + + const slotId = + String( + slot.slot_id || + '' + ); + + + if ( + mySlotMap[ + slotId + ] + ) { + + return; + + } + + + const occupied = + ( + signupsBySlot[ + slotId + ] || + [] + ).length; + + + const capacity = + planningNumber_( + slot.capacity + ); + + + const freePlaces = + Math.max( + 0, + capacity - + occupied + ); + + + if ( + freePlaces <= + 0 + ) { + + return; + + } + + + result.push( + { + + slot_id: + slotId, + + date: + String( + slot.date || + '' + ), + + start_time: + String( + slot.start_time || + '' + ), + + end_time: + String( + slot.end_time || + '' + ), + + capacity: + capacity, + + occupied: + occupied, + + free_places: + freePlaces, + + already_signed: + false, + + note: + String( + slot.note || + '' + ) + + } + ); + + } + ); + + + return result.sort( + shiftSlotSort_ + ); + +} + + + +/* ============================================================ + MOJE SMĚNY Z JIŽ NAČTENÝCH ŘÁDKŮ +============================================================ */ + +function buildMyPlannedShiftsFromRows_( + employeeId, + period, + slots, + signups +) { + + const slotMap = + {}; + + + slots.forEach( + function(slot) { + + slotMap[ + String( + slot.slot_id + ) + ] = + slot; + + } + ); + + + const result = + []; + + + signups.forEach( + function(signup) { + + if ( + String( + signup.employee_id + ) !== + String( + employeeId + ) || + String( + signup.status + ) !== + 'APPROVED' + ) { + + return; + + } + + + const slot = + slotMap[ + String( + signup.slot_id + ) + ]; + + + if ( + !slot + ) { + + return; + + } + + + if ( + String( + slot.date || + '' + ).substring( + 0, + 7 + ) !== + period + ) { + + return; + + } + + + result.push( + { + + signup_id: + String( + signup.signup_id || + '' + ), + + slot_id: + String( + slot.slot_id || + '' + ), + + date: + String( + slot.date || + '' + ), + + start_time: + String( + slot.start_time || + '' + ), + + end_time: + String( + slot.end_time || + '' + ), + + note: + String( + slot.note || + '' + ) + + } + ); + + } + ); + + + return result.sort( + shiftSlotSort_ + ); + +} + + +/* ============================================================ + ZAMĚSTNANEC – VOLNÉ SMĚNY +============================================================ */ + +function getAvailableShiftSlots( + token, + period +) { + + const user = + requireUser_( + token, + [ + 'EMPLOYEE', + 'MANAGER', + 'ADMIN' + ] + ); + + + if ( + !user.employee_id + ) { + + throw new Error( + 'Účet není propojen se zaměstnancem.' + ); + + } + + + period = + normalizePlanningPeriod_( + period + ); + + + const slots = + displayRows_( + SHIFT_PLAN_SHEETS.SLOTS + ); + + + const signups = + displayRows_( + SHIFT_PLAN_SHEETS.SIGNUPS + ); + + + return buildAvailableShiftSlotsFromRows_( + user.employee_id, + period, + slots, + signups + ); + +} + + +/* ============================================================ + ZAMĚSTNANEC – PŘIHLÁŠENÍ NA SMĚNU +============================================================ */ + +function signupForShiftSlot( + token, + slotId +) { + + const user = + requireUser_( + token, + [ + 'EMPLOYEE', + 'MANAGER', + 'ADMIN' + ] + ); + + + if ( + !user.employee_id + ) { + + throw new Error( + 'Účet není propojen se zaměstnancem.' + ); + + } + + + return signupEmployeeToSlot_( + slotId, + user.employee_id, + user.user_id + ); + +} + + +/* ============================================================ + ZAMĚSTNANEC – MOJE SMĚNY +============================================================ */ + +function getMyPlannedShifts( + token, + period +) { + + const user = + requireUser_( + token, + [ + 'EMPLOYEE', + 'MANAGER', + 'ADMIN' + ] + ); + + + if ( + !user.employee_id + ) { + + throw new Error( + 'Účet není propojen se zaměstnancem.' + ); + + } + + + period = + normalizePlanningPeriod_( + period + ); + + + const slots = + displayRows_( + SHIFT_PLAN_SHEETS.SLOTS + ); + + + const signups = + displayRows_( + SHIFT_PLAN_SHEETS.SIGNUPS + ); + + + return buildMyPlannedShiftsFromRows_( + user.employee_id, + period, + slots, + signups + ); + +} + + +/* ============================================================ + ZAMĚSTNANEC – ZRUŠENÍ PŘIHLÁŠKY +============================================================ */ + +function cancelMyShiftSignup( + token, + slotId +) { + + const user = + requireUser_( + token, + [ + 'EMPLOYEE', + 'MANAGER', + 'ADMIN' + ] + ); + + + if ( + !user.employee_id + ) { + + throw new Error( + 'Účet není propojen se zaměstnancem.' + ); + + } + + + const signup = + displayRows_( + SHIFT_PLAN_SHEETS.SIGNUPS + ) + .find( + function(row) { + + return ( + String( + row.slot_id + ) === + String( + slotId + ) && + String( + row.employee_id + ) === + String( + user.employee_id + ) && + String( + row.status + ) === + 'APPROVED' + ); + + } + ); + + + if ( + !signup + ) { + + throw new Error( + 'Na této směně nejsi přihlášen.' + ); + + } + + + updateBy_( + SHIFT_PLAN_SHEETS.SIGNUPS, + 'signup_id', + signup.signup_id, + { + + status: + 'CANCELLED', + + cancelled_at: + now_(), + + cancelled_by: + user.user_id + + } + ); + + + refreshSlotStatus_( + slotId + ); + + + return { + + ok:true + + }; + +} + + +/* ============================================================ + INTERNÍ PŘIHLÁŠENÍ +============================================================ */ + +function signupEmployeeToSlot_( + slotId, + employeeId, + actorUserId +) { + + const lock = + LockService + .getScriptLock(); + + + lock.waitLock( + 10000 + ); + + + try { + + const slots = + displayRows_( + SHIFT_PLAN_SHEETS.SLOTS + ); + + + const slot = + slots.find( + function(row) { + + return ( + String( + row.slot_id + ) === + String( + slotId + ) + ); + + } + ); + + + if ( + !slot + ) { + + throw new Error( + 'Směna nebyla nalezena.' + ); + + } + + + if ( + String( + slot.status + ) === + 'CLOSED' + ) { + + throw new Error( + 'Směna je uzavřená.' + ); + + } + + + const existing = + displayRows_( + SHIFT_PLAN_SHEETS.SIGNUPS + ) + .find( + function(row) { + + return ( + String( + row.slot_id + ) === + String( + slotId + ) && + String( + row.employee_id + ) === + String( + employeeId + ) && + String( + row.status + ) === + 'APPROVED' + ); + + } + ); + + + if ( + existing + ) { + + throw new Error( + 'Na této směně už jsi přihlášen.' + ); + + } + + + assertNoPlanningCollision_( + employeeId, + slot + ); + + + const approved = + getApprovedSlotSignups_( + slotId + ); + + + const capacity = + planningNumber_( + slot.capacity + ); + + + if ( + approved.length >= + capacity + ) { + + throw new Error( + 'Směna je už obsazená.' + ); + + } + + + append_( + SHIFT_PLAN_SHEETS.SIGNUPS, + { + + signup_id: + uuid_( + 'SIGN' + ), + + slot_id: + slotId, + + employee_id: + employeeId, + + status: + 'APPROVED', + + created_at: + now_(), + + cancelled_at: + '', + + cancelled_by: + '' + + } + ); + + + refreshSlotStatus_( + slotId + ); + + + audit_( + actorUserId, + 'SHIFT_SIGNUP_CREATED', + 'SHIFT_SLOT', + slotId, + '', + employeeId + ); + + + return { + + ok:true + + }; + + } + + finally { + + lock.releaseLock(); + + } + +} + + +/* ============================================================ + KOLIZE SMĚN +============================================================ */ + +function assertNoPlanningCollision_( + employeeId, + targetSlot +) { + + const myShifts = + getApprovedPlannedSlotsForEmployee_( + employeeId + ); + + + const targetStart = + planningSlotStart_( + targetSlot + ); + + + const targetEnd = + planningSlotEnd_( + targetSlot + ); + + + myShifts.forEach( + function(slot) { + + const start = + planningSlotStart_( + slot + ); + + + const end = + planningSlotEnd_( + slot + ); + + + if ( + targetStart < + end && + targetEnd > + start + ) { + + throw new Error( + 'Tato směna se překrývá s jinou směnou, na kterou už jsi přihlášen.' + ); + + } + + } + ); + +} + + +/* ============================================================ + POMOCNÉ FUNKCE +============================================================ */ + +function getApprovedSlotSignups_( + slotId +) { + + return displayRows_( + SHIFT_PLAN_SHEETS.SIGNUPS + ) + .filter( + function(signup) { + + return ( + String( + signup.slot_id + ) === + String( + slotId + ) && + String( + signup.status + ) === + 'APPROVED' + ); + + } + ); + +} + + +function getApprovedPlannedSlotsForEmployee_( + employeeId +) { + + const slots = + displayRows_( + SHIFT_PLAN_SHEETS.SLOTS + ); + + + const slotMap = + {}; + + + slots.forEach( + function(slot) { + + slotMap[ + String( + slot.slot_id + ) + ] = + slot; + + } + ); + + + return displayRows_( + SHIFT_PLAN_SHEETS.SIGNUPS + ) + .filter( + function(signup) { + + return ( + String( + signup.employee_id + ) === + String( + employeeId + ) && + String( + signup.status + ) === + 'APPROVED' + ); + + } + ) + .map( + function(signup) { + + return slotMap[ + String( + signup.slot_id + ) + ]; + + } + ) + .filter( + Boolean + ); + +} + + +function refreshSlotStatus_( + slotId +) { + + const slots = + displayRows_( + SHIFT_PLAN_SHEETS.SLOTS + ); + + + const slot = + slots.find( + function(row) { + + return ( + String( + row.slot_id + ) === + String( + slotId + ) + ); + + } + ); + + + if ( + !slot + ) { + + return; + + } + + + const capacity = + planningNumber_( + slot.capacity + ); + + + const occupied = + getApprovedSlotSignups_( + slotId + ).length; + + + let status = + 'OPEN_FOR_SIGNUP'; + + + if ( + capacity <= 0 + ) { + + status = + 'CLOSED'; + + } else if ( + occupied >= + capacity + ) { + + status = + 'FULL'; + + } + + + updateBy_( + SHIFT_PLAN_SHEETS.SLOTS, + 'slot_id', + slotId, + { + + status: + status, + + updated_at: + now_() + + } + ); + +} + + +function planningSlotStart_( + slot +) { + + const parts = + String( + slot.date + ).split( + '-' + ); + + + const time = + String( + slot.start_time + ).split( + ':' + ); + + + return new Date( + Number( + parts[0] + ), + Number( + parts[1] + ) - 1, + Number( + parts[2] + ), + Number( + time[0] + ), + Number( + time[1] || + 0 + ) + ); + +} + + +function planningSlotEnd_( + slot +) { + + const start = + planningSlotStart_( + slot + ); + + + const time = + String( + slot.end_time + ).split( + ':' + ); + + + const end = + new Date( + start.getFullYear(), + start.getMonth(), + start.getDate(), + Number( + time[0] + ), + Number( + time[1] || + 0 + ) + ); + + + if ( + end <= + start + ) { + + end.setDate( + end.getDate() + + 1 + ); + + } + + + return end; + +} + + +function shiftSlotKey_( + date, + start, + end, + locationId +) { + + return [ + String( + date || + '' + ).substring( + 0, + 10 + ), + String( + start || + '' + ).substring( + 0, + 5 + ), + String( + end || + '' + ).substring( + 0, + 5 + ), + String( + locationId || + '' + ) + ].join( + '|' + ); + +} + + +function shiftSlotSort_( + a, + b +) { + + const aKey = + String( + a.date || + '' + ) + + ' ' + + String( + a.start_time || + '' + ); + + + const bKey = + String( + b.date || + '' + ) + + ' ' + + String( + b.start_time || + '' + ); + + + return aKey.localeCompare( + bKey + ); + +} + + +function normalizePlanningPeriod_( + period +) { + + period = + String( + period || + Utilities.formatDate( + new Date(), + CFG.TZ, + 'yyyy-MM' + ) + ); + + + if ( + !/^\d{4}-\d{2}$/.test( + period + ) + ) { + + throw new Error( + 'Neplatné období.' + ); + + } + + + return period; + +} + + +function normalizePlanningTime_( + value +) { + + value = + String( + value || + '' + ).trim(); + + + if ( + !/^\d{1,2}:\d{2}$/.test( + value + ) + ) { + + throw new Error( + 'Neplatný čas.' + ); + + } + + + const parts = + value.split( + ':' + ); + + + const hour = + Number( + parts[0] + ); + + + const minute = + Number( + parts[1] + ); + + + 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' + ) + ); + +} + + +function planningNumber_( + value +) { + + const number = + Number( + String( + value == null + ? 0 + : value + ) + .replace( + ',', + '.' + ) + ); + + + return isFinite( + number + ) + ? number + : 0; + +} + + +/* ============================================================ + VÝCHOZÍ PROVOZOVNA +============================================================ */ + +function getDefaultPlanningLocation_() { + + const locations = + displayRows_( + CFG.SHEETS.LOCATIONS + ); + + + if ( + locations.length + ) { + + return String( + locations[0] + .location_id || + '' + ); + + } + + + return 'LOCATION_MAIN'; + +} + + +/* ============================================================ + VYTVOŘENÍ LISTŮ + + Použije existující databázový spreadsheet. +============================================================ */ + +function ensureShiftPlanningSheet_( + sheetName, + headers +) { + + let sheet; + + + try { + + sheet = + sh_( + sheetName + ); + + + if ( + sheet + ) { + + return sheet; + + } + + } + + catch(error) { + + /* + * list zatím neexistuje + */ + + } + + + const spreadsheet = + getPlanningSpreadsheet_(); + + + sheet = + spreadsheet + .getSheetByName( + sheetName + ); + + + if ( + !sheet + ) { + + sheet = + spreadsheet + .insertSheet( + sheetName + ); + + } + + + sheet + .getRange( + 1, + 1, + 1, + headers.length + ) + .setValues( + [ + headers + ] + ); + + + sheet + .setFrozenRows( + 1 + ); + + + return sheet; + +} + + +function getPlanningSpreadsheet_() { + + /* + * Nejprve zkusíme aktivní spreadsheet. + */ + + const active = + SpreadsheetApp + .getActiveSpreadsheet(); + + + if ( + active + ) { + + return active; + + } + + + /* + * Pokud je projekt standalone, + * hledáme ID ve Script Properties. + */ + + const id = + PropertiesService + .getScriptProperties() + .getProperty( + 'SPREADSHEET_ID' + ); + + + if ( + id + ) { + + return SpreadsheetApp + .openById( + id + ); + + } + + + /* + * Kompatibilita s případným CFG.SPREADSHEET_ID + */ + + if ( + typeof CFG !== + 'undefined' && + CFG.SPREADSHEET_ID + ) { + + return SpreadsheetApp + .openById( + CFG.SPREADSHEET_ID + ); + + } + + + throw new Error( + 'Nepodařilo se zjistit databázový spreadsheet.' + ); + +} + + +/* ============================================================ + DIAGNOSTIKA +============================================================ */ + +function testShiftPlanning() { + + Logger.log( + 'SHIFT_SLOTS: ' + + JSON.stringify( + displayRows_( + SHIFT_PLAN_SHEETS.SLOTS + ) + ) + ); + + + Logger.log( + 'SHIFT_SIGNUPS: ' + + JSON.stringify( + displayRows_( + SHIFT_PLAN_SHEETS.SIGNUPS + ) + ) + ); + + + Logger.log( + 'ShiftPlanningService OK' + ); + +} diff --git a/gscript/ShiftReconciliationService.js b/gscript/ShiftReconciliationService.js new file mode 100644 index 0000000..1ff8f35 --- /dev/null +++ b/gscript/ShiftReconciliationService.js @@ -0,0 +1,1591 @@ +/* ============================================================ + EATME PORTÁL – SHIFT RECONCILIATION SERVICE + + Porovnává: + - plánované směny: SHIFT_SLOTS + SHIFT_SIGNUPS + - skutečnou docházku: SHIFTS + + Nic nepřepisuje. Jde o read-only analytickou vrstvu. +============================================================ */ + +const SHIFT_RECONCILIATION = Object.freeze({ + LATE_TOLERANCE_MINUTES: 10, + EARLY_LEAVE_TOLERANCE_MINUTES: 10, + MATCH_WINDOW_HOURS: 8 +}); + + +/* ============================================================ + ZAMĚSTNANEC – PLÁN VS DOCHÁZKA +============================================================ */ + +function getMyShiftReconciliation( + token, + period +) { + + const user = + requireUser_( + token, + [ + 'EMPLOYEE', + 'MANAGER', + 'ADMIN' + ] + ); + + + if ( + !user.employee_id + ) { + + throw new Error( + 'Účet není propojen se zaměstnancem.' + ); + + } + + + period = + normalizeReconciliationPeriod_( + period + ); + + + const context = + buildReconciliationContext_(); + + + const rows = + reconcileEmployeePeriod_( + user.employee_id, + period, + context + ); + + + return { + + period: + period, + + stats: + reconciliationStats_( + rows + ), + + rows: + rows + + }; + +} + + + +/* ============================================================ + ADMIN / MANAGER – PLÁN VS DOCHÁZKA +============================================================ */ + +function getAdminShiftReconciliation( + token, + period +) { + + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + period = + normalizeReconciliationPeriod_( + period + ); + + + const context = + buildReconciliationContext_(); + + + const result = + []; + + + context.employees + .filter( + function(employee) { + + return ( + String( + employee.active + ).toUpperCase() === + 'TRUE' + ); + + } + ) + .forEach( + function(employee) { + + const rows = + reconcileEmployeePeriod_( + employee.employee_id, + period, + context + ); + + + const employeeName = + ( + String( + employee.first_name || + '' + ) + + ' ' + + String( + employee.last_name || + '' + ) + ).trim(); + + + rows.forEach( + function(row) { + + row.employee_id = + String( + employee.employee_id || + '' + ); + + + row.employee_name = + employeeName; + + + result.push( + row + ); + + } + ); + + } + ); + + + result.sort( + function(a,b) { + + if ( + a.date !== + b.date + ) { + + return String( + a.date + ).localeCompare( + String( + b.date + ) + ); + + } + + + if ( + a.planned_start !== + b.planned_start + ) { + + return String( + a.planned_start || + a.actual_start || + '' + ).localeCompare( + String( + b.planned_start || + b.actual_start || + '' + ) + ); + + } + + + return String( + a.employee_name || + '' + ).localeCompare( + String( + b.employee_name || + '' + ), + 'cs' + ); + + } + ); + + + return { + + period: + period, + + stats: + reconciliationStats_( + result + ), + + rows: + result + + }; + +} + + + +/* ============================================================ + KONTEXT – KAŽDÝ LIST NAČTEME JEN JEDNOU +============================================================ */ + +function buildReconciliationContext_() { + + const slots = + displayRows_( + 'SHIFT_SLOTS' + ); + + + const signups = + displayRows_( + 'SHIFT_SIGNUPS' + ); + + + const shifts = + displayRows_( + CFG.SHEETS.SHIFTS + ); + + + const employees = + displayRows_( + CFG.SHEETS.EMPLOYEES + ); + + + const slotMap = + {}; + + + slots.forEach( + function(slot) { + + slotMap[ + String( + slot.slot_id + ) + ] = + slot; + + } + ); + + + const plannedByEmployee = + {}; + + + signups.forEach( + function(signup) { + + if ( + String( + signup.status + ) !== + 'APPROVED' + ) { + + return; + + } + + + const slot = + slotMap[ + String( + signup.slot_id + ) + ]; + + + if ( + !slot + ) { + + return; + + } + + + const employeeId = + String( + signup.employee_id || + '' + ); + + + if ( + !plannedByEmployee[ + employeeId + ] + ) { + + plannedByEmployee[ + employeeId + ] = + []; + + } + + + plannedByEmployee[ + employeeId + ].push( + { + + signup: + signup, + + slot: + slot + + } + ); + + } + ); + + + const actualByEmployee = + {}; + + + shifts.forEach( + function(shift) { + + const employeeId = + String( + shift.employee_id || + '' + ); + + + if ( + !actualByEmployee[ + employeeId + ] + ) { + + actualByEmployee[ + employeeId + ] = + []; + + } + + + actualByEmployee[ + employeeId + ].push( + shift + ); + + } + ); + + + return { + + employees: + employees, + + plannedByEmployee: + plannedByEmployee, + + actualByEmployee: + actualByEmployee + + }; + +} + + + +/* ============================================================ + PÁROVÁNÍ JEDNOHO ZAMĚSTNANCE +============================================================ */ + +function reconcileEmployeePeriod_( + employeeId, + period, + context +) { + + const planned = + ( + context.plannedByEmployee[ + String( + employeeId + ) + ] || + [] + ) + .filter( + function(item) { + + return ( + String( + item.slot.date || + '' + ).substring( + 0, + 7 + ) === + period + ); + + } + ) + .map( + function(item) { + + return makePlannedItem_( + item + ); + + } + ) + .filter( + Boolean + ) + .sort( + function(a,b) { + + return ( + a.start.getTime() - + b.start.getTime() + ); + + } + ); + + + const actual = + ( + context.actualByEmployee[ + String( + employeeId + ) + ] || + [] + ) + .map( + function(shift) { + + return makeActualItem_( + shift + ); + + } + ) + .filter( + function(item) { + + if ( + !item || + !item.start + ) { + + return false; + + } + + + return ( + Utilities.formatDate( + item.start, + CFG.TZ, + 'yyyy-MM' + ) === + period + ); + + } + ) + .sort( + function(a,b) { + + return ( + a.start.getTime() - + b.start.getTime() + ); + + } + ); + + + const usedActual = + {}; + + + const rows = + []; + + + planned.forEach( + function(plan) { + + let bestIndex = + -1; + + + let bestDistance = + Infinity; + + + actual.forEach( + function(attendance,index) { + + if ( + usedActual[ + index + ] + ) { + + return; + + } + + + const distance = + Math.abs( + attendance.start.getTime() - + plan.start.getTime() + ); + + + if ( + distance > + SHIFT_RECONCILIATION.MATCH_WINDOW_HOURS * + 3600000 + ) { + + return; + + } + + + /* + * Přednost má stejný pracovní den. + */ + + const planDate = + Utilities.formatDate( + plan.start, + CFG.TZ, + 'yyyy-MM-dd' + ); + + + const actualDate = + Utilities.formatDate( + attendance.start, + CFG.TZ, + 'yyyy-MM-dd' + ); + + + const penalty = + planDate === + actualDate + ? 0 + : 24 * + 3600000; + + + const score = + distance + + penalty; + + + if ( + score < + bestDistance + ) { + + bestDistance = + score; + + bestIndex = + index; + + } + + } + ); + + + if ( + bestIndex >= + 0 + ) { + + usedActual[ + bestIndex + ] = + true; + + + rows.push( + reconciliationRow_( + plan, + actual[ + bestIndex + ] + ) + ); + + } else { + + rows.push( + reconciliationRow_( + plan, + null + ) + ); + + } + + } + ); + + + actual.forEach( + function(attendance,index) { + + if ( + usedActual[ + index + ] + ) { + + return; + + } + + + rows.push( + reconciliationRow_( + null, + attendance + ) + ); + + } + ); + + + return rows.sort( + function(a,b) { + + const aKey = + String( + a.date || + '' + ) + + ' ' + + String( + a.planned_start || + a.actual_start || + '' + ); + + + const bKey = + String( + b.date || + '' + ) + + ' ' + + String( + b.planned_start || + b.actual_start || + '' + ); + + + return aKey.localeCompare( + bKey + ); + + } + ); + +} + + + +/* ============================================================ + PLÁNOVANÁ SMĚNA +============================================================ */ + +function makePlannedItem_(item) { + + const slot = + item.slot; + + + const date = + String( + slot.date || + '' + ).substring( + 0, + 10 + ); + + + if ( + !date + ) { + + return null; + + } + + + const start = + reconciliationDateTime_( + date, + slot.start_time + ); + + + let end = + reconciliationDateTime_( + date, + slot.end_time + ); + + + if ( + !start || + !end + ) { + + return null; + + } + + + if ( + end <= + start + ) { + + end = + new Date( + end.getTime() + + 86400000 + ); + + } + + + return { + + slot_id: + String( + slot.slot_id || + '' + ), + + date: + date, + + start: + start, + + end: + end, + + start_time: + reconciliationHHMM_( + start + ), + + end_time: + reconciliationHHMM_( + end + ) + + }; + +} + + + +/* ============================================================ + SKUTEČNÁ SMĚNA +============================================================ */ + +function makeActualItem_(shift) { + + const start = + reconciliationParseDate_( + shift.clock_in + ); + + + if ( + !start + ) { + + return null; + + } + + + const end = + reconciliationParseDate_( + shift.clock_out + ); + + + return { + + shift_id: + String( + shift.shift_id || + '' + ), + + start: + start, + + end: + end, + + status: + String( + shift.status || + '' + ), + + worked_minutes: + reconciliationNumber_( + shift.worked_minutes + ) + + }; + +} + + + +/* ============================================================ + VÝSLEDNÝ ŘÁDEK +============================================================ */ + +function reconciliationRow_( + plan, + attendance +) { + + const now = + new Date(); + + + let status = + 'OK'; + + + let label = + 'V pořádku'; + + + let lateMinutes = + 0; + + + let earlyLeaveMinutes = + 0; + + + let differenceMinutes = + 0; + + + if ( + plan && + !attendance + ) { + + if ( + now < + plan.end + ) { + + status = + 'PLANNED'; + + label = + 'Plánováno'; + + } else { + + status = + 'ABSENT'; + + label = + 'Bez docházky'; + + } + + } + + + if ( + !plan && + attendance + ) { + + status = + 'EXTRA'; + + label = + 'Směna navíc'; + + } + + + if ( + plan && + attendance + ) { + + lateMinutes = + Math.max( + 0, + Math.round( + ( + attendance.start.getTime() - + plan.start.getTime() + ) / + 60000 + ) + ); + + + if ( + attendance.end + ) { + + earlyLeaveMinutes = + Math.max( + 0, + Math.round( + ( + plan.end.getTime() - + attendance.end.getTime() + ) / + 60000 + ) + ); + + } + + + const plannedMinutes = + Math.round( + ( + plan.end.getTime() - + plan.start.getTime() + ) / + 60000 + ); + + + differenceMinutes = + attendance.worked_minutes - + plannedMinutes; + + + if ( + attendance.status === + 'OPEN' + ) { + + status = + 'OPEN'; + + label = + 'Právě probíhá'; + + } else if ( + lateMinutes > + SHIFT_RECONCILIATION.LATE_TOLERANCE_MINUTES + ) { + + status = + 'LATE'; + + label = + 'Pozdní příchod'; + + } else if ( + earlyLeaveMinutes > + SHIFT_RECONCILIATION.EARLY_LEAVE_TOLERANCE_MINUTES + ) { + + status = + 'EARLY_LEAVE'; + + label = + 'Předčasný odchod'; + + } + + } + + + const date = + plan + ? plan.date + : Utilities.formatDate( + attendance.start, + CFG.TZ, + 'yyyy-MM-dd' + ); + + + return { + + date: + date, + + slot_id: + plan + ? plan.slot_id + : '', + + shift_id: + attendance + ? attendance.shift_id + : '', + + planned_start: + plan + ? plan.start_time + : '', + + planned_end: + plan + ? plan.end_time + : '', + + actual_start: + attendance + ? reconciliationHHMM_( + attendance.start + ) + : '', + + actual_end: + attendance && + attendance.end + ? reconciliationHHMM_( + attendance.end + ) + : '', + + worked_minutes: + attendance + ? attendance.worked_minutes + : 0, + + late_minutes: + lateMinutes, + + early_leave_minutes: + earlyLeaveMinutes, + + difference_minutes: + differenceMinutes, + + status: + status, + + status_label: + label + + }; + +} + + + +/* ============================================================ + STATISTIKY +============================================================ */ + +function reconciliationStats_(rows) { + + const stats = + { + + total: + rows.length, + + ok: + 0, + + late: + 0, + + early_leave: + 0, + + absent: + 0, + + extra: + 0, + + open: + 0, + + planned: + 0 + + }; + + + rows.forEach( + function(row) { + + switch( + String( + row.status + ) + ) { + + case 'OK': + + stats.ok++; + break; + + + case 'LATE': + + stats.late++; + break; + + + case 'EARLY_LEAVE': + + stats.early_leave++; + break; + + + case 'ABSENT': + + stats.absent++; + break; + + + case 'EXTRA': + + stats.extra++; + break; + + + case 'OPEN': + + stats.open++; + break; + + + case 'PLANNED': + + stats.planned++; + break; + + } + + } + ); + + + return stats; + +} + + + +/* ============================================================ + HELPERS +============================================================ */ + +function normalizeReconciliationPeriod_( + period +) { + + period = + String( + period || + '' + ).trim(); + + + if ( + !/^\d{4}-\d{2}$/.test( + period + ) + ) { + + return Utilities.formatDate( + new Date(), + CFG.TZ, + 'yyyy-MM' + ); + + } + + + return period; + +} + + +function reconciliationDateTime_( + date, + time +) { + + const dateParts = + String( + date || + '' + ).split( + '-' + ); + + + const timeParts = + String( + time || + '00:00' + ).substring( + 0, + 5 + ).split( + ':' + ); + + + if ( + dateParts.length !== + 3 || + timeParts.length < + 2 + ) { + + return null; + + } + + + const result = + new Date( + Number( + dateParts[0] + ), + Number( + dateParts[1] + ) - 1, + Number( + dateParts[2] + ), + Number( + timeParts[0] + ), + Number( + timeParts[1] + ), + 0 + ); + + + return isNaN( + result.getTime() + ) + ? null + : result; + +} + + +function reconciliationParseDate_( + value +) { + + if ( + !value + ) { + + return null; + + } + + + if ( + value instanceof Date + ) { + + return value; + + } + + + const text = + String( + value + ).trim(); + + + let match; + + + match = + text.match( + /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/ + ); + + + if ( + match + ) { + + return new Date( + Number( + match[1] + ), + Number( + match[2] + ) - 1, + Number( + match[3] + ), + Number( + match[4] || + 0 + ), + Number( + match[5] || + 0 + ), + Number( + match[6] || + 0 + ) + ); + + } + + + match = + text.match( + /^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/ + ); + + + if ( + match + ) { + + return new Date( + Number( + match[3] + ), + Number( + match[1] + ) - 1, + Number( + match[2] + ), + Number( + match[4] || + 0 + ), + Number( + match[5] || + 0 + ), + Number( + match[6] || + 0 + ) + ); + + } + + + match = + text.match( + /^(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/ + ); + + + if ( + match + ) { + + return new Date( + Number( + match[3] + ), + Number( + match[2] + ) - 1, + Number( + match[1] + ), + Number( + match[4] || + 0 + ), + Number( + match[5] || + 0 + ), + Number( + match[6] || + 0 + ) + ); + + } + + + const date = + new Date( + text + ); + + + return isNaN( + date.getTime() + ) + ? null + : date; + +} + + +function reconciliationHHMM_( + date +) { + + if ( + !date + ) { + + return ''; + + } + + + return Utilities.formatDate( + date, + CFG.TZ, + 'HH:mm' + ); + +} + + +function reconciliationNumber_( + value +) { + + const number = + Number( + String( + value == null + ? 0 + : value + ) + .replace( + ',', + '.' + ) + .replace( + /\s/g, + '' + ) + ); + + + return isFinite( + number + ) + ? number + : 0; + +} + + +/* ============================================================ + DIAGNOSTIKA +============================================================ */ + +function testShiftReconciliation() { + + const period = + Utilities.formatDate( + new Date(), + CFG.TZ, + 'yyyy-MM' + ); + + + const context = + buildReconciliationContext_(); + + + Logger.log( + 'Employees: ' + + context.employees.length + ); + + + Logger.log( + 'Period: ' + + period + ); + + + Logger.log( + 'Shift reconciliation OK' + ); + +} diff --git a/gscript/WeeklyPayrollService.js b/gscript/WeeklyPayrollService.js new file mode 100644 index 0000000..b3c9b68 --- /dev/null +++ b/gscript/WeeklyPayrollService.js @@ -0,0 +1,3605 @@ +/* ============================================================ + EATME PORTÁL – WEEKLY PAYROLL SERVICE + + Týdenní výkaz: + - období Po–Ne + - zaměstnanec potvrzuje nejpozději 12 hodin po skončení + poslední plánované směny v daném týdnu + - MANAGER / ADMIN doplní spropitné, bonus, ostatní + - ADMIN označí týden jako PAID + + Měsíční PAYROLL / MONTH_CLOSURES zůstávají oddělené. +============================================================ */ + +const WEEKLY_SHEETS = { + CLOSURES: 'WEEKLY_CLOSURES' +}; + + +/* ============================================================ + JEDNORÁZOVÝ SETUP +============================================================ */ + +function setupWeeklyPayroll() { + + ensureWeeklySheet_( + WEEKLY_SHEETS.CLOSURES, + [ + 'weekly_id', + 'employee_id', + 'week_start', + 'week_end', + 'last_shift_end', + 'deadline_at', + 'worked_minutes', + 'base_amount', + 'tips_amount', + 'card_tip_amount', + 'bonus_amount', + 'sales_bonus_amount', + 'other_amount', + 'final_amount', + 'employee_confirmed_at', + 'status', + 'paid_at', + 'paid_by', + 'employee_notified_at', + 'reminder_sent_at', + 'overdue_notified_at', + 'updated_at' + ] + ); + + + ensureWeeklyPayrollColumns_(); + + + createWeeklyDeadlineTrigger_(); + + + Logger.log( + 'Weekly payroll setup OK' + ); + +} + + + +/* ============================================================ + DOPLNĚNÍ NOVÝCH SLOUPCŮ DO EXISTUJÍCÍHO LISTU +============================================================ */ + +function ensureWeeklyPayrollColumns_() { + + const sheet = + sh_( + WEEKLY_SHEETS.CLOSURES + ); + + + const required = + [ + 'employee_notified_at', + 'reminder_sent_at', + 'overdue_notified_at' + ]; + + + const lastColumn = + Math.max( + 1, + sheet.getLastColumn() + ); + + + const headers = + sheet + .getRange( + 1, + 1, + 1, + lastColumn + ) + .getDisplayValues()[0] + .map( + function(value) { + + return String( + value + ).trim(); + + } + ); + + + required.forEach( + function(header) { + + if ( + headers.includes( + header + ) + ) { + + return; + + } + + + const column = + sheet.getLastColumn() + + 1; + + + sheet + .getRange( + 1, + column + ) + .setValue( + header + ); + + + headers.push( + header + ); + + } + ); + +} + + +/* ============================================================ + HODINOVÝ TRIGGER PRO OVERDUE STAV +============================================================ */ + +function createWeeklyDeadlineTrigger_() { + + const handler = + 'refreshWeeklyDeadlineStatuses'; + + + ScriptApp + .getProjectTriggers() + .filter( + function(trigger) { + + return ( + trigger.getHandlerFunction() === + handler + ); + + } + ) + .forEach( + function(trigger) { + + ScriptApp.deleteTrigger( + trigger + ); + + } + ); + + + ScriptApp + .newTrigger( + handler + ) + .timeBased() + .everyHours( + 1 + ) + .create(); + +} + + + +/* ============================================================ + ZAMĚSTNANEC – AKTUÁLNÍ / ZVOLENÝ TÝDEN +============================================================ */ + +function getMyWeeklyReport( + token, + dateValue +) { + + const user = + requireUser_( + token, + [ + 'EMPLOYEE', + 'MANAGER', + 'ADMIN' + ] + ); + + + if ( + !user.employee_id + ) { + + throw new Error( + 'Účet není propojen se zaměstnancem.' + ); + + } + + + const week = + getWeekRange_( + dateValue + ); + + + const data = + buildWeeklyData_( + user.employee_id, + week.start, + week.end + ); + + + if ( + !data.has_activity + ) { + + return { + + has_activity:false, + + week_start: + week.start, + + week_end: + week.end, + + status: + 'NO_ACTIVITY', + + worked_minutes: + 0, + + shift_count: + 0, + + base_amount: + 0, + + tips_amount: + 0, + + card_tip_amount: + 0, + + bonus_amount: + 0, + + sales_bonus_amount: + 0, + + other_amount: + 0, + + final_amount: + 0, + + last_shift_end: + '', + + deadline_at: + '', + + employee_confirmed_at: + '', + + paid_at: + '', + + can_confirm: + false, + + overdue: + false + + }; + + } + + + const closure = + ensureWeeklyClosure_( + user.employee_id, + week.start, + week.end, + data + ); + + + const now = + new Date(); + + + const deadline = + parseWeeklyDateTime_( + closure.deadline_at + ); + + + const lastShiftEnd = + parseWeeklyDateTime_( + closure.last_shift_end + ); + + + const status = + computeWeeklyStatus_( + closure, + now + ); + + + if ( + status !== + String( + closure.status + ) + ) { + + updateBy_( + WEEKLY_SHEETS.CLOSURES, + 'weekly_id', + closure.weekly_id, + { + + status: + status, + + updated_at: + now_() + + } + ); + + } + + + const salesBonusAmount = + getSalesBonusForEmployeeWeek_( + user.employee_id, + week.start, + week.end + ); + + + const cardTipAmount = + getCardTipForEmployeeWeek_( + user.employee_id, + week.start, + week.end + ); + + + return { + + has_activity:true, + + weekly_id: + String( + closure.weekly_id || + '' + ), + + week_start: + String( + closure.week_start || + week.start + ), + + week_end: + String( + closure.week_end || + week.end + ), + + worked_minutes: + Number( + data.worked_minutes || + 0 + ), + + shift_count: + Number( + data.shift_count || + 0 + ), + + base_amount: + Number( + data.base_amount || + 0 + ), + + tips_amount: + weeklyNumber_( + closure.tips_amount + ), + + card_tip_amount: + cardTipAmount, + + bonus_amount: + weeklyNumber_( + closure.bonus_amount + ), + + sales_bonus_amount: + salesBonusAmount, + + other_amount: + weeklyNumber_( + closure.other_amount + ), + + final_amount: + Number( + data.base_amount || + 0 + ) + + weeklyNumber_( + closure.tips_amount + ) + + cardTipAmount + + weeklyNumber_( + closure.bonus_amount + ) + + salesBonusAmount + + weeklyNumber_( + closure.other_amount + ), + + last_shift_end: + String( + closure.last_shift_end || + '' + ), + + deadline_at: + String( + closure.deadline_at || + '' + ), + + employee_confirmed_at: + String( + closure.employee_confirmed_at || + '' + ), + + paid_at: + String( + closure.paid_at || + '' + ), + + status: + status, + + can_confirm: + ( + lastShiftEnd && + now >= + lastShiftEnd && + ![ + 'EMPLOYEE_CONFIRMED', + 'READY_TO_PAY', + 'PAID' + ].includes( + status + ) + ), + + overdue: + ( + status === + 'OVERDUE' + ), + + deadline_remaining_minutes: + deadline + ? Math.round( + ( + deadline.getTime() - + now.getTime() + ) / + 60000 + ) + : null + + }; + +} + + + +/* ============================================================ + ZAMĚSTNANEC – POTVRZENÍ TÝDNE +============================================================ */ + +function confirmMyWeeklyReport( + token, + weekStart +) { + + const user = + requireUser_( + token, + [ + 'EMPLOYEE', + 'MANAGER', + 'ADMIN' + ] + ); + + + if ( + !user.employee_id + ) { + + throw new Error( + 'Účet není propojen se zaměstnancem.' + ); + + } + + + const week = + getWeekRange_( + weekStart + ); + + + const data = + buildWeeklyData_( + user.employee_id, + week.start, + week.end + ); + + + if ( + !data.has_activity + ) { + + throw new Error( + 'Za tento týden není co potvrdit.' + ); + + } + + + const closure = + ensureWeeklyClosure_( + user.employee_id, + week.start, + week.end, + data + ); + + + if ( + [ + 'EMPLOYEE_CONFIRMED', + 'READY_TO_PAY', + 'PAID' + ].includes( + String( + closure.status + ) + ) + ) { + + return { + ok:true + }; + + } + + + const now = + new Date(); + + + const lastShiftEnd = + parseWeeklyDateTime_( + closure.last_shift_end + ); + + + if ( + !lastShiftEnd || + now < + lastShiftEnd + ) { + + throw new Error( + 'Týdenní výkaz lze potvrdit až po skončení poslední směny v týdnu.' + ); + + } + + + updateBy_( + WEEKLY_SHEETS.CLOSURES, + 'weekly_id', + closure.weekly_id, + { + + worked_minutes: + data.worked_minutes, + + base_amount: + data.base_amount, + + final_amount: + data.base_amount + + weeklyNumber_( + closure.tips_amount + ) + + weeklyNumber_( + closure.bonus_amount + ) + + weeklyNumber_( + closure.other_amount + ), + + employee_confirmed_at: + now_(), + + status: + 'EMPLOYEE_CONFIRMED', + + updated_at: + now_() + + } + ); + + + audit_( + user.user_id, + 'WEEKLY_REPORT_CONFIRMED', + 'WEEKLY_CLOSURE', + closure.weekly_id, + closure.status, + 'EMPLOYEE_CONFIRMED' + ); + + + notifyManagers_( + user.employee_id, + 'Týdenní výkaz potvrzen', + 'Zaměstnanec potvrdil týdenní výkaz za období ' + + week.start + + ' až ' + + week.end + + '.' + ); + + + return { + ok:true + }; + +} + + + +/* ============================================================ + MANAGER / ADMIN – TÝDENNÍ PŘEHLED +============================================================ */ + +/* ============================================================ + ADMIN / MANAGER – DETAIL DOCHÁZKY PRO TÝDENNÍ VÝPLATU + + Vrací pouze směny, které vstupují do mzdového základu. +============================================================ */ + +function getAdminWeeklyAttendanceDetail( + token, + employeeId, + weekStart +) { + + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + employeeId = + String( + employeeId || + '' + ); + + + if ( + !employeeId + ) { + + throw new Error( + 'Chybí zaměstnanec.' + ); + + } + + + const week = + getWeekRange_( + weekStart + ); + + + const context = + buildWeeklyRequestContext_(); + + + const employee = + context.employees + .find( + function(row) { + + return ( + String( + row.employee_id + ) === + employeeId + ); + + } + ); + + + if ( + !employee + ) { + + throw new Error( + 'Zaměstnanec nebyl nalezen.' + ); + + } + + + const shifts = + context.shiftsByEmployee[ + employeeId + ] || + []; + + + const rates = + context.ratesByEmployee[ + employeeId + ] || + []; + + + const dayMap = + {}; + + + shifts.forEach( + function(shift) { + + const workDate = + normalizeDateOnly_( + shift.work_date + ); + + + if ( + !workDate || + workDate < + week.start || + workDate > + week.end + ) { + + return; + + } + + + /* + * Musíme používat stejnou logiku jako buildWeeklyDataFromContext_(), + * jinak by se detail mohl lišit od částky ve výplatě. + */ + if ( + String( + shift.status + ) === + 'OPEN' + ) { + + return; + + } + + + const minutes = + weeklyNumber_( + shift.worked_minutes + ); + + + const breakMinutes = + weeklyNumber_( + shift.break_minutes + ); + + + const clockIn = + parseEmployeeDate_( + shift.clock_in + ); + + + const clockOut = + parseEmployeeDate_( + shift.clock_out + ); + + + const rate = + employeeRateAt_( + rates, + employeeId, + clockIn || + new Date( + workDate + ) + ); + + + const amount = + ( + minutes / + 60 + ) * + rate; + + + if ( + !dayMap[ + workDate + ] + ) { + + dayMap[ + workDate + ] = + { + + work_date: + workDate, + + first_clock_in: + null, + + last_clock_out: + null, + + break_minutes: + 0, + + worked_minutes: + 0, + + base_amount: + 0, + + rates: + [], + + shift_count: + 0 + + }; + + } + + + const day = + dayMap[ + workDate + ]; + + + if ( + clockIn && + ( + !day.first_clock_in || + clockIn < + day.first_clock_in + ) + ) { + + day.first_clock_in = + clockIn; + + } + + + if ( + clockOut && + ( + !day.last_clock_out || + clockOut > + day.last_clock_out + ) + ) { + + day.last_clock_out = + clockOut; + + } + + + day.break_minutes += + breakMinutes; + + + day.worked_minutes += + minutes; + + + day.base_amount += + amount; + + + day.rates.push( + rate + ); + + + day.shift_count++; + + } + ); + + + const rows = + Object.keys( + dayMap + ) + .sort() + .map( + function(date) { + + const day = + dayMap[ + date + ]; + + + const uniqueRates = + Array.from( + new Set( + day.rates.map( + function(rate) { + + return Number( + rate || + 0 + ); + + } + ) + ) + ); + + + return { + + work_date: + day.work_date, + + clock_in: + day.first_clock_in + ? formatWeeklyDateTime_( + day.first_clock_in + ) + : '', + + clock_out: + day.last_clock_out + ? formatWeeklyDateTime_( + day.last_clock_out + ) + : '', + + break_minutes: + Math.round( + day.break_minutes + ), + + worked_minutes: + Math.round( + day.worked_minutes + ), + + hourly_rate: + uniqueRates.length === + 1 + ? uniqueRates[0] + : null, + + effective_hourly_rate: + day.worked_minutes > + 0 + ? ( + day.base_amount / + ( + day.worked_minutes / + 60 + ) + ) + : 0, + + base_amount: + Math.round( + day.base_amount + ), + + shift_count: + day.shift_count + + }; + + } + ); + + + const totalWorkedMinutes = + rows.reduce( + function(sum,row) { + + return ( + sum + + Number( + row.worked_minutes || + 0 + ) + ); + + }, + 0 + ); + + + const totalBreakMinutes = + rows.reduce( + function(sum,row) { + + return ( + sum + + Number( + row.break_minutes || + 0 + ) + ); + + }, + 0 + ); + + + const totalBaseAmount = + rows.reduce( + function(sum,row) { + + return ( + sum + + Number( + row.base_amount || + 0 + ) + ); + + }, + 0 + ); + + + return { + + employee_id: + employeeId, + + employee_name: + ( + String( + employee.first_name || + '' + ) + + ' ' + + String( + employee.last_name || + '' + ) + ).trim(), + + week_start: + week.start, + + week_end: + week.end, + + rows: + rows, + + totals: + { + + worked_minutes: + totalWorkedMinutes, + + break_minutes: + totalBreakMinutes, + + base_amount: + totalBaseAmount, + + day_count: + rows.length + + } + + }; + +} + + +/* ============================================================ + ADMIN / MANAGER – TÝDENNÍ VÝPLATY +============================================================ */ + +function getAdminWeeklyPayroll( + token, + dateValue +) { + + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + const week = + getWeekRange_( + dateValue + ); + + + /* + * VŠECHNA DATA NAČTEME JEDNOU. + * Původní verze znovu četla několik listů pro každého zaměstnance. + */ + const context = + buildWeeklyRequestContext_(); + + + const employees = + context.employees + .filter( + function(employee) { + + return ( + String( + employee.active + ).toUpperCase() === + 'TRUE' + ); + + } + ); + + + const result = + []; + + + const now = + new Date(); + + + employees.forEach( + function(employee) { + + const data = + buildWeeklyDataFromContext_( + employee.employee_id, + week.start, + week.end, + context + ); + + + if ( + !data.has_activity + ) { + + return; + + } + + + const closure = + ensureWeeklyClosureWithContext_( + employee.employee_id, + week.start, + week.end, + data, + context + ); + + + const status = + computeWeeklyStatus_( + closure, + now + ); + + + const salesBonusAmount = + getSalesBonusForEmployeeWeek_( + employee.employee_id, + week.start, + week.end + ); + + + const cardTipAmount = + getCardTipForEmployeeWeek_( + employee.employee_id, + week.start, + week.end + ); + + + result.push( + { + + weekly_id: + String( + closure.weekly_id || + '' + ), + + employee_id: + String( + employee.employee_id || + '' + ), + + employee_name: + ( + String( + employee.first_name || + '' + ) + + ' ' + + String( + employee.last_name || + '' + ) + ).trim(), + + week_start: + week.start, + + week_end: + week.end, + + worked_minutes: + Number( + data.worked_minutes || + 0 + ), + + shift_count: + Number( + data.shift_count || + 0 + ), + + base_amount: + Number( + data.base_amount || + 0 + ), + + tips_amount: + weeklyNumber_( + closure.tips_amount + ), + + card_tip_amount: + cardTipAmount, + + bonus_amount: + weeklyNumber_( + closure.bonus_amount + ), + + sales_bonus_amount: + salesBonusAmount, + + other_amount: + weeklyNumber_( + closure.other_amount + ), + + final_amount: + Number( + data.base_amount || + 0 + ) + + weeklyNumber_( + closure.tips_amount + ) + + cardTipAmount + + weeklyNumber_( + closure.bonus_amount + ) + + salesBonusAmount + + weeklyNumber_( + closure.other_amount + ), + + last_shift_end: + String( + closure.last_shift_end || + '' + ), + + deadline_at: + String( + closure.deadline_at || + '' + ), + + employee_confirmed_at: + String( + closure.employee_confirmed_at || + '' + ), + + paid_at: + String( + closure.paid_at || + '' + ), + + status: + status + + } + ); + + } + ); + + + const sortedRows = + result.sort( + function(a,b) { + + return a.employee_name.localeCompare( + b.employee_name, + 'cs' + ); + + } + ); + + + const stats = + { + + total: + sortedRows.length, + + waiting: + sortedRows.filter( + function(row) { + + return [ + 'IN_PROGRESS', + 'WAITING_EMPLOYEE' + ].includes( + row.status + ); + + } + ).length, + + overdue: + sortedRows.filter( + function(row) { + + return row.status === + 'OVERDUE'; + + } + ).length, + + confirmed: + sortedRows.filter( + function(row) { + + return [ + 'EMPLOYEE_CONFIRMED', + 'READY_TO_PAY' + ].includes( + row.status + ); + + } + ).length, + + paid: + sortedRows.filter( + function(row) { + + return row.status === + 'PAID'; + + } + ).length, + + total_amount: + sortedRows.reduce( + function(sum,row) { + + return sum + + Number( + row.final_amount || + 0 + ); + + }, + 0 + ) + + }; + + + return { + + week_start: + week.start, + + week_end: + week.end, + + stats: + stats, + + rows: + sortedRows + + }; + +} + + + +/* ============================================================ + MANAGER / ADMIN – ODMĚNY +============================================================ */ + +function saveWeeklyAdjustments( + token, + weeklyId, + tipsAmount, + bonusAmount, + otherAmount +) { + + const user = + requireUser_( + token, + [ + 'ADMIN', + 'MANAGER' + ] + ); + + + const closure = + findWeeklyClosureById_( + weeklyId + ); + + + if ( + !closure + ) { + + throw new Error( + 'Týdenní výkaz nebyl nalezen.' + ); + + } + + + if ( + String( + closure.status + ) === + 'PAID' + ) { + + throw new Error( + 'Vyplacený týden už nelze měnit.' + ); + + } + + + if ( + ![ + 'EMPLOYEE_CONFIRMED', + 'READY_TO_PAY' + ].includes( + String( + closure.status + ) + ) + ) { + + throw new Error( + 'Odměny lze zadat až po potvrzení výkazu zaměstnancem.' + ); + + } + + + tipsAmount = + weeklyNumber_( + tipsAmount + ); + + + bonusAmount = + weeklyNumber_( + bonusAmount + ); + + + otherAmount = + weeklyNumber_( + otherAmount + ); + + + const baseAmount = + weeklyNumber_( + closure.base_amount + ); + + + const salesBonusAmount = + getSalesBonusForEmployeeWeek_( + closure.employee_id, + closure.week_start, + closure.week_end + ); + + + const cardTipAmount = + getCardTipForEmployeeWeek_( + closure.employee_id, + closure.week_start, + closure.week_end + ); + + + const finalAmount = + baseAmount + + tipsAmount + + cardTipAmount + + bonusAmount + + salesBonusAmount + + otherAmount; + + + updateBy_( + WEEKLY_SHEETS.CLOSURES, + 'weekly_id', + weeklyId, + { + + tips_amount: + tipsAmount, + + card_tip_amount: + cardTipAmount, + + bonus_amount: + bonusAmount, + + sales_bonus_amount: + salesBonusAmount, + + other_amount: + otherAmount, + + final_amount: + finalAmount, + + status: + 'READY_TO_PAY', + + updated_at: + now_() + + } + ); + + + audit_( + user.user_id, + 'WEEKLY_ADJUSTMENTS_SAVED', + 'WEEKLY_CLOSURE', + weeklyId, + '', + JSON.stringify({ + + tips_amount: + tipsAmount, + + card_tip_amount: + cardTipAmount, + + bonus_amount: + bonusAmount, + + sales_bonus_amount: + salesBonusAmount, + + other_amount: + otherAmount, + + final_amount: + finalAmount + + }) + ); + + + return { + + ok:true, + + final_amount: + finalAmount + + }; + +} + + + +/* ============================================================ + ADMIN – OZNAČIT VYPLACENO +============================================================ */ + +function markWeeklyPaid( + token, + weeklyId +) { + + const user = + requireUser_( + token, + [ + 'ADMIN' + ] + ); + + + const closure = + findWeeklyClosureById_( + weeklyId + ); + + + if ( + !closure + ) { + + throw new Error( + 'Týdenní výkaz nebyl nalezen.' + ); + + } + + + if ( + ![ + 'EMPLOYEE_CONFIRMED', + 'READY_TO_PAY' + ].includes( + String( + closure.status + ) + ) + ) { + + throw new Error( + 'Výkaz zatím není připravený k výplatě.' + ); + + } + + + updateBy_( + WEEKLY_SHEETS.CLOSURES, + 'weekly_id', + weeklyId, + { + + status: + 'PAID', + + paid_at: + now_(), + + paid_by: + user.user_id, + + updated_at: + now_() + + } + ); + + + audit_( + user.user_id, + 'WEEKLY_MARKED_PAID', + 'WEEKLY_CLOSURE', + weeklyId, + closure.status, + 'PAID' + ); + + + notifyEmployee_( + closure.employee_id, + 'Týdenní výplata vyplacena', + 'Výplata za týden ' + + closure.week_start + + ' až ' + + closure.week_end + + ' byla označena jako vyplacená.' + ); + + + return { + ok:true + }; + +} + + + +/* ============================================================ + AUTOMATICKÁ KONTROLA DEADLINŮ +============================================================ */ + +function refreshWeeklyDeadlineStatuses() { + + ensureWeeklyPayrollColumns_(); + + + const closures = + displayRows_( + WEEKLY_SHEETS.CLOSURES + ); + + + const now = + new Date(); + + + closures.forEach( + function(closure) { + + const current = + String( + closure.status || + '' + ); + + + if ( + [ + 'EMPLOYEE_CONFIRMED', + 'READY_TO_PAY', + 'PAID' + ].includes( + current + ) + ) { + + return; + + } + + + const lastShiftEnd = + parseWeeklyDateTime_( + closure.last_shift_end + ); + + + const deadline = + parseWeeklyDateTime_( + closure.deadline_at + ); + + + if ( + !lastShiftEnd || + !deadline + ) { + + return; + + } + + + const status = + computeWeeklyStatus_( + closure, + now + ); + + + const update = + {}; + + + /* + * 1) Jakmile skončí poslední směna, zaměstnanec dostane + * upozornění, že má 12 hodin na potvrzení. + */ + if ( + now >= + lastShiftEnd && + !closure.employee_notified_at + ) { + + notifyEmployee_( + closure.employee_id, + 'Potvrď týdenní výkaz', + 'Skončila tvoje poslední směna v týdnu. Týdenní výkaz potvrď nejpozději do ' + + formatWeeklyDateTime_( + deadline + ) + + '.' + ); + + + update.employee_notified_at = + now_(); + + } + + + /* + * 2) Dvě hodiny před deadlinem pošleme jednu připomínku. + */ + const reminderAt = + new Date( + deadline.getTime() - + 2 * + 60 * + 60 * + 1000 + ); + + + if ( + now >= + reminderAt && + now < + deadline && + !closure.reminder_sent_at + ) { + + notifyEmployee_( + closure.employee_id, + 'Týdenní výkaz čeká na potvrzení', + 'Na potvrzení týdenního výkazu zbývají méně než 2 hodiny. Deadline je ' + + formatWeeklyDateTime_( + deadline + ) + + '.' + ); + + + update.reminder_sent_at = + now_(); + + } + + + /* + * 3) Po překročení deadlinu upozorníme manažery. + */ + if ( + now > + deadline && + !closure.overdue_notified_at + ) { + + notifyManagers_( + closure.employee_id, + 'Týdenní výkaz po termínu', + 'Zaměstnanec nepotvrdil týdenní výkaz za období ' + + closure.week_start + + ' až ' + + closure.week_end + + ' do stanoveného termínu.' + ); + + + update.overdue_notified_at = + now_(); + + } + + + if ( + status !== + current + ) { + + update.status = + status; + + } + + + if ( + Object.keys( + update + ).length + ) { + + update.updated_at = + now_(); + + + updateBy_( + WEEKLY_SHEETS.CLOSURES, + 'weekly_id', + closure.weekly_id, + update + ); + + } + + } + ); + +} + + + +/* ============================================================ + VÝPOČET DAT TÝDNE +============================================================ */ + +function buildWeeklyData_( + employeeId, + weekStart, + weekEnd +) { + + const context = + buildWeeklyRequestContext_(); + + + return buildWeeklyDataFromContext_( + employeeId, + weekStart, + weekEnd, + context + ); + +} + + + +/* ============================================================ + POSLEDNÍ PLÁNOVANÁ SMĚNA TÝDNE +============================================================ */ + +function getLastPlannedShiftEnd_( + employeeId, + weekStart, + weekEnd +) { + + const context = + buildWeeklyRequestContext_(); + + + return getLastPlannedShiftEndFromContext_( + employeeId, + weekStart, + weekEnd, + context + ); + +} + + + +/* ============================================================ + ZALOŽENÍ / OBNOVENÍ TÝDENNÍHO ZÁZNAMU +============================================================ */ + +function ensureWeeklyClosure_( + employeeId, + weekStart, + weekEnd, + data +) { + + const context = + buildWeeklyRequestContext_(); + + + return ensureWeeklyClosureWithContext_( + employeeId, + weekStart, + weekEnd, + data, + context + ); + +} + + + +/* ============================================================ + REQUEST CONTEXT – JEDNO ČTENÍ TABULEK NA REQUEST +============================================================ */ + +function buildWeeklyRequestContext_() { + + const employees = + displayRows_( + CFG.SHEETS.EMPLOYEES + ); + + + const shifts = + displayRows_( + CFG.SHEETS.SHIFTS + ); + + + const rates = + displayRows_( + CFG.SHEETS.PAY_RATES + ); + + + const slots = + displayRows_( + 'SHIFT_SLOTS' + ); + + + const signups = + displayRows_( + 'SHIFT_SIGNUPS' + ); + + + const closures = + displayRows_( + WEEKLY_SHEETS.CLOSURES + ); + + + const shiftsByEmployee = + {}; + + + shifts.forEach( + function(shift) { + + const employeeId = + String( + shift.employee_id || + '' + ); + + + if ( + !employeeId + ) { + + return; + + } + + + if ( + !shiftsByEmployee[ + employeeId + ] + ) { + + shiftsByEmployee[ + employeeId + ] = + []; + + } + + + shiftsByEmployee[ + employeeId + ].push( + shift + ); + + } + ); + + + const ratesByEmployee = + {}; + + + rates.forEach( + function(rate) { + + const employeeId = + String( + rate.employee_id || + '' + ); + + + if ( + !employeeId + ) { + + return; + + } + + + if ( + !ratesByEmployee[ + employeeId + ] + ) { + + ratesByEmployee[ + employeeId + ] = + []; + + } + + + ratesByEmployee[ + employeeId + ].push( + rate + ); + + } + ); + + + const slotMap = + {}; + + + slots.forEach( + function(slot) { + + slotMap[ + String( + slot.slot_id + ) + ] = + slot; + + } + ); + + + const plannedSlotsByEmployee = + {}; + + + signups.forEach( + function(signup) { + + if ( + String( + signup.status + ) !== + 'APPROVED' + ) { + + return; + + } + + + const employeeId = + String( + signup.employee_id || + '' + ); + + + const slot = + slotMap[ + String( + signup.slot_id + ) + ]; + + + if ( + !employeeId || + !slot + ) { + + return; + + } + + + if ( + !plannedSlotsByEmployee[ + employeeId + ] + ) { + + plannedSlotsByEmployee[ + employeeId + ] = + []; + + } + + + plannedSlotsByEmployee[ + employeeId + ].push( + slot + ); + + } + ); + + + const closuresByKey = + {}; + + + closures.forEach( + function(closure) { + + closuresByKey[ + weeklyClosureKey_( + closure.employee_id, + closure.week_start + ) + ] = + closure; + + } + ); + + + return { + + employees: + employees, + + shiftsByEmployee: + shiftsByEmployee, + + ratesByEmployee: + ratesByEmployee, + + plannedSlotsByEmployee: + plannedSlotsByEmployee, + + closuresByKey: + closuresByKey + + }; + +} + + + +/* ============================================================ + VÝPOČET TÝDNE Z PŘEDNAČTENÝCH DAT +============================================================ */ + +function buildWeeklyDataFromContext_( + employeeId, + weekStart, + weekEnd, + context +) { + + const employeeKey = + String( + employeeId + ); + + + const shifts = + context.shiftsByEmployee[ + employeeKey + ] || + []; + + + const rates = + context.ratesByEmployee[ + employeeKey + ] || + []; + + + let workedMinutes = + 0; + + + let baseAmount = + 0; + + + let shiftCount = + 0; + + + let latestActualEnd = + null; + + + shifts.forEach( + function(shift) { + + const workDate = + normalizeDateOnly_( + shift.work_date + ); + + + if ( + !workDate || + workDate < + weekStart || + workDate > + weekEnd + ) { + + return; + + } + + + if ( + String( + shift.status + ) === + 'OPEN' + ) { + + return; + + } + + + const minutes = + weeklyNumber_( + shift.worked_minutes + ); + + + workedMinutes += + minutes; + + + if ( + minutes > + 0 + ) { + + shiftCount++; + + } + + + const clockIn = + parseEmployeeDate_( + shift.clock_in + ); + + + const rate = + employeeRateAt_( + rates, + employeeId, + clockIn || + new Date( + workDate + ) + ); + + + baseAmount += + ( + minutes / + 60 + ) * + rate; + + + const clockOut = + parseEmployeeDate_( + shift.clock_out + ); + + + if ( + clockOut && + ( + !latestActualEnd || + clockOut > + latestActualEnd + ) + ) { + + latestActualEnd = + clockOut; + + } + + } + ); + + + const plannedEnd = + getLastPlannedShiftEndFromContext_( + employeeId, + weekStart, + weekEnd, + context + ); + + + let lastShiftEnd = + plannedEnd || + latestActualEnd; + + + if ( + plannedEnd && + latestActualEnd && + latestActualEnd > + plannedEnd + ) { + + lastShiftEnd = + latestActualEnd; + + } + + + const deadline = + lastShiftEnd + ? new Date( + lastShiftEnd.getTime() + + 12 * + 60 * + 60 * + 1000 + ) + : null; + + + return { + + has_activity: + ( + shiftCount > + 0 || + !!plannedEnd + ), + + worked_minutes: + Math.round( + workedMinutes + ), + + shift_count: + shiftCount, + + base_amount: + Math.round( + baseAmount + ), + + last_shift_end: + lastShiftEnd + ? formatWeeklyDateTime_( + lastShiftEnd + ) + : '', + + deadline_at: + deadline + ? formatWeeklyDateTime_( + deadline + ) + : '' + + }; + +} + + + +/* ============================================================ + POSLEDNÍ PLÁNOVANÁ SMĚNA Z PŘEDNAČTENÝCH DAT +============================================================ */ + +function getLastPlannedShiftEndFromContext_( + employeeId, + weekStart, + weekEnd, + context +) { + + const slots = + context.plannedSlotsByEmployee[ + String( + employeeId + ) + ] || + []; + + + let latest = + null; + + + slots.forEach( + function(slot) { + + const date = + normalizeDateOnly_( + slot.date + ); + + + if ( + !date || + date < + weekStart || + date > + weekEnd + ) { + + return; + + } + + + const end = + weeklySlotEnd_( + slot + ); + + + if ( + end && + ( + !latest || + end > + latest + ) + ) { + + latest = + end; + + } + + } + ); + + + return latest; + +} + + + +/* ============================================================ + UZÁVĚRKA Z REQUEST CONTEXTU + + Důležité: + - tabulku WEEKLY_CLOSURES nečteme znovu pro každého člověka + - update děláme pouze pokud se skutečně změnila data +============================================================ */ + +function ensureWeeklyClosureWithContext_( + employeeId, + weekStart, + weekEnd, + data, + context +) { + + const key = + weeklyClosureKey_( + employeeId, + weekStart + ); + + + let closure = + context.closuresByKey[ + key + ]; + + + if ( + !closure + ) { + + const weeklyId = + uuid_( + 'WEEK' + ); + + + closure = + { + + weekly_id: + weeklyId, + + employee_id: + employeeId, + + week_start: + weekStart, + + week_end: + weekEnd, + + last_shift_end: + data.last_shift_end, + + deadline_at: + data.deadline_at, + + worked_minutes: + data.worked_minutes, + + base_amount: + data.base_amount, + + tips_amount: + 0, + + bonus_amount: + 0, + + sales_bonus_amount: + getSalesBonusForEmployeeWeek_( + employeeId, + weekStart, + weekEnd + ), + + other_amount: + 0, + + final_amount: + data.base_amount, + + employee_confirmed_at: + '', + + status: + initialWeeklyStatus_( + data + ), + + paid_at: + '', + + paid_by: + '', + + updated_at: + now_() + + }; + + + append_( + WEEKLY_SHEETS.CLOSURES, + closure + ); + + + context.closuresByKey[ + key + ] = + closure; + + + return closure; + + } + + + if ( + [ + 'EMPLOYEE_CONFIRMED', + 'READY_TO_PAY', + 'PAID' + ].includes( + String( + closure.status + ) + ) + ) { + + return closure; + + } + + + const finalAmount = + data.base_amount + + weeklyNumber_( + closure.tips_amount + ) + + weeklyNumber_( + closure.bonus_amount + ) + + getSalesBonusForEmployeeWeek_( + employeeId, + weekStart, + weekEnd + ) + + weeklyNumber_( + closure.other_amount + ); + + + const changed = + String( + closure.week_end || + '' + ) !== + String( + weekEnd + ) || + + String( + closure.last_shift_end || + '' + ) !== + String( + data.last_shift_end || + '' + ) || + + String( + closure.deadline_at || + '' + ) !== + String( + data.deadline_at || + '' + ) || + + Number( + closure.worked_minutes || + 0 + ) !== + Number( + data.worked_minutes || + 0 + ) || + + Number( + closure.base_amount || + 0 + ) !== + Number( + data.base_amount || + 0 + ) || + + Number( + closure.final_amount || + 0 + ) !== + Number( + finalAmount || + 0 + ); + + + if ( + changed + ) { + + const update = + { + + week_end: + weekEnd, + + last_shift_end: + data.last_shift_end, + + deadline_at: + data.deadline_at, + + worked_minutes: + data.worked_minutes, + + base_amount: + data.base_amount, + + final_amount: + finalAmount, + + updated_at: + now_() + + }; + + + updateBy_( + WEEKLY_SHEETS.CLOSURES, + 'weekly_id', + closure.weekly_id, + update + ); + + + Object.keys( + update + ).forEach( + function(field) { + + closure[ + field + ] = + update[ + field + ]; + + } + ); + + } + + + return closure; + +} + + + +/* ============================================================ + KLÍČ TÝDENNÍ UZÁVĚRKY +============================================================ */ + +function weeklyClosureKey_( + employeeId, + weekStart +) { + + return ( + String( + employeeId || + '' + ) + + '|' + + String( + weekStart || + '' + ) + ); + +} + + +/* ============================================================ + STAVY +============================================================ */ + +function initialWeeklyStatus_( + data +) { + + const now = + new Date(); + + + const lastShiftEnd = + parseWeeklyDateTime_( + data.last_shift_end + ); + + + const deadline = + parseWeeklyDateTime_( + data.deadline_at + ); + + + if ( + lastShiftEnd && + now < + lastShiftEnd + ) { + + return 'IN_PROGRESS'; + + } + + + if ( + deadline && + now > + deadline + ) { + + return 'OVERDUE'; + + } + + + return 'WAITING_EMPLOYEE'; + +} + + +function computeWeeklyStatus_( + closure, + now +) { + + const current = + String( + closure.status || + '' + ); + + + if ( + [ + 'EMPLOYEE_CONFIRMED', + 'READY_TO_PAY', + 'PAID' + ].includes( + current + ) + ) { + + return current; + + } + + + const lastShiftEnd = + parseWeeklyDateTime_( + closure.last_shift_end + ); + + + const deadline = + parseWeeklyDateTime_( + closure.deadline_at + ); + + + if ( + lastShiftEnd && + now < + lastShiftEnd + ) { + + return 'IN_PROGRESS'; + + } + + + if ( + deadline && + now > + deadline + ) { + + return 'OVERDUE'; + + } + + + return 'WAITING_EMPLOYEE'; + +} + + + +/* ============================================================ + TÝDEN PO–NE +============================================================ */ + +function getWeekRange_( + dateValue +) { + + let date; + + + if ( + dateValue && + /^\d{4}-\d{2}-\d{2}$/.test( + String( + dateValue + ) + ) + ) { + + const parts = + String( + dateValue + ).split( + '-' + ); + + + date = + new Date( + Number( + parts[0] + ), + Number( + parts[1] + ) - 1, + Number( + parts[2] + ), + 12, + 0, + 0 + ); + + } else { + + date = + new Date(); + + } + + + const day = + date.getDay(); + + + const mondayOffset = + day === 0 + ? -6 + : 1 - day; + + + const monday = + new Date( + date + ); + + + monday.setDate( + monday.getDate() + + mondayOffset + ); + + + const sunday = + new Date( + monday + ); + + + sunday.setDate( + sunday.getDate() + + 6 + ); + + + return { + + start: + Utilities.formatDate( + monday, + CFG.TZ, + 'yyyy-MM-dd' + ), + + end: + Utilities.formatDate( + sunday, + CFG.TZ, + 'yyyy-MM-dd' + ) + + }; + +} + + + +/* ============================================================ + HELPERS +============================================================ */ + +function findWeeklyClosureById_( + weeklyId +) { + + return displayRows_( + WEEKLY_SHEETS.CLOSURES + ) + .find( + function(row) { + + return ( + String( + row.weekly_id + ) === + String( + weeklyId + ) + ); + + } + ) || + null; + +} + + +function normalizeDateOnly_( + value +) { + + if ( + !value + ) { + + return ''; + + } + + + if ( + value instanceof Date + ) { + + return Utilities.formatDate( + value, + CFG.TZ, + 'yyyy-MM-dd' + ); + + } + + + const text = + String( + value + ); + + + const iso = + text.match( + /^(\d{4}-\d{2}-\d{2})/ + ); + + + if ( + iso + ) { + + return iso[1]; + + } + + + const date = + new Date( + text + ); + + + if ( + isNaN( + date.getTime() + ) + ) { + + return ''; + + } + + + return Utilities.formatDate( + date, + CFG.TZ, + 'yyyy-MM-dd' + ); + +} + + +function weeklySlotEnd_( + slot +) { + + const date = + normalizeDateOnly_( + slot.date + ); + + + if ( + !date + ) { + + return null; + + } + + + const dateParts = + date.split( + '-' + ); + + + const startParts = + String( + slot.start_time || + '00:00' + ).split( + ':' + ); + + + const endParts = + String( + slot.end_time || + '00:00' + ).split( + ':' + ); + + + const start = + new Date( + Number( + dateParts[0] + ), + Number( + dateParts[1] + ) - 1, + Number( + dateParts[2] + ), + Number( + startParts[0] + ), + Number( + startParts[1] || + 0 + ), + 0 + ); + + + const end = + new Date( + Number( + dateParts[0] + ), + Number( + dateParts[1] + ) - 1, + Number( + dateParts[2] + ), + Number( + endParts[0] + ), + Number( + endParts[1] || + 0 + ), + 0 + ); + + + if ( + end <= + start + ) { + + end.setDate( + end.getDate() + + 1 + ); + + } + + + return end; + +} + + +function formatWeeklyDateTime_( + date +) { + + return Utilities.formatDate( + date, + CFG.TZ, + 'yyyy-MM-dd HH:mm:ss' + ); + +} + + +function parseWeeklyDateTime_( + value +) { + + if ( + !value + ) { + + return null; + + } + + + if ( + value instanceof Date + ) { + + return value; + + } + + + const text = + String( + value + ).trim(); + + + const match = + text.match( + /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/ + ); + + + if ( + match + ) { + + return new Date( + Number( + match[1] + ), + Number( + match[2] + ) - 1, + Number( + match[3] + ), + Number( + match[4] + ), + Number( + match[5] + ), + Number( + match[6] || + 0 + ) + ); + + } + + + const date = + new Date( + text + ); + + + return isNaN( + date.getTime() + ) + ? null + : date; + +} + + +function weeklyNumber_( + value +) { + + const number = + Number( + String( + value == null + ? 0 + : value + ) + .replace( + ',', + '.' + ) + ); + + + return isFinite( + number + ) + ? number + : 0; + +} + + +/* ============================================================ + VYTVOŘENÍ LISTU +============================================================ */ + +function ensureWeeklySheet_( + sheetName, + headers +) { + + const employeeSheet = + sh_( + CFG.SHEETS.EMPLOYEES + ); + + + const spreadsheet = + employeeSheet + .getParent(); + + + let sheet = + spreadsheet + .getSheetByName( + sheetName + ); + + + if ( + !sheet + ) { + + sheet = + spreadsheet + .insertSheet( + sheetName + ); + + } + + + if ( + sheet.getLastRow() === + 0 + ) { + + sheet + .getRange( + 1, + 1, + 1, + headers.length + ) + .setValues( + [ + headers + ] + ); + + + sheet.setFrozenRows( + 1 + ); + + } + + + return sheet; + +} + + +/* ============================================================ + DIAGNOSTIKA +============================================================ */ + +function testWeeklyPayroll() { + + Logger.log( + 'WEEKLY_CLOSURES: ' + + JSON.stringify( + displayRows_( + WEEKLY_SHEETS.CLOSURES + ) + ) + ); + + + Logger.log( + 'WeeklyPayrollService OK' + ); + +} diff --git a/gscript/appsscript.json b/gscript/appsscript.json new file mode 100644 index 0000000..c0eb641 --- /dev/null +++ b/gscript/appsscript.json @@ -0,0 +1,10 @@ +{ + "timeZone": "Europe/Prague", + "dependencies": {}, + "exceptionLogging": "STACKDRIVER", + "runtimeVersion": "V8", + "webapp": { + "executeAs": "USER_DEPLOYING", + "access": "ANYONE_ANONYMOUS" + } +} \ No newline at end of file