diff --git a/docs/superpowers/specs/2026-06-05-plan-praci-design.md b/docs/superpowers/specs/2026-06-05-plan-praci-design.md new file mode 100644 index 0000000..b8addbd --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-plan-praci-design.md @@ -0,0 +1,450 @@ +# 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 `