# 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).
