# 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: · " (the entry's `created_by`), and (if the cell is part of a range) "Patří do rozsahu: ". ### 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 `` with sticky first column (date) and sticky header (employee names). - Each cell is a `