Files
app/docs/superpowers/specs/2026-06-05-plan-praci-design.md

451 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).