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

8.0 KiB
Raw Blame History

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.