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>
189 lines
8.0 KiB
Markdown
189 lines
8.0 KiB
Markdown
# 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.
|