From c6a146d57ad43eb3034aab428bc5f3e2010dd27f Mon Sep 17 00:00:00 2001 From: BOHA Date: Mon, 8 Jun 2026 11:49:55 +0200 Subject: [PATCH] docs(plan): spec for bulk plan creation (Feature B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../2026-06-08-bulk-plan-creation-design.md | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-bulk-plan-creation-design.md diff --git a/docs/superpowers/specs/2026-06-08-bulk-plan-creation-design.md b/docs/superpowers/specs/2026-06-08-bulk-plan-creation-design.md new file mode 100644 index 0000000..d44ee9c --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-bulk-plan-creation-design.md @@ -0,0 +1,188 @@ +# 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.