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>
8.0 KiB
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
forceexposed 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_entriesand Feature A's cap logic). - No overwrite/replace of existing records (purely additive under the cap).
- No holiday/
svátkyexclusion (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:
Dis within[date_from, date_to](inclusive).include_weekendsis true, orDis a weekday (Mon–Fri).Uhas fewer thanMAX_RECORDS_PER_CELLactive entries coveringD(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
BulkPlanEntrySchemainsrc/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, actioncreate, entityTypework_plan_entry, description e.g.Hromadné přiřazení: <kategorie> <projekt> — N zaměstnanců, <from>–<to> (<created_days> dní)), thensuccess(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)andassertNotPastDate(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). ReusingcreateEntrykeeps cap / past-date / category validation DRY; its cap re-check passes because every day in the run is already under the cap. AcreateEntrythat 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
CheckboxFieldlist (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
bulkCreatemutation (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, andinvalidate: ["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_daysvsskipped_days. - Multi-employee aggregation:
users,created_entries,created_dayssum correctly across the selected employees. - Validation: empty
user_ids→ 400; pastdate_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.