Files
app/docs/superpowers/specs/2026-06-08-bulk-plan-creation-design.md
BOHA c6a146d57a docs(plan): spec for bulk plan creation (Feature B)
Assign one project/category to many employees over a date range in one
action. Modal mirrors single-create + employee checklist + "include
weekends" toggle. Stores range entries grouped by contiguous eligible
days per employee (weekends-off → per-work-week chunks); per-day cap
skips days already at 3. POST /plan/entries/bulk + bulkCreateEntries
(reuses createEntry); summary audit + result counts. No migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:49:55 +02:00

189 lines
8.0 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.
# Design — Bulk plan creation (Feature B)
**Date:** 2026-06-08
**Status:** Approved (brainstorming) — ready for implementation plan
**Builds on:** Feature A (multi-record plan cells, max 3 per cell), shipped in v2.0.5. Spec: `docs/superpowers/specs/2026-06-08-multi-record-plan-cells-design.md`.
---
## Goal
Let a planner assign one project/category to **many employees over a date range
in a single action**, instead of creating each person's plan record one by one.
A "Hromadné přiřazení" button on **/plan-work** opens a modal that mirrors the
existing single-record create form, plus an employee checklist and an "include
weekends" toggle.
## Decisions (locked in brainstorming)
- **Modal mirrors single-create** (`Datum od` + "Více dní" → `Datum do`, Projekt,
Kategorie, Text poznámky) **plus** a multi-select employee checklist and a
**"Zahrnout víkendy"** checkbox.
- **Storage = range entries** (`work_plan_entries`), one per contiguous run of
eligible days per employee — the same row shape the single-create form produces.
- **Weekends:** default off (MonFri only). Only Sat/Sun are excluded — **no
separate holiday handling**.
- **Cap:** governed by Feature A's `MAX_RECORDS_PER_CELL = 3`. A day already at
the cap for an employee is **skipped** (never overwritten), which also splits
that employee's range around the skipped day.
- **Past dates blocked** (no `force` exposed in the bulk UI).
- **Category** picker defaults to **Práce**; **Project** is optional ("bez
projektu"); **note** optional, applied to all created entries.
## Non-goals / out of scope
- No DB migration (reuses `work_plan_entries` and Feature A's cap logic).
- No overwrite/replace of existing records (purely additive under the cap).
- No holiday/`svátky` exclusion (only weekends).
- No bulk creation of **overrides** — bulk always creates **entries** (the normal
layer); single-day exceptions remain the per-cell day-panel's job.
---
## Storage & weekend behaviour
For each selected employee, the service computes the **eligible days** in
`[date_from, date_to]` and groups **contiguous** eligible days into range
entries.
A day `D` is **eligible** for employee `U` when all hold:
1. `D` is within `[date_from, date_to]` (inclusive).
2. `include_weekends` is true, **or** `D` is a weekday (MonFri).
3. `U` has fewer than `MAX_RECORDS_PER_CELL` active entries covering `D` (under
the cap).
Contiguous runs of eligible days become one range entry each
(`date_from = run start`, `date_to = run end`). Consequences:
- **Víkendy ON, no cap conflicts:** one continuous range per employee — identical
to creating a single multi-day record for everyone.
- **Víkendy OFF:** one range per work-week (MonFri chunks), because a weekend day
breaks the run.
- **A cap-full day** breaks the run too, so the range splits around it (and that
`(employee, day)` is counted as skipped).
In the grid these are ordinary range entries — they show the "součást rozsahu"
badge and open the range-vs-this-day chooser on click, exactly like
manually-created ranges.
---
## Backend
### Route — `POST /api/admin/plan/entries/bulk`
- Guard: `requirePermission("attendance.manage")` (same as single entry create).
- Body (validated by a new Zod schema `BulkPlanEntrySchema` in
`src/schemas/plan.schema.ts`):
```
{
user_ids: number[] // ≥1
date_from: string // YYYY-MM-DD
date_to: string // YYYY-MM-DD (≥ date_from)
include_weekends: boolean
project_id?: number | null
category: string // default applied client-side; validated server-side
note?: string // default ""
}
```
- On success: one **summary audit** row (`logAudit`, action `create`, entityType
`work_plan_entry`, description e.g. `Hromadné přiřazení: <kategorie> <projekt> —
N zaměstnanců, <from><to> (<created_days> dní)`), then `success(reply,
summary, 201, "Hromadné přiřazení dokončeno")`.
### Service — `bulkCreateEntries(input, actorUserId)` in `src/services/plan.service.ts`
Up-front validation (fail fast, nothing created):
- `user_ids.length >= 1` (else `{ error, status: 400 }`).
- `date_to >= date_from` (else 400).
- `assertNotPastDate(date_from, false)` and `assertNotPastDate(date_to, false)`
(bulk blocks past dates; 403).
- `assertActiveCategory(category)` (400 if inactive/unknown).
Then, per employee:
- Load the employee's active entries overlapping `[date_from, date_to]` once;
compute per-day coverage counts.
- Walk the days, mark eligible ones (weekday filter + under-cap), group contiguous
runs.
- For each run, create the range by **reusing `createEntry`**
(`{ user_id, date_from: runStart, date_to: runEnd, project_id, category, note }`,
`actorUserId`, `force: false`). Reusing `createEntry` keeps cap / past-date /
category validation DRY; its cap re-check passes because every day in the run is
already under the cap. A `createEntry` that nonetheless returns `{ error }` (rare
race) is counted as skipped, not fatal.
Aggregate and return:
```
{
created_entries: number // range rows created
created_days: number // (employee, day) records created
skipped_days: number // weekday-eligible (employee, day) slots skipped at the cap
users: number // employees processed
}
```
`skipped_days` counts only days that passed the weekday filter but were at the
cap — weekend days (when excluded) are out of scope, not "skipped".
Best-effort across employees/segments (no cross-entry transaction): up-front
validation already rejects bad input, and the cap-skip is by design. Partial
results are reported in the summary.
---
## Frontend
### Modal — `src/admin/components/BulkPlanModal.tsx`
Mirrors the single-create `EditForm` fields + bulk-attendance's employee picker
(`BulkAttendanceModal.tsx` is the pattern to follow):
- `Datum od` (DateField) + "Více dní" CheckboxField → `Datum do` (DateField).
- **Zaměstnanci** — scrollable `CheckboxField` list (source: the plan users
already loaded for the grid, `/plan/users`), with a "Vybrat vše" / "Odznačit
vše" toggle and a "Vybráno X z Y" caption; submit disabled when none selected.
- **Zahrnout víkendy** CheckboxField (default off).
- **Projekt** Select (optional — "— bez projektu —"), **Kategorie** Select
(active categories, default `work`/Práce), **Text poznámky** TextField (optional).
- A short helper line, e.g. _"Vytvoří záznamy pro vybrané dny u zvolených
zaměstnanců. Dny nad limit 3 záznamů se přeskočí."_
### Wiring — `src/admin/pages/PlanWork.tsx`
- A "Hromadné přiřazení" Button in the toolbar (next to "Správa kategorií"),
rendered only when `canEdit` (`attendance.manage`).
- A `bulkCreate` mutation (React Query) → `POST /api/admin/plan/entries/bulk`;
on success show an alert summarising the returned counts (e.g. _"Vytvořeno 18
záznamů pro 5 zaměstnanců; 4 dny přeskočeny (limit 3 na den)."_), close the
modal, and `invalidate: ["plan"]` so the grid refreshes.
---
## Testing (`src/__tests__/plan.test.ts` or a new `bulkPlan.test.ts`)
Backend service tests against the real test DB:
- **Weekday filter:** weekends-off over a MonSun span creates only MonFri days
(and as the expected number of per-work-week ranges); weekends-on creates one
continuous range.
- **Segmentation:** a cap-full day in the middle splits the range into two; the
skipped day is counted in `skipped_days`.
- **Cap skip:** an employee already at 3 entries on some days gets those days
skipped; the summary reflects `created_days` vs `skipped_days`.
- **Multi-employee aggregation:** `users`, `created_entries`, `created_days` sum
correctly across the selected employees.
- **Validation:** empty `user_ids` → 400; past `date_from` → 403; inactive
category → 400; `date_to < date_from` → 400.
Gates: `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`.
## Rollout
Ships as **v2.0.6** via the standard process (commit → tag → Gitea → tarball →
prod deploy → health check). No migration step. The grid refreshes via
`["plan"]` invalidation.