# 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 (Mon–Fri 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 (Mon–Fri). 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 (Mon–Fri 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í: — N zaměstnanců, ( 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 Mon–Sun span creates only Mon–Fri 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.