Update OKF docs bundle, README, and CLAUDE.md for today's features
Documents the shift planning and closure/payroll modules (new tables, routes, stores), the dark redesign, and the docker-compose-broken-host workaround. Adds CLAUDE.md pointing agents at docs/. Translates README to English and keeps host-specific infrastructure details out of the repo.
This commit is contained in:
@@ -4,3 +4,8 @@ SQLite via `better-sqlite3`, schema created on boot in `backend/src/db/index.ts`
|
||||
|
||||
- [employees](./employees.md) - the whitelist of employee emails, with a soft-delete active flag
|
||||
- [attendance_events](./attendance-events.md) - append-only clock-in/out/break log and the derived state machine
|
||||
- [shift_slots](./shift-slots.md) - bookable shifts generated from a weekly template or added ad hoc
|
||||
- [shift_signups](./shift-signups.md) - an employee's claim on a slot
|
||||
- [pay_rates](./pay-rates.md) - versioned hourly rate per employee
|
||||
- [month_closures](./month-closures.md) - the confirm -> lock workflow per employee per month
|
||||
- [payroll](./payroll.md) - computed + admin-adjusted pay per employee per month
|
||||
|
||||
61
docs/data-model/month-closures.md
Normal file
61
docs/data-model/month-closures.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
type: SQLite Table
|
||||
title: month_closures
|
||||
description: One row per employee per calendar month tracking the confirm -> lock workflow that gates payroll.
|
||||
resource: backend/src/db/index.ts
|
||||
tags: [data-model, sqlite, payroll]
|
||||
timestamp: 2026-08-16T00:00:00Z
|
||||
---
|
||||
|
||||
# month_closures
|
||||
|
||||
Tracks where a given employee's given month is in the confirm/lock
|
||||
workflow. Rows are created lazily — the first time the employee opens that
|
||||
period — not proactively for every employee/month combination.
|
||||
|
||||
# Schema
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK | autoincrement |
|
||||
| `employee_id` | INTEGER | references [employees](./employees.md) |
|
||||
| `period` | TEXT | `YYYY-MM` |
|
||||
| `status` | TEXT | `waiting_employee` \| `confirmed` \| `locked` |
|
||||
| `employee_confirmed_at` | TEXT \| NULL | set on confirm |
|
||||
| `locked_at` | TEXT \| NULL | set on lock, cleared on reopen |
|
||||
|
||||
`UNIQUE (employee_id, period)`.
|
||||
|
||||
# State machine
|
||||
|
||||
```
|
||||
waiting_employee --employee confirms--> confirmed
|
||||
confirmed --admin locks--> locked
|
||||
locked --admin reopens--> confirmed (undoes a premature lock)
|
||||
```
|
||||
|
||||
- **Confirm** (`confirmClosure`, `backend/src/services/closure.ts`):
|
||||
employee-only action, blocked while any session in that period is still
|
||||
`open` (see [attendance_events](./attendance-events.md)). Idempotent —
|
||||
confirming an already-confirmed month is a no-op, not an error.
|
||||
- **Lock** (`lockClosure`, called from `lockAndFinalizePayroll` in
|
||||
`backend/src/services/payroll.ts`): admin-only, requires `confirmed`,
|
||||
re-checks for an open session (defensive — time may have passed since
|
||||
confirm), freezes the [payroll](./payroll.md) row.
|
||||
- **Reopen** (`reopenPayroll`): undoes a lock made before the numbers were
|
||||
actually right (e.g. the hourly rate wasn't set yet) — back to
|
||||
`confirmed`, with the payroll row reset to `draft`. Only possible while
|
||||
payroll status is `ready`, not once `paid` — that's the point past which
|
||||
a mistake has to be corrected some other way, not silently rewritten.
|
||||
|
||||
Unlike the source Google Apps Script this was ported from
|
||||
(`gscript/ClosureService.js`), there's no separate `MANAGER_APPROVED`
|
||||
state — that system modeled independent ADMIN/MANAGER/ACCOUNTANT roles;
|
||||
this app has one owner-admin, so "manager approves" and "admin locks"
|
||||
collapse into a single action here.
|
||||
|
||||
# Related
|
||||
|
||||
- [payroll](./payroll.md)
|
||||
- [attendance_events](./attendance-events.md) — `hasOpenSessionInPeriod` gate
|
||||
- [Closure API](/docs/api/closure-routes.md)
|
||||
47
docs/data-model/pay-rates.md
Normal file
47
docs/data-model/pay-rates.md
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
type: SQLite Table
|
||||
title: pay_rates
|
||||
description: Hourly rate history per employee, keyed by the date it takes effect.
|
||||
resource: backend/src/db/index.ts
|
||||
tags: [data-model, sqlite, payroll]
|
||||
timestamp: 2026-08-16T00:00:00Z
|
||||
---
|
||||
|
||||
# pay_rates
|
||||
|
||||
Versioned hourly rate per employee. There's no `valid_to` column — the
|
||||
active rate for a given date is simply the row with the largest
|
||||
`valid_from <= that date` (`getRateAt` in `backend/src/services/payRates.ts`).
|
||||
A new rate implicitly ends the previous one.
|
||||
|
||||
# Schema
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK | autoincrement |
|
||||
| `employee_id` | INTEGER | references [employees](./employees.md) |
|
||||
| `hourly_rate` | REAL | Kč/hour |
|
||||
| `valid_from` | TEXT | `YYYY-MM-DD`, takes effect from this date inclusive |
|
||||
| `created_at` | TEXT | ISO 8601 UTC |
|
||||
|
||||
`UNIQUE (employee_id, valid_from)` — setting a rate again for the same date
|
||||
overwrites it (`ON CONFLICT ... DO UPDATE`) rather than erroring.
|
||||
|
||||
# Behavior
|
||||
|
||||
- **First rate ever set for an employee** defaults `valid_from` to the
|
||||
employee's own `created_at` date when the admin doesn't specify one — so
|
||||
hours already worked before the admin got around to entering a rate
|
||||
still get paid correctly, rather than pricing at 0. This was a real bug
|
||||
found live: an admin set a rate today, and it didn't apply to shifts
|
||||
worked earlier that same month.
|
||||
- **Every later rate change** (a raise) defaults to today instead — not
|
||||
retroactive. This asymmetry lives in `defaultValidFrom` in
|
||||
`backend/src/services/payRates.ts` and is covered by a test in
|
||||
`backend/src/services/payroll.test.ts`.
|
||||
|
||||
# Related
|
||||
|
||||
- [employees](./employees.md)
|
||||
- [month_closures](./month-closures.md) / [payroll](./payroll.md) — consume
|
||||
`getRateAt` when computing `earned_estimate` / `base_amount`
|
||||
64
docs/data-model/payroll.md
Normal file
64
docs/data-model/payroll.md
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
type: SQLite Table
|
||||
title: payroll
|
||||
description: Computed + admin-adjusted pay per employee per month — base hours pay, tips, bonus, other, and payment status.
|
||||
resource: backend/src/db/index.ts
|
||||
tags: [data-model, sqlite, payroll]
|
||||
timestamp: 2026-08-16T00:00:00Z
|
||||
---
|
||||
|
||||
# payroll
|
||||
|
||||
One row per employee per period, created the first time the admin either
|
||||
saves a draft adjustment or locks the month.
|
||||
|
||||
# Schema
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK | autoincrement |
|
||||
| `employee_id` | INTEGER | references [employees](./employees.md) |
|
||||
| `period` | TEXT | `YYYY-MM` |
|
||||
| `worked_minutes` | INTEGER | snapshotted at last save/lock, not live |
|
||||
| `base_amount` | REAL | worked hours × the rate active on each shift's own date — see [pay_rates](./pay-rates.md) |
|
||||
| `tips_amount` / `bonus_amount` / `other_amount` | REAL | admin-entered, default 0 |
|
||||
| `final_amount` | REAL | sum of the four amounts above |
|
||||
| `status` | TEXT | `draft` \| `ready` \| `paid` |
|
||||
| `payment_date` | TEXT \| NULL | set when marked `paid` |
|
||||
| `updated_at` | TEXT | ISO 8601 UTC |
|
||||
|
||||
`UNIQUE (employee_id, period)`.
|
||||
|
||||
# Status lifecycle
|
||||
|
||||
```
|
||||
draft --admin locks the month closure--> ready --admin marks paid--> paid
|
||||
ready --admin reopens--> draft
|
||||
```
|
||||
|
||||
- **`draft`**: editable. `saveDraftAdjustments`
|
||||
(`backend/src/services/payroll.ts`) recomputes `base_amount` /
|
||||
`worked_minutes` fresh from
|
||||
[attendance_events](./attendance-events.md) on every save, and requires
|
||||
the linked [month_closures](./month-closures.md) row to be at least
|
||||
`confirmed` (not still `waiting_employee`).
|
||||
- **`ready`**: frozen. Set by `lockAndFinalizePayroll`, which locks the
|
||||
closure in the same call. `saveDraftAdjustments` refuses to touch a
|
||||
non-`draft` row — this is what turns "already locked" into the right
|
||||
error message instead of the generic "not confirmed yet" one (a real bug
|
||||
caught by `payroll.test.ts`: the two checks used to overlap).
|
||||
- **`paid`**: terminal. Set by `markPaid`, stamps today's date. Not
|
||||
reopenable — a mistake past this point needs a manual correction, not a
|
||||
silent rewrite.
|
||||
|
||||
This collapses the source system's four-stage accountant pipeline
|
||||
(`READY_FOR_ACCOUNTANT` / `PROCESSING` / `PROCESSED` / `PAID` in
|
||||
`gscript/PayrollService.js`) down to two — `ready` and `paid` — since
|
||||
there's no separate accountant role here for the intermediate stages to
|
||||
mean anything.
|
||||
|
||||
# Related
|
||||
|
||||
- [month_closures](./month-closures.md)
|
||||
- [pay_rates](./pay-rates.md)
|
||||
- [Closure API](/docs/api/closure-routes.md), [Admin API](/docs/api/admin-routes.md)
|
||||
48
docs/data-model/shift-signups.md
Normal file
48
docs/data-model/shift-signups.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
type: SQLite Table
|
||||
title: shift_signups
|
||||
description: An employee's claim on a shift slot — approved or cancelled.
|
||||
resource: backend/src/db/index.ts
|
||||
tags: [data-model, sqlite, shift-planning]
|
||||
timestamp: 2026-08-16T00:00:00Z
|
||||
---
|
||||
|
||||
# shift_signups
|
||||
|
||||
# Schema
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK | autoincrement |
|
||||
| `slot_id` | INTEGER | references [shift_slots](./shift-slots.md) |
|
||||
| `employee_id` | INTEGER | references [employees](./employees.md) |
|
||||
| `status` | TEXT | `approved` \| `cancelled` |
|
||||
| `created_at` | TEXT | ISO 8601 UTC |
|
||||
| `cancelled_at` / `cancelled_by` | TEXT \| NULL | when, and by whom (admin email or the employee's own) |
|
||||
|
||||
Cancelling never deletes the row (`status = 'cancelled'`), so who was ever
|
||||
signed up for a slot stays visible in history.
|
||||
|
||||
# Signup rules
|
||||
|
||||
Enforced in `signupInternal` (`backend/src/services/shiftPlanning.ts`), in
|
||||
this order, identically whether the employee signs themselves up or the
|
||||
admin assigns them:
|
||||
|
||||
1. Slot exists and isn't `closed`.
|
||||
2. Not already approved for this exact slot.
|
||||
3. **No time overlap** with any of the employee's other approved slots,
|
||||
anywhere — not just the same day. Computed from real start/end
|
||||
`Date`s (`slotStart`/`slotEnd`), so a slot whose `end_time <= start_time`
|
||||
correctly counts as ending the next day.
|
||||
4. Slot isn't already at capacity.
|
||||
|
||||
All of this runs synchronously in a single request — better-sqlite3 is
|
||||
synchronous, so there's no `LockService`-style mutex needed the way the
|
||||
source Apps Script version required
|
||||
(`signupEmployeeToSlot_` in `gscript/ShiftPlanningService.js`).
|
||||
|
||||
# Related
|
||||
|
||||
- [shift_slots](./shift-slots.md)
|
||||
- [Shift planning API](/docs/api/shifts-routes.md)
|
||||
51
docs/data-model/shift-slots.md
Normal file
51
docs/data-model/shift-slots.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
type: SQLite Table
|
||||
title: shift_slots
|
||||
description: Bookable shift slots (a date + time range + headcount capacity), generated from a weekly template or created ad hoc by the admin.
|
||||
resource: backend/src/db/index.ts
|
||||
tags: [data-model, sqlite, shift-planning]
|
||||
timestamp: 2026-08-16T00:00:00Z
|
||||
---
|
||||
|
||||
# shift_slots
|
||||
|
||||
# Schema
|
||||
|
||||
| Column | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | INTEGER PK | autoincrement |
|
||||
| `date` | TEXT | `YYYY-MM-DD` |
|
||||
| `start_time` / `end_time` | TEXT | `HH:MM`; `end_time <= start_time` means the slot rolls past midnight |
|
||||
| `capacity` | INTEGER | headcount |
|
||||
| `status` | TEXT | `open` \| `full` \| `closed` — recomputed from capacity vs. approved signups on every change |
|
||||
| `note` | TEXT | free text |
|
||||
| `generated` | INTEGER | 1 if created by the weekly template, 0 if manually added by the admin |
|
||||
| `created_at` / `updated_at` | TEXT | ISO 8601 UTC |
|
||||
|
||||
`UNIQUE (date, start_time, end_time)` — this is what makes template
|
||||
generation idempotent (`INSERT OR IGNORE`).
|
||||
|
||||
# Weekly template
|
||||
|
||||
`ensureSlotsForPeriod` (`backend/src/services/shiftPlanning.ts`) generates a
|
||||
whole calendar month lazily, the first time anyone (employee or admin)
|
||||
requests that period — not on a cron schedule. There's no scheduler in this
|
||||
app, and lazy + idempotent is simpler and just as reliable as the source
|
||||
system's Apps Script monthly trigger
|
||||
(`createShiftPlanningTrigger_` in `gscript/ShiftPlanningService.js`, which
|
||||
could only fire in a coarse hourly window).
|
||||
|
||||
| Day | Slots |
|
||||
|---|---|
|
||||
| Sun–Thu | 18:00–23:00, capacity 1 |
|
||||
| Fri, Sat | 18:00–02:00, capacity 1 **and** 20:00–00:00, capacity 1 |
|
||||
|
||||
The two Friday/Saturday slots deliberately overlap in time — a real
|
||||
scheduling choice from the source bistro (an early shift and a late shift),
|
||||
not a bug. It's also exactly what the collision test in
|
||||
`backend/src/services/shiftPlanning.test.ts` exercises.
|
||||
|
||||
# Related
|
||||
|
||||
- [shift_signups](./shift-signups.md)
|
||||
- [Shift planning API](/docs/api/shifts-routes.md)
|
||||
Reference in New Issue
Block a user