# 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í: <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 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.
