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

20 KiB
Raw Permalink Blame History

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_idoptional 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: <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

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