20 KiB
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 existingprojectstable.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
projectstable. 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_overridesrow 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:
- Look up
work_plan_overridesfor(U, D). If present, use it. - Otherwise, look up
work_plan_entrieswhereuser_id = Uanddate_from <= D <= date_to. If multiple match, return the last-created one and write a warning to the audit log. - Otherwise, the cell is empty.
Editing rules
- Lock past, free future. Cells with
shift_date < todayare read-only. Admins can override with?force=1on 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Žádostientry). - 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).
- If
- All endpoints require
attendance.managefor writes, and eitherattendance.manageorattendance.recordfor reads. Employees withattendance.recordare scoped server-side to their ownuser_idfor 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.recordpermission (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_dateconcept (date column on theattendancetable) — 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:
- Override
(U, D)if it exists and is not soft-deleted. - Otherwise the latest
work_plan_entrywhose range coversDfor userUand is not soft-deleted. If multiple match, pick the latest bycreated_atand write a warning toaudit_logs. - 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(). IfhasPermission('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) orPlanCellDetailModal(view).
PlanGrid.tsx
- Pure presentational. Takes
users,days,cellsas 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-tableand.admin-table-stickyfrom 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 fromprojects, 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_overridesrow. 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,filterActiveeffectiveCellsfrom['plan', 'grid']queryentriesandoverridesfor the cell being editedmodalState: { 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:CREATE TABLE work_plan_entries (...)with columns and indexes.CREATE TABLE work_plan_overrides (...)with columns and indexes.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):
- Effective-cell resolution — range + override returns the
override for that day, the range for the other days,
nullfor uncovered days. - Past-date lock — POST with
date_from < todayreturns 403 for users withoutattendance.manage; allowed with?force=1for users withattendance.manage; audit log records the force. - Employee self-view scoping — employee with
attendance.recordcallingGET /plan/entries?user_id=<other>is scoped to self. - Soft-delete + is_deleted — deleted rows are excluded from
/plan/gridbut kept in/plan/audit. - Project FK nullability — entries with
project_id = nullandcategory = 'leave'are valid and render correctly. - Range overlap — two ranges for the same
(user, day)does not error; the latest one wins; a warning is logged. - Audit log on every write — POST/PATCH/DELETE each insert
exactly one
audit_logsrow withentity_type,old_values,new_values.
Release Plan
- Bump
package.jsonversion (minor, since new module). npm run build— confirm clean compile.- Commit + push to Gitea.
- Tarball + deploy to production via the existing release script
(per
CLAUDE.md"Release Process" section). npx prisma migrate deployon prod.pm2 restart app-ts --update-env.- 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
notesonAttendanceCreatefrom 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
userslater).