451 lines
20 KiB
Markdown
451 lines
20 KiB
Markdown
# Plán prací (Work Schedule) Module Design
|
||
|
||
**Date:** 2026-06-05
|
||
**Status:** Approved
|
||
**Module:** Work schedule / Rozpis prací for boha-app-ts
|
||
|
||
---
|
||
|
||
## Overview
|
||
|
||
A new module under the "Docházka" sidebar section that lets a foreman plan
|
||
each employee's work on a per-day basis, weeks or months in advance, and lets
|
||
every employee see what they (and the team) are supposed to be doing on any
|
||
given day.
|
||
|
||
The reference for the UX is `simon/plan_boha.pdf` — a hand-rolled Excel grid
|
||
with employees as columns and dates as rows, each cell holding a free-text
|
||
task description. The module replaces that spreadsheet with a live, audited,
|
||
permission-aware grid in the existing admin app.
|
||
|
||
A plan entry is **what should happen**. It is intentionally separate from
|
||
attendance (which records **what actually happened**). The two are not
|
||
auto-linked.
|
||
|
||
---
|
||
|
||
## Requirements
|
||
|
||
### Core
|
||
|
||
- Plan entries are stored as **date ranges** (e.g. "PLC upgrade VW USA"
|
||
from 8.6. to 26.6.), with an optional **per-day override** for the rare
|
||
case where one day in a range is different (e.g. "Volno po noční").
|
||
- Each entry has:
|
||
- `user_id` — the employee this plan is for.
|
||
- `date_from` / `date_to` — inclusive range.
|
||
- `project_id` — **optional** FK to the existing `projects` table.
|
||
- `category` — enum (see below), drives color-coding and reports.
|
||
- `note` — required free-text task description (max 500 chars).
|
||
- The project dropdown is populated from the existing `projects` table.
|
||
No new "task" or "sub-project" entity — the note is the human-readable
|
||
task description.
|
||
- A cell can be empty (unassigned / not planned). An empty cell is also
|
||
clickable for admins — opens the create-entry modal.
|
||
- A `work_plan_overrides` row covers a single `(user, date)` and takes
|
||
precedence over the range that would otherwise cover that day.
|
||
|
||
### Effective-cell resolution (runtime)
|
||
|
||
To compute the cell for user `U` on date `D`:
|
||
|
||
1. Look up `work_plan_overrides` for `(U, D)`. If present, use it.
|
||
2. Otherwise, look up `work_plan_entries` where `user_id = U` and
|
||
`date_from <= D <= date_to`. If multiple match, return the
|
||
**last-created** one and write a warning to the audit log.
|
||
3. Otherwise, the cell is empty.
|
||
|
||
### Editing rules
|
||
|
||
- **Lock past, free future.** Cells with `shift_date < today` are
|
||
read-only. Admins can override with `?force=1` on the API; the
|
||
override is recorded in the audit log.
|
||
- Today and future are always editable for users with `attendance.manage`.
|
||
- The modal offers three actions when editing a cell inside an existing
|
||
range:
|
||
- "Upravit celý rozsah" — edit the range.
|
||
- "Upravit pouze tento den" — creates an override.
|
||
- "Zrušit přiřazení tohoto dne" — creates an override with
|
||
`category = 'other'` and empty note, leaving the cell empty for
|
||
that day only.
|
||
|
||
### Categories
|
||
|
||
A new enum `plan_category` for color-coding and reports:
|
||
|
||
| Value | Czech label | Cell color hint |
|
||
| ------------- | -------------- | --------------- |
|
||
| `work` | Práce | blue |
|
||
| `preparation` | Příprava | teal |
|
||
| `travel` | Cesta / Montáž | amber |
|
||
| `leave` | Dovolená | green |
|
||
| `sick` | Nemoc | red |
|
||
| `training` | Školení | purple |
|
||
| `other` | Jiné | gray |
|
||
|
||
Plan overrides for leave/sick are **informational only**. They do not
|
||
create or modify `leave_requests` or `attendance` rows. Employees still
|
||
file leave through the existing `Žádosti` flow.
|
||
|
||
### Visibility & permissions
|
||
|
||
- **Single sidebar entry** under Docházka: `Plán prací` → `/attendance/plan`.
|
||
- The sidebar entry is gated by `attendance.manage || attendance.record`
|
||
(mirrors the existing `Žádosti` entry).
|
||
- The page is **one** (`PlanWork.tsx`):
|
||
- If `useAuth().hasPermission('attendance.manage')` → editor mode
|
||
(grid + edit modal + create/edit/delete affordances).
|
||
- Otherwise → view-only mode (same grid, read-only detail modal).
|
||
- All endpoints require `attendance.manage` for writes, and either
|
||
`attendance.manage` or `attendance.record` for reads. Employees
|
||
with `attendance.record` are scoped server-side to their own
|
||
`user_id` for any direct row read; the grid endpoint handles the
|
||
scoping transparently.
|
||
- Admin role bypasses all permission checks (existing behavior).
|
||
|
||
### Grid
|
||
|
||
- Default view is **week** (T23, T24, T25 … matches the PDF's week
|
||
numbering). A toggle at the top switches to **month** view.
|
||
- The grid is `employees × dates`. Columns are ordered by
|
||
role/team first, then alphabetically by name.
|
||
- Columns are users with the `attendance.record` permission (which
|
||
includes admin). A "Aktivní" toggle in the toolbar lets the planner
|
||
hide inactive users.
|
||
- Each cell shows: a colored chip for the category, the project name
|
||
(if any) in bold, and the note underneath. Empty cells are blank.
|
||
- Weekend cells have a subtle yellow background tint to match the
|
||
PDF's "So 6.6. ★" rows.
|
||
- Read-only employees see the same grid, but cells are non-clickable
|
||
for editing. A click opens a read-only detail modal showing project,
|
||
category, note, "Vytvořil: <user> · <date>" (the entry's
|
||
`created_by`), and (if the cell is part of a range) "Patří do
|
||
rozsahu: <date_from> – <date_to>".
|
||
|
||
### Audit
|
||
|
||
Every create/update/delete of `work_plan_entries` and
|
||
`work_plan_overrides` writes a row to `audit_logs` with
|
||
`entity_type = 'work_plan_entry' | 'work_plan_override'` and full
|
||
`old_values` / `new_values` JSON, following the same pattern as
|
||
`attendance` and `projects`. The `?force=1` past-edit override is
|
||
recorded in the description field.
|
||
|
||
### Non-goals (out of scope for v1)
|
||
|
||
- No automatic link to attendance (plan stays independent).
|
||
- No time-slot granularity (full days only).
|
||
- No shift / payroll calculation.
|
||
- No drag-fill on the grid.
|
||
- No "task list" entity — projects and free text only.
|
||
- No notification/email when a plan is changed.
|
||
- No browser tests (the codebase has none; manual verification in dev).
|
||
|
||
---
|
||
|
||
## Architecture
|
||
|
||
### Module placement
|
||
|
||
- New module under the **Docházka** sidebar section.
|
||
- Reuses the existing `attendance.shift_date` concept (date column on
|
||
the `attendance` table) — the planner lives next to the calendar it
|
||
informs.
|
||
- No "administrativa" module exists; the sidebar "Administrativa"
|
||
section is the commercial back-office (offers, orders, invoices,
|
||
projects, customers) and is not the right home for HR-style planning.
|
||
|
||
### File layout
|
||
|
||
```
|
||
src/
|
||
├── routes/admin/plan.ts # Fastify routes
|
||
├── services/plan.service.ts # Business logic
|
||
├── schemas/plan.schema.ts # Zod validation
|
||
└── __tests__/plan.test.ts # Vitest + Supertest tests
|
||
|
||
src/admin/
|
||
├── pages/PlanWork.tsx # The single page (editor or view-only)
|
||
├── hooks/usePlanWork.ts # State, modal, mutations
|
||
├── lib/queries/plan.ts # React Query queryOptions
|
||
└── components/
|
||
├── PlanGrid.tsx # Week/month grid (shared by both modes)
|
||
├── PlanCellModal.tsx # Edit modal (create / edit-range / override)
|
||
├── PlanCellDetailModal.tsx # Read-only detail modal
|
||
├── PlanRangeChips.tsx # Cell content chip with category color
|
||
└── plan.css # Module-specific styles only
|
||
```
|
||
|
||
The new CSS file holds only category color helpers and grid-specific
|
||
overrides. All form/modal/table styling reuses existing classes
|
||
(`.admin-table`, `.admin-table-sticky`, `FormField`, `FormModal`,
|
||
`ConfirmModal`, `AdminDatePicker`, etc.) — no new design tokens.
|
||
|
||
### React Query keys
|
||
|
||
Per the project's "invalidate the full domain" convention:
|
||
|
||
- `['plan', 'grid', { dateFrom, dateTo, view }]`
|
||
- `['plan', 'entries', { userId, dateFrom, dateTo }]`
|
||
- `['plan', 'overrides', { userId, dateFrom, dateTo }]`
|
||
- `['plan', 'users']`
|
||
|
||
A mutation on any plan row invalidates `['plan']` (covers all four
|
||
sub-queries by prefix match).
|
||
|
||
---
|
||
|
||
## Database Schema
|
||
|
||
### New enum
|
||
|
||
```prisma
|
||
enum plan_category {
|
||
work
|
||
preparation
|
||
travel
|
||
leave
|
||
sick
|
||
training
|
||
other
|
||
}
|
||
```
|
||
|
||
### New table: `work_plan_entries`
|
||
|
||
| Column | Type | Notes |
|
||
| ------------ | ------------------------ | ------------------------------------------------------- |
|
||
| `id` | int PK autoincrement | |
|
||
| `user_id` | int FK → `users(id)` | NOT NULL, indexed |
|
||
| `date_from` | Date | NOT NULL — first day of the range |
|
||
| `date_to` | Date | NOT NULL — last day of the range, must be ≥ `date_from` |
|
||
| `project_id` | int? FK → `projects(id)` | nullable — unlinked text entries are allowed |
|
||
| `category` | enum `plan_category` | NOT NULL, default `'work'` |
|
||
| `note` | varchar(500) | NOT NULL — free-text task description |
|
||
| `created_by` | int FK → `users(id)` | who entered it |
|
||
| `created_at` | DateTime | Prisma default `now()` |
|
||
| `updated_at` | DateTime | Prisma default `now()` |
|
||
| `is_deleted` | bool? default false | soft-delete |
|
||
|
||
Indexes:
|
||
|
||
- `(user_id, date_from)`
|
||
- `(user_id, date_to)`
|
||
- `(project_id)`
|
||
- `(date_from, date_to)` — for cross-user queries like
|
||
"what's planned for project X this month?"
|
||
|
||
### New table: `work_plan_overrides`
|
||
|
||
| Column | Type | Notes |
|
||
| ------------ | ------------------------ | ------------------------------------------------- |
|
||
| `id` | int PK autoincrement | |
|
||
| `user_id` | int FK → `users(id)` | NOT NULL, indexed |
|
||
| `shift_date` | Date | NOT NULL — the one day being overridden |
|
||
| `project_id` | int? FK → `projects(id)` | nullable |
|
||
| `category` | enum `plan_category` | NOT NULL — usually `'leave'`, `'sick'`, `'other'` |
|
||
| `note` | varchar(500) | NOT NULL |
|
||
| `created_by` | int FK → `users(id)` | |
|
||
| `created_at` | DateTime | |
|
||
| `updated_at` | DateTime | |
|
||
| `is_deleted` | bool? default false | soft-delete |
|
||
|
||
Indexes:
|
||
|
||
- **Unique** `(user_id, shift_date)` — exactly one non-deleted
|
||
override per user-day. MySQL unique indexes don't ignore
|
||
soft-deleted rows, so the service layer enforces "at most one
|
||
active override per `(user_id, shift_date)`" in application code
|
||
(the database unique constraint exists as a final safety net and
|
||
may be temporarily violated by soft-deleted rows, which is
|
||
acceptable). When restoring a soft-deleted override, the service
|
||
re-validates that no active override exists for the same day.
|
||
- `(shift_date)` — for cross-user "what's overridden today" queries.
|
||
|
||
### Read semantics (recap)
|
||
|
||
For user `U` and date `D`, the "effective" cell is:
|
||
|
||
1. Override `(U, D)` if it exists and is not soft-deleted.
|
||
2. Otherwise the latest `work_plan_entry` whose range covers `D`
|
||
for user `U` and is not soft-deleted. If multiple match, pick
|
||
the latest by `created_at` and write a warning to `audit_logs`.
|
||
3. Otherwise, empty.
|
||
|
||
---
|
||
|
||
## API Surface
|
||
|
||
REST endpoints under `/api/admin/plan`. All routes use the existing
|
||
Zod-validated, `requirePermission`, `success`/`error` helpers, and
|
||
`logAudit` pattern. Czech error messages, following project convention.
|
||
|
||
| Method | Path | Permission | Body / Query | Returns |
|
||
| ------ | --------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------- |
|
||
| GET | `/plan/grid` | `attendance.manage` OR `attendance.record` | `?date_from=&date_to=&view=week\|month` (range ≤ 92 days) | `{ days, users, cells }` — effective cells ready to render |
|
||
| GET | `/plan/entries` | `attendance.manage` OR `attendance.record` (employee scoped to self) | `?user_id=&date_from=&date_to=` | raw `work_plan_entries` rows in range |
|
||
| GET | `/plan/overrides` | `attendance.manage` OR `attendance.record` (employee scoped to self) | `?user_id=&date_from=&date_to=` | raw `work_plan_overrides` rows in range |
|
||
| GET | `/plan/users` | `attendance.manage` OR `attendance.record` | — | users with `attendance.record`, ordered by role/team then name |
|
||
| POST | `/plan/entries` | `attendance.manage` | `{ user_id, date_from, date_to, project_id?, category, note }` | created entry |
|
||
| PATCH | `/plan/entries/:id` | `attendance.manage` | partial of create body | updated entry |
|
||
| DELETE | `/plan/entries/:id` | `attendance.manage` | `?force=1` to bypass past-date lock | `{ ok: true }` (soft-delete) |
|
||
| POST | `/plan/overrides` | `attendance.manage` | `{ user_id, shift_date, project_id?, category, note }` | created override |
|
||
| PATCH | `/plan/overrides/:id` | `attendance.manage` | partial of create body | updated override |
|
||
| DELETE | `/plan/overrides/:id` | `attendance.manage` | `?force=1` | `{ ok: true }` (soft-delete) |
|
||
| GET | `/plan/audit` | `settings.audit` | `?entity_type=&entity_id=` | audit log entries for a row |
|
||
|
||
### Past-date lock
|
||
|
||
Server-enforced. POST/PATCH/DELETE with any `shift_date` / `date_from`
|
||
in the past returns 403 with a Czech error message:
|
||
|
||
```
|
||
Nelze upravovat plán pro datum v minulosti. Pro nouzovou opravu použijte ?force=1.
|
||
```
|
||
|
||
With `?force=1` and `attendance.manage`, the operation succeeds and
|
||
the audit log records `description = 'force-edit past date'`.
|
||
|
||
### Employee scoping
|
||
|
||
`GET /plan/entries` and `GET /plan/overrides` accept `?user_id=`. For
|
||
a user with only `attendance.record`, the service silently overrides
|
||
the query to scope to `user_id = auth.user.id`. The grid endpoint
|
||
returns the same data either way (employees see the full grid in
|
||
view-only mode).
|
||
|
||
---
|
||
|
||
## Frontend Detail
|
||
|
||
### `PlanWork.tsx`
|
||
|
||
- Mounts and calls `useAuth()`. If `hasPermission('attendance.manage')`
|
||
→ editor state. Otherwise → view-only state.
|
||
- Renders the toolbar (view toggle, date navigator, active-toggle,
|
||
print) and `PlanGrid`.
|
||
- Owns the modal state machine for `PlanCellModal` (editor) or
|
||
`PlanCellDetailModal` (view).
|
||
|
||
### `PlanGrid.tsx`
|
||
|
||
- Pure presentational. Takes `users`, `days`, `cells` as props.
|
||
- Renders a `<table>` with sticky first column (date) and sticky
|
||
header (employee names).
|
||
- Each cell is a `<button>` (admin) or `<div>` (view-only) with the
|
||
colored chip + note.
|
||
- Calls `onCellClick(userId, date)` for the parent to open the modal.
|
||
- Reuses `.admin-table` and `.admin-table-sticky` from existing
|
||
stylesheets.
|
||
|
||
### `PlanCellModal.tsx` (editor)
|
||
|
||
Three modes, decided by what the user clicked:
|
||
|
||
- **Create** — empty cell. Fields: `user_id` (locked to clicked user,
|
||
can be switched), `date_from` (default = clicked date), `date_to`
|
||
(default = clicked date; "Jeden den" / "Rozsah dnů" toggle),
|
||
`project_id` (dropdown, populated from `projects`, nullable),
|
||
`category` (select), `note` (textarea, required).
|
||
- **Edit range** — cell is part of an existing range. Loads the
|
||
full range into the form. "Upravit celý rozsah" button is the
|
||
primary action.
|
||
- **Edit day in range** — cell is part of a range. Three buttons:
|
||
"Upravit celý rozsah" / "Upravit pouze tento den" /
|
||
"Zrušit přiřazení tohoto dne".
|
||
- **Edit single-day override** — cell is itself a `work_plan_overrides`
|
||
row. Standard edit form.
|
||
- Bottom: delete button (with `ConfirmModal`).
|
||
|
||
### `PlanCellDetailModal.tsx` (view-only)
|
||
|
||
Read-only. Shows project, category, note, "Vytvořil: <full name of
|
||
`created_by`> · <`created_at` in local Czech time>", and (if the
|
||
cell is part of a range) "Patří do rozsahu: <date_from> – <date_to>".
|
||
For override cells, the "Vytvořil" line refers to the override's own
|
||
`created_by` (not the parent range's). No edit, no delete.
|
||
|
||
### `usePlanWork.ts`
|
||
|
||
Owns:
|
||
|
||
- `view: 'week' | 'month'`, `anchorDate`, `filterActive`
|
||
- `effectiveCells` from `['plan', 'grid']` query
|
||
- `entries` and `overrides` for the cell being edited
|
||
- `modalState: { mode, userId, date, entryId?, overrideId? }`
|
||
- `createEntry`, `updateEntry`, `deleteEntry`,
|
||
`createOverride`, `updateOverride`, `deleteOverride` —
|
||
React Query mutations that invalidate `['plan']`.
|
||
|
||
---
|
||
|
||
## Migration
|
||
|
||
Single new migration:
|
||
|
||
`prisma/migrations/<timestamp>_add_work_plan/`
|
||
|
||
- `migration.sql`:
|
||
1. `CREATE TABLE work_plan_entries (...)` with columns and indexes.
|
||
2. `CREATE TABLE work_plan_overrides (...)` with columns and indexes.
|
||
3. `CREATE TYPE plan_category` (or in-table enum, per Prisma's
|
||
MySQL output).
|
||
|
||
No data migration. No `seed.ts` change. No new permission keys.
|
||
|
||
---
|
||
|
||
## Testing
|
||
|
||
`src/__tests__/plan.test.ts` — Vitest + Supertest against the real
|
||
test database (`.env.test`):
|
||
|
||
1. **Effective-cell resolution** — range + override returns the
|
||
override for that day, the range for the other days, `null`
|
||
for uncovered days.
|
||
2. **Past-date lock** — POST with `date_from < today` returns 403
|
||
for users without `attendance.manage`; allowed with `?force=1`
|
||
for users with `attendance.manage`; audit log records the force.
|
||
3. **Employee self-view scoping** — employee with `attendance.record`
|
||
calling `GET /plan/entries?user_id=<other>` is scoped to self.
|
||
4. **Soft-delete + is_deleted** — deleted rows are excluded from
|
||
`/plan/grid` but kept in `/plan/audit`.
|
||
5. **Project FK nullability** — entries with `project_id = null`
|
||
and `category = 'leave'` are valid and render correctly.
|
||
6. **Range overlap** — two ranges for the same `(user, day)` does
|
||
not error; the latest one wins; a warning is logged.
|
||
7. **Audit log on every write** — POST/PATCH/DELETE each insert
|
||
exactly one `audit_logs` row with `entity_type`,
|
||
`old_values`, `new_values`.
|
||
|
||
---
|
||
|
||
## Release Plan
|
||
|
||
1. Bump `package.json` version (minor, since new module).
|
||
2. `npm run build` — confirm clean compile.
|
||
3. Commit + push to Gitea.
|
||
4. Tarball + deploy to production via the existing release script
|
||
(per `CLAUDE.md` "Release Process" section).
|
||
5. `npx prisma migrate deploy` on prod.
|
||
6. `pm2 restart app-ts --update-env`.
|
||
7. Smoke test: log in, open Docházka → Plán prací, add a one-day
|
||
entry for yourself, reload, confirm it shows.
|
||
|
||
The deployment follows the same pattern as the warehouse module
|
||
release (see commit `5233db2` and the `2026-05-29-warehouse-module`
|
||
spec for reference).
|
||
|
||
---
|
||
|
||
## Open items / future work
|
||
|
||
- Month view polish if the toggle proves popular.
|
||
- Drag-fill on the grid (out of scope for v1).
|
||
- Optional tie-in to attendance: pre-fill `notes` on
|
||
`AttendanceCreate` from today's plan (read-only hint). Not
|
||
pursued in v1.
|
||
- "Patří do rozsahu" link in the detail modal: clickable
|
||
jump to the range's full detail.
|
||
- Per-team filtering (if a "team" concept is added to `users` later).
|