Compare commits

..

6 Commits

Author SHA1 Message Date
BOHA
cce2c9cfaa chore(release): v2.0.6 — bulk plan creation
Assign one project/category to many employees over a date range in one
action. "Hromadné přiřazení" on /plan-work: employee checklist +
include-weekends toggle, mirrors the single-create form. Creates range
entries grouped by contiguous eligible days per employee (weekends-off →
per-work-week chunks); per-day cap skips days already at 3; 92-day range
cap. Reuses createEntry; summary audit + result counts. No migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:48:09 +02:00
BOHA
2610301258 harden(plan): 92-day range cap + dedup user_ids on bulk create 2026-06-08 12:17:51 +02:00
BOHA
13d1fc2be3 feat(plan): bulk assignment modal on /plan-work
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 12:11:20 +02:00
BOHA
95ca258718 feat(plan): bulk entry creation endpoint + service 2026-06-08 12:03:47 +02:00
BOHA
6db87bf4ae docs(plan): implementation plan for bulk plan creation (v2.0.6)
Three tasks: (1) backend bulk endpoint + bulkCreateEntries service
(reuses createEntry; weekday filter + contiguous-run segmentation +
per-day cap skip) with TDD tests, (2) BulkPlanModal + PlanWork wiring
+ usePlanWork bulkCreate mutation, (3) v2.0.6 release. No migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:54:44 +02:00
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
10 changed files with 1795 additions and 1 deletions

View File

@@ -0,0 +1,891 @@
# Bulk Plan Creation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Assign one project/category to many employees over a date range in a single action ("Hromadné přiřazení" on /plan-work).
**Architecture:** A new `POST /api/admin/plan/entries/bulk` endpoint drives a `bulkCreateEntries` service that, per employee, computes the eligible days in the range (weekday filter + Feature A's under-cap rule), groups contiguous eligible days into runs, and creates one range `work_plan_entries` row per run by **reusing `createEntry`**. The frontend adds a presentational `BulkPlanModal` (mirroring the single-create form + the existing bulk-attendance employee picker) wired from `PlanWork`, with a summary alert and `["plan"]` invalidation. No DB migration.
**Tech Stack:** Fastify 5 + Prisma (MySQL), Zod 4, React 18 + MUI v7, TanStack Query, Vitest (server-side, real test DB). Spec: `docs/superpowers/specs/2026-06-08-bulk-plan-creation-design.md`. Builds on Feature A (multi-record plan cells, v2.0.5).
**Gates:** `npx tsc -b --noEmit`, `npm run build`, `npx vitest run`. The frontend has no component-test harness (server-side tests only), so Task 2 gates on `tsc -b` + `build`.
---
## File Structure
**Backend**
- `src/schemas/plan.schema.ts` — add `BulkPlanEntrySchema`.
- `src/services/plan.service.ts` — add `bulkCreateEntries(...)` (reuses `createEntry`, `assertNotPastDate`, `assertActiveCategory`, `toDateOnly`, `MAX_RECORDS_PER_CELL`).
- `src/routes/admin/plan.ts` — add `POST /entries/bulk`.
**Frontend**
- `src/admin/components/BulkPlanModal.tsx` — new presentational modal.
- `src/admin/hooks/usePlanWork.ts` — add a `bulkCreate` mutation.
- `src/admin/pages/PlanWork.tsx` — toolbar button + bulk form state + toggles + submit wiring.
**Tests**
- `src/__tests__/plan.test.ts``bulkCreateEntries` service tests.
---
## Task 1: Backend — bulk endpoint + service
**Files:**
- Modify: `src/schemas/plan.schema.ts`
- Modify: `src/services/plan.service.ts`
- Modify: `src/routes/admin/plan.ts`
- Test: `src/__tests__/plan.test.ts`
- [ ] **Step 1: Write the failing service tests**
Add this block to the end of `src/__tests__/plan.test.ts` (before the final HTTP describe block is fine; anywhere at top level). It imports `bulkCreateEntries` — add it to the existing `import { ... } from "../services/plan.service"` list at the top of the file.
```ts
// ---------------------------------------------------------------------------
// bulkCreateEntries
// ---------------------------------------------------------------------------
describe("plan.service.bulkCreateEntries", () => {
it("creates one continuous range per employee when weekends are included", async () => {
// 2096-06-01 is a Friday; 2096-06-03 is a Sunday. include_weekends → one
// range 06-01..06-03 covering all 3 days.
const res = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-06-01",
date_to: "2096-06-03",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}bulk-wknd`,
},
adminUserId,
);
expect("data" in res).toBe(true);
if (!("data" in res)) return;
expect(res.data.created_entries).toBe(1);
expect(res.data.created_days).toBe(3);
expect(res.data.skipped_days).toBe(0);
expect(res.data.users).toBe(1);
});
it("splits into per-work-week ranges and skips weekends when excluded", async () => {
// 2096-06-01 (Fri) .. 2096-06-05 (Tue): Fri | Sat Sun excluded | Mon Tue.
// Two runs: [06-01] and [06-04..06-05] → 2 entries, 3 days, 0 skipped.
const res = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-06-01",
date_to: "2096-06-05",
include_weekends: false,
project_id: null,
category: "work",
note: `${N}bulk-week`,
},
adminUserId,
);
expect("data" in res).toBe(true);
if (!("data" in res)) return;
expect(res.data.created_entries).toBe(2);
expect(res.data.created_days).toBe(3);
expect(res.data.skipped_days).toBe(0);
});
it("skips days already at the cap and splits the range around them", async () => {
// Pre-fill 2096-07-02 to the cap (3 entries) for the user. A weekends-on
// bulk over 07-01..07-03 then creates 07-01 and 07-03 (two ranges), skips
// 07-02. 2096-07-01..03 are Sun/Mon/Tue — all included with weekends on.
for (let i = 0; i < 3; i++) {
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
date_from: new Date("2096-07-02"),
date_to: new Date("2096-07-02"),
category: "work",
note: `${N}cap${i}`,
created_by: adminUserId,
},
});
}
const res = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-07-01",
date_to: "2096-07-03",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}bulk-cap`,
},
adminUserId,
);
expect("data" in res).toBe(true);
if (!("data" in res)) return;
expect(res.data.created_entries).toBe(2);
expect(res.data.created_days).toBe(2);
expect(res.data.skipped_days).toBe(1);
});
it("aggregates across multiple employees", async () => {
const res = await bulkCreateEntries(
{
user_ids: [adminUserId, noPermUserId],
date_from: "2096-08-03", // Friday
date_to: "2096-08-03",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}bulk-multi`,
},
adminUserId,
);
expect("data" in res).toBe(true);
if (!("data" in res)) return;
expect(res.data.users).toBe(2);
expect(res.data.created_entries).toBe(2);
expect(res.data.created_days).toBe(2);
});
it("rejects empty user_ids, past dates, bad range, and inactive category", async () => {
const empty = await bulkCreateEntries(
{
user_ids: [],
date_from: "2096-06-01",
date_to: "2096-06-01",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}x`,
},
adminUserId,
);
expect("error" in empty && empty.status).toBe(400);
const past = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2000-01-01",
date_to: "2000-01-02",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}x`,
},
adminUserId,
);
expect("error" in past && past.status).toBe(403);
const badRange = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-06-10",
date_to: "2096-06-01",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}x`,
},
adminUserId,
);
expect("error" in badRange && badRange.status).toBe(400);
const badCat = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-06-01",
date_to: "2096-06-01",
include_weekends: true,
project_id: null,
category: "__nope__",
note: `${N}x`,
},
adminUserId,
);
expect("error" in badCat && badCat.status).toBe(400);
});
});
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `npx vitest run src/__tests__/plan.test.ts`
Expected: FAIL — `bulkCreateEntries` is not exported / not a function.
- [ ] **Step 3: Implement `bulkCreateEntries` in `plan.service.ts`**
Add at the end of `src/services/plan.service.ts` (after `listOverrides`):
```ts
// ---------------------------------------------------------------------------
// Bulk entry creation
// ---------------------------------------------------------------------------
/**
* Bulk-assign a project/category to many employees over a date range. For each
* employee, walk the range, keep the days that pass the weekday filter
* (weekends excluded unless include_weekends) AND are under the per-cell cap,
* group contiguous kept days into runs, and create one range entry per run by
* reusing createEntry (so cap / past-date / category validation stays DRY).
*
* Days that pass the weekday filter but are already at the cap are counted in
* skipped_days (and split the range). Weekend days, when excluded, are out of
* scope and are NOT counted as skipped.
*/
export async function bulkCreateEntries(
input: {
user_ids: number[];
date_from: string;
date_to: string;
include_weekends: boolean;
project_id?: number | null;
category: string;
note: string;
},
actorUserId: number,
): Promise<
Result<{
created_entries: number;
created_days: number;
skipped_days: number;
users: number;
}>
> {
if (input.user_ids.length === 0) {
return { error: "Vyberte alespoň jednoho zaměstnance", status: 400 };
}
if (input.date_to < input.date_from) {
return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
}
const lockFrom = assertNotPastDate(input.date_from, false);
if (lockFrom) return lockFrom;
const lockTo = assertNotPastDate(input.date_to, false);
if (lockTo) return lockTo;
const catErr = await assertActiveCategory(input.category);
if (catErr) return catErr;
const from = toDateOnly(input.date_from);
const to = toDateOnly(input.date_to);
// The day list for the whole range (UTC midnights), built once.
const days: Date[] = [];
for (let d = new Date(from); d <= to; d.setUTCDate(d.getUTCDate() + 1)) {
days.push(new Date(d));
}
let createdEntries = 0;
let createdDays = 0;
let skippedDays = 0;
for (const userId of input.user_ids) {
// Per-day cap counts from this user's existing active entries.
const existing = await prisma.work_plan_entries.findMany({
where: {
user_id: userId,
is_deleted: false,
date_from: { lte: to },
date_to: { gte: from },
},
select: { date_from: true, date_to: true },
});
const countFor = (day: Date): number =>
existing.filter((e) => e.date_from <= day && e.date_to >= day).length;
// Eligibility per day (weekday filter + under-cap). Tally cap skips.
const eligible: boolean[] = [];
for (const day of days) {
const dow = day.getUTCDay(); // 0 = Sun, 6 = Sat
const isWeekend = dow === 0 || dow === 6;
if (isWeekend && !input.include_weekends) {
eligible.push(false);
continue;
}
if (countFor(day) >= MAX_RECORDS_PER_CELL) {
skippedDays++;
eligible.push(false);
continue;
}
eligible.push(true);
}
// Group contiguous eligible days into runs; create one range per run.
let runStart = -1;
for (let i = 0; i <= days.length; i++) {
const ok = i < days.length && eligible[i];
if (ok && runStart === -1) {
runStart = i;
} else if (!ok && runStart !== -1) {
const segFrom = days[runStart].toISOString().slice(0, 10);
const segTo = days[i - 1].toISOString().slice(0, 10);
const runDays = i - runStart;
const res = await createEntry(
{
user_id: userId,
date_from: segFrom,
date_to: segTo,
project_id: input.project_id ?? null,
category: input.category,
note: input.note,
},
actorUserId,
false,
);
if ("data" in res) {
createdEntries++;
createdDays += runDays;
} else {
// Rare race (e.g. the cap filled concurrently). Treat as skipped.
skippedDays += runDays;
}
runStart = -1;
}
}
}
return {
data: {
created_entries: createdEntries,
created_days: createdDays,
skipped_days: skippedDays,
users: input.user_ids.length,
},
oldData: null,
};
}
```
- [ ] **Step 4: Run the service tests to verify they pass**
Run: `npx vitest run src/__tests__/plan.test.ts`
Expected: PASS (including the 5 new bulk tests).
- [ ] **Step 5: Add `BulkPlanEntrySchema` to `plan.schema.ts`**
Append to `src/schemas/plan.schema.ts`:
```ts
export const BulkPlanEntrySchema = z
.object({
user_ids: z
.array(intFromForm)
.min(1, "Vyberte alespoň jednoho zaměstnance"),
date_from: isoDate,
date_to: isoDate,
include_weekends: z
.union([z.boolean(), z.string()])
.transform((v) => v === true || v === "true" || v === "1")
.default(false),
project_id: z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null ? null : Number(v)))
.nullish(),
category: planCategoryEnum.default("work"),
note: z.string().max(500, "Maximálně 500 znaků").default(""),
})
.refine((v) => v.date_to >= v.date_from, {
message: "Datum do musí být stejné nebo po datumu od",
path: ["date_to"],
});
```
- [ ] **Step 6: Add the `POST /entries/bulk` route**
In `src/routes/admin/plan.ts`: add `bulkCreateEntries` to the existing import from `../../services/plan.service` and `BulkPlanEntrySchema` to the import from `../../schemas/plan.schema`. Then add this route immediately after the `POST /entries` handler (after its closing `);`, before `PATCH /entries/:id`):
```ts
// --- POST /plan/entries/bulk ---
app.post(
"/entries/bulk",
{ preHandler: requirePermission("attendance.manage") },
async (request: FastifyRequest, reply: FastifyReply) => {
const body = parseBody(BulkPlanEntrySchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
const result = await bulkCreateEntries(
body.data!,
request.authData!.userId,
);
if ("error" in result) return error(reply, result.error, result.status);
await logAudit({
request,
authData: request.authData,
action: "create",
entityType: "work_plan_entry",
description: `Hromadné přiřazení: ${body.data!.category}${result.data.users} zaměstnanců, ${body.data!.date_from}${body.data!.date_to} (${result.data.created_days} dní)`,
});
return success(reply, result.data, 201, "Hromadné přiřazení dokončeno");
},
);
```
- [ ] **Step 7: Add an HTTP route test**
Add to the `describe("HTTP /api/admin/plan", ...)` block in `src/__tests__/plan.test.ts`:
```ts
it("POST /entries/bulk requires attendance.manage", async () => {
const res = await authPost("/api/admin/plan/entries/bulk", noPermToken, {
user_ids: [adminUserId],
date_from: "2095-06-01",
date_to: "2095-06-01",
include_weekends: true,
category: "work",
note: `${N}bulk-forbidden`,
});
expect(res.statusCode).toBe(403);
});
it("POST /entries/bulk creates entries for an admin and returns a summary", async () => {
const res = await authPost("/api/admin/plan/entries/bulk", adminToken, {
user_ids: [adminUserId],
date_from: "2095-06-05", // Sunday; weekends on → 1 day
date_to: "2095-06-05",
include_weekends: true,
category: "work",
note: `${N}bulk-http`,
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.created_days).toBe(1);
expect(body.data.users).toBe(1);
});
```
- [ ] **Step 8: Gate**
Run: `npx tsc -b --noEmit` (exit 0), then `npx vitest run` (all pass).
- [ ] **Step 9: Commit**
```bash
git add src/schemas/plan.schema.ts src/services/plan.service.ts src/routes/admin/plan.ts src/__tests__/plan.test.ts
git commit -m "feat(plan): bulk entry creation endpoint + service"
```
---
## Task 2: Frontend — bulk modal + wiring
**Files:**
- Create: `src/admin/components/BulkPlanModal.tsx`
- Modify: `src/admin/hooks/usePlanWork.ts`
- Modify: `src/admin/pages/PlanWork.tsx`
Frontend-only; gate on `tsc -b` + `build` (no component-test harness). Mirrors `BulkAttendanceModal.tsx` (presentational; parent owns state + toggles + submit).
- [ ] **Step 1: Create `BulkPlanModal.tsx`**
```tsx
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import {
Modal,
Field,
DateField,
Select,
TextField,
CheckboxField,
} from "../ui";
import type { PlanUser, PlanCategory } from "../lib/queries/plan";
import type { Project } from "../lib/queries/projects";
export interface BulkPlanForm {
date_from: string;
date_to: string;
is_range: boolean;
user_ids: string[];
include_weekends: boolean;
project_id: string;
category: string;
note: string;
}
interface Props {
isOpen: boolean;
onClose: () => void;
form: BulkPlanForm;
setForm: (form: BulkPlanForm) => void;
users: PlanUser[];
projects: Project[];
categories: PlanCategory[];
onSubmit: () => void;
submitting: boolean;
toggleUser: (userId: number) => void;
toggleAllUsers: () => void;
}
export default function BulkPlanModal({
isOpen,
onClose,
form,
setForm,
users,
projects,
categories,
onSubmit,
submitting,
toggleUser,
toggleAllUsers,
}: Props) {
const activeCategories = categories.filter((c) => c.is_active);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Hromadné přiřazení"
maxWidth="lg"
loading={submitting}
onSubmit={onSubmit}
submitText="Vytvořit"
submitDisabled={form.user_ids.length === 0}
>
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 0.25, mb: 2 }}
>
Vytvoří záznamy pro vybrané dny u zvolených zaměstnanců. Dny, které
mají 3 záznamy, se přeskočí.
</Typography>
<Field label="Datum od">
<DateField
value={form.date_from}
onChange={(val) => setForm({ ...form, date_from: val })}
/>
</Field>
<Field label="Rozsah dnů">
<CheckboxField
label="Více dní"
checked={form.is_range}
onChange={(checked) => setForm({ ...form, is_range: checked })}
/>
</Field>
{form.is_range && (
<Field label="Datum do">
<DateField
value={form.date_to}
onChange={(val) => setForm({ ...form, date_to: val })}
/>
</Field>
)}
<Field label="Víkendy">
<CheckboxField
label="Zahrnout víkendy"
checked={form.include_weekends}
onChange={(checked) =>
setForm({ ...form, include_weekends: checked })
}
/>
</Field>
<Box sx={{ mb: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, mb: 0.5 }}>
<Typography
component="span"
variant="body2"
sx={{ fontWeight: 600, color: "text.secondary" }}
>
Zaměstnanci
</Typography>
<Typography
component="button"
type="button"
onClick={toggleAllUsers}
variant="caption"
sx={{
background: "none",
border: "none",
p: 0,
cursor: "pointer",
fontWeight: 500,
color: "primary.main",
}}
>
{form.user_ids.length === users.length
? "Odznačit vše"
: "Vybrat vše"}
</Typography>
</Box>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 0.25,
maxHeight: 200,
overflowY: "auto",
p: 1.5,
bgcolor: "action.hover",
borderRadius: 2,
border: 1,
borderColor: "divider",
}}
>
{users.map((u) => (
<CheckboxField
key={u.id}
label={u.full_name}
checked={form.user_ids.includes(String(u.id))}
onChange={() => toggleUser(u.id)}
/>
))}
</Box>
<Typography variant="caption" color="text.secondary">
Vybráno: {form.user_ids.length} z {users.length}
</Typography>
</Box>
<Field label="Projekt">
<Select
value={form.project_id}
onChange={(val) => setForm({ ...form, project_id: val })}
options={[
{ value: "", label: "— bez projektu —" },
...projects.map((p) => ({ value: String(p.id), label: p.name })),
]}
/>
</Field>
<Field label="Kategorie">
<Select
value={form.category}
onChange={(val) => setForm({ ...form, category: val })}
options={activeCategories.map((c) => ({
value: c.key,
label: c.label,
}))}
/>
</Field>
<Field label="Text poznámky (volitelný)">
<TextField
multiline
minRows={2}
value={form.note}
onChange={(e) => setForm({ ...form, note: e.target.value })}
inputProps={{ maxLength: 500 }}
/>
</Field>
</Modal>
);
}
```
- [ ] **Step 2: Add a `bulkCreate` mutation to `usePlanWork.ts`**
After the `deleteOverride` mutation definition (before the `rollbackMutation` function), add:
```ts
const bulkCreate = useMutation({
mutationFn: (body: any) =>
apiCall("/api/admin/plan/entries/bulk", {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
}),
onSuccess: () => {
// No optimistic patch — bulk can touch many cells; just invalidate and
// let the grid refetch the authoritative state.
invalidate();
},
});
```
Add `bulkCreate` to the hook's return object (next to `createEntry`, etc.).
- [ ] **Step 3: Wire the bulk modal into `PlanWork.tsx`**
(a) Add imports near the other component imports:
```ts
import BulkPlanModal, { type BulkPlanForm } from "../components/BulkPlanModal";
```
(b) Pull `bulkCreate` out of the `usePlanWork(...)` destructure (alongside `createEntry`, etc.).
(c) Add bulk state + a default form (place near the other `useState` declarations, after `catModalOpen`):
```ts
const [bulkOpen, setBulkOpen] = useState(false);
const [bulkSubmitting, setBulkSubmitting] = useState(false);
const [bulkForm, setBulkForm] = useState<BulkPlanForm>(() => {
const today = todayIsoLocal();
return {
date_from: today,
date_to: today,
is_range: false,
user_ids: [],
include_weekends: false,
project_id: "",
category: "work",
note: "",
};
});
const planUsers = grid?.users ?? [];
const toggleBulkUser = useCallback((userId: number) => {
setBulkForm((prev) => ({
...prev,
user_ids: prev.user_ids.includes(String(userId))
? prev.user_ids.filter((u) => u !== String(userId))
: [...prev.user_ids, String(userId)],
}));
}, []);
const toggleAllBulkUsers = useCallback(() => {
const allIds = (grid?.users ?? []).map((u) => String(u.id));
setBulkForm((prev) => ({
...prev,
user_ids: prev.user_ids.length === allIds.length ? [] : allIds,
}));
}, [grid]);
const submitBulk = useCallback(async () => {
if (bulkForm.user_ids.length === 0) return;
setBulkSubmitting(true);
try {
const data: any = await bulkCreate.mutateAsync({
user_ids: bulkForm.user_ids.map(Number),
date_from: bulkForm.date_from,
date_to: bulkForm.is_range ? bulkForm.date_to : bulkForm.date_from,
include_weekends: bulkForm.include_weekends,
project_id: bulkForm.project_id ? Number(bulkForm.project_id) : null,
category: bulkForm.category,
note: bulkForm.note,
});
const skipped =
data?.skipped_days > 0
? ` ${data.skipped_days} dní přeskočeno (limit 3 na den).`
: "";
alert.success(
`Vytvořeno ${data?.created_days ?? 0} záznamů pro ${data?.users ?? 0} zaměstnanců.${skipped}`,
);
setBulkOpen(false);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
} finally {
setBulkSubmitting(false);
}
}, [bulkForm, bulkCreate, alert]);
```
(d) Add the toolbar button next to "Správa kategorií" (inside the same `canEdit` region of the toolbar `<Box>`):
```tsx
{
canEdit && (
<Button
variant="outlined"
color="inherit"
onClick={() => setBulkOpen(true)}
>
Hromadné přiřazení
</Button>
);
}
```
(e) Render the modal alongside `<PlanCategoriesModal ... />`:
```tsx
<BulkPlanModal
isOpen={bulkOpen}
onClose={() => setBulkOpen(false)}
form={bulkForm}
setForm={setBulkForm}
users={planUsers}
projects={projects}
categories={categories}
onSubmit={submitBulk}
submitting={bulkSubmitting}
toggleUser={toggleBulkUser}
toggleAllUsers={toggleAllBulkUsers}
/>
```
- [ ] **Step 4: Gate + Chrome check**
```bash
npx tsc -b --noEmit
npm run build
```
Then in Chrome on `/plan-work` (dev server already running — do not start it): click "Hromadné přiřazení", select 2 employees, a date range with "Více dní", leave weekends off, pick a project + category, submit; confirm the grid fills only MonFri across the selected people, the summary alert shows counts, and re-running on a day already at 3 records skips it.
- [ ] **Step 5: Commit**
```bash
git add src/admin/components/BulkPlanModal.tsx src/admin/hooks/usePlanWork.ts src/admin/pages/PlanWork.tsx
git commit -m "feat(plan): bulk assignment modal on /plan-work"
```
---
## Task 3: Release v2.0.6
**Files:**
- Modify: `package.json`
- [ ] **Step 1: Bump version** to `"version": "2.0.6"` in `package.json`.
- [ ] **Step 2: Full gate**
```bash
npx tsc -b --noEmit
npx vitest run
npm run build
```
Expected: tsc exit 0, vitest all pass, build success.
- [ ] **Step 3: Merge to master + commit + tag**
```bash
git checkout master
git merge --ff-only feat/bulk-plan-creation
git add package.json
git commit -m "chore(release): v2.0.6 — bulk plan creation"
git tag -a v2.0.6 -m "v2.0.6 — bulk plan creation"
```
(If implementation happened directly on master, skip the checkout/merge.)
- [ ] **Step 4: Push to Gitea**
```bash
git push origin master
git push origin v2.0.6
```
- [ ] **Step 5: Build tarball + deploy to production (REQUIRES explicit user confirmation)**
```bash
tar -czf app-ts-2.0.6.tar.gz dist dist-client prisma package.json package-lock.json scripts
scp app-ts-2.0.6.tar.gz boha_admin@192.168.50.100:/tmp/
ssh boha_admin@192.168.50.100 'set -e; cd /var/www/app-ts && rm -rf dist dist-client prisma scripts package.json package-lock.json && tar -xzf /tmp/app-ts-2.0.6.tar.gz && npm install --omit=dev && npx prisma migrate deploy && pm2 restart app-ts --update-env'
```
No migration ships in this release → `prisma migrate deploy` reports "No pending migrations".
- [ ] **Step 6: Health check**
```bash
ssh boha_admin@192.168.50.100 'curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3001/; curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3001/api/admin/session'
```
Expected: `200` then `401`. Confirm pm2 shows `app-ts` v2.0.6 online.
---
## Notes for the implementer
- **Reuse, don't re-validate:** `bulkCreateEntries` calls `createEntry`, which already enforces the cap, past-date, category, and range checks. Don't duplicate those.
- **`skipped_days` semantics:** only weekday-eligible days blocked by the cap count as skipped. Weekend days (when excluded) are out of scope, not skipped.
- **No DB migration.** Don't run any prisma migrate command.
- **Do not start the dev server** — the user runs it. Use the running instance for the Chrome check.
- **Do not deploy to production without explicit confirmation** (Task 3, Steps 56).

View File

@@ -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 (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.

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "2.0.5",
"version": "2.0.6",
"description": "",
"main": "dist/server.js",
"scripts": {

View File

@@ -20,6 +20,7 @@ import {
deleteOverride,
listEntries,
listOverrides,
bulkCreateEntries,
} from "../services/plan.service";
// ---------------------------------------------------------------------------
@@ -788,6 +789,184 @@ describe("plan.service.listOverrides (employee scoping)", () => {
});
});
// ---------------------------------------------------------------------------
// bulkCreateEntries
// ---------------------------------------------------------------------------
describe("plan.service.bulkCreateEntries", () => {
it("creates one continuous range per employee when weekends are included", async () => {
// 2096-06-01 is a Friday; 2096-06-03 is a Sunday. include_weekends → one
// range 06-01..06-03 covering all 3 days.
const res = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-06-01",
date_to: "2096-06-03",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}bulk-wknd`,
},
adminUserId,
);
expect("data" in res).toBe(true);
if (!("data" in res)) return;
expect(res.data.created_entries).toBe(1);
expect(res.data.created_days).toBe(3);
expect(res.data.skipped_days).toBe(0);
expect(res.data.users).toBe(1);
});
it("splits into per-work-week ranges and skips weekends when excluded", async () => {
// 2096-06-01 (Fri) .. 2096-06-05 (Tue): Fri | Sat Sun excluded | Mon Tue.
// Two runs: [06-01] and [06-04..06-05] → 2 entries, 3 days, 0 skipped.
const res = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-06-01",
date_to: "2096-06-05",
include_weekends: false,
project_id: null,
category: "work",
note: `${N}bulk-week`,
},
adminUserId,
);
expect("data" in res).toBe(true);
if (!("data" in res)) return;
expect(res.data.created_entries).toBe(2);
expect(res.data.created_days).toBe(3);
expect(res.data.skipped_days).toBe(0);
});
it("skips days already at the cap and splits the range around them", async () => {
// Pre-fill 2096-07-02 to the cap (3 entries) for the user. A weekends-on
// bulk over 07-01..07-03 then creates 07-01 and 07-03 (two ranges), skips
// 07-02. 2096-07-01..03 are Sun/Mon/Tue — all included with weekends on.
for (let i = 0; i < 3; i++) {
await prisma.work_plan_entries.create({
data: {
user_id: adminUserId,
date_from: new Date("2096-07-02"),
date_to: new Date("2096-07-02"),
category: "work",
note: `${N}cap${i}`,
created_by: adminUserId,
},
});
}
const res = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-07-01",
date_to: "2096-07-03",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}bulk-cap`,
},
adminUserId,
);
expect("data" in res).toBe(true);
if (!("data" in res)) return;
expect(res.data.created_entries).toBe(2);
expect(res.data.created_days).toBe(2);
expect(res.data.skipped_days).toBe(1);
});
it("aggregates across multiple employees", async () => {
const res = await bulkCreateEntries(
{
user_ids: [adminUserId, noPermUserId],
date_from: "2096-08-03", // Friday
date_to: "2096-08-03",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}bulk-multi`,
},
adminUserId,
);
expect("data" in res).toBe(true);
if (!("data" in res)) return;
expect(res.data.users).toBe(2);
expect(res.data.created_entries).toBe(2);
expect(res.data.created_days).toBe(2);
});
it("rejects empty user_ids, past dates, bad range, and inactive category", async () => {
const empty = await bulkCreateEntries(
{
user_ids: [],
date_from: "2096-06-01",
date_to: "2096-06-01",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}x`,
},
adminUserId,
);
expect("error" in empty && empty.status).toBe(400);
const tooLong = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-01-01",
date_to: "2096-12-31", // > 92 days
include_weekends: true,
project_id: null,
category: "work",
note: `${N}x`,
},
adminUserId,
);
expect("error" in tooLong && tooLong.status).toBe(400);
const past = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2000-01-01",
date_to: "2000-01-02",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}x`,
},
adminUserId,
);
expect("error" in past && past.status).toBe(403);
const badRange = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-06-10",
date_to: "2096-06-01",
include_weekends: true,
project_id: null,
category: "work",
note: `${N}x`,
},
adminUserId,
);
expect("error" in badRange && badRange.status).toBe(400);
const badCat = await bulkCreateEntries(
{
user_ids: [adminUserId],
date_from: "2096-06-01",
date_to: "2096-06-01",
include_weekends: true,
project_id: null,
category: "__nope__",
note: `${N}x`,
},
adminUserId,
);
expect("error" in badCat && badCat.status).toBe(400);
});
});
// ---------------------------------------------------------------------------
// HTTP route tests (Fastify inject)
// ---------------------------------------------------------------------------
@@ -1036,4 +1215,32 @@ describe("HTTP /api/admin/plan", () => {
);
expect(res.statusCode).toBe(201);
});
it("POST /entries/bulk requires attendance.manage", async () => {
const res = await authPost("/api/admin/plan/entries/bulk", noPermToken, {
user_ids: [adminUserId],
date_from: "2095-06-01",
date_to: "2095-06-01",
include_weekends: true,
category: "work",
note: `${N}bulk-forbidden`,
});
expect(res.statusCode).toBe(403);
});
it("POST /entries/bulk creates entries for an admin and returns a summary", async () => {
const res = await authPost("/api/admin/plan/entries/bulk", adminToken, {
user_ids: [adminUserId],
date_from: "2095-06-05", // Sunday; weekends on → 1 day
date_to: "2095-06-05",
include_weekends: true,
category: "work",
note: `${N}bulk-http`,
});
expect(res.statusCode).toBe(201);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.created_days).toBe(1);
expect(body.data.users).toBe(1);
});
});

View File

@@ -0,0 +1,191 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import {
Modal,
Field,
DateField,
Select,
TextField,
CheckboxField,
} from "../ui";
import type { PlanUser, PlanCategory } from "../lib/queries/plan";
import type { Project } from "../lib/queries/projects";
export interface BulkPlanForm {
date_from: string;
date_to: string;
is_range: boolean;
user_ids: string[];
include_weekends: boolean;
project_id: string;
category: string;
note: string;
}
interface Props {
isOpen: boolean;
onClose: () => void;
form: BulkPlanForm;
setForm: (form: BulkPlanForm) => void;
users: PlanUser[];
projects: Project[];
categories: PlanCategory[];
onSubmit: () => void;
submitting: boolean;
toggleUser: (userId: number) => void;
toggleAllUsers: () => void;
}
export default function BulkPlanModal({
isOpen,
onClose,
form,
setForm,
users,
projects,
categories,
onSubmit,
submitting,
toggleUser,
toggleAllUsers,
}: Props) {
const activeCategories = categories.filter((c) => c.is_active);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Hromadné přiřazení"
maxWidth="lg"
loading={submitting}
onSubmit={onSubmit}
submitText="Vytvořit"
submitDisabled={form.user_ids.length === 0}
>
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 0.25, mb: 2 }}
>
Vytvoří záznamy pro vybrané dny u zvolených zaměstnanců. Dny, které
mají 3 záznamy, se přeskočí.
</Typography>
<Field label="Datum od">
<DateField
value={form.date_from}
onChange={(val) => setForm({ ...form, date_from: val })}
/>
</Field>
<Field label="Rozsah dnů">
<CheckboxField
label="Více dní"
checked={form.is_range}
onChange={(checked) => setForm({ ...form, is_range: checked })}
/>
</Field>
{form.is_range && (
<Field label="Datum do">
<DateField
value={form.date_to}
onChange={(val) => setForm({ ...form, date_to: val })}
/>
</Field>
)}
<Field label="Víkendy">
<CheckboxField
label="Zahrnout víkendy"
checked={form.include_weekends}
onChange={(checked) =>
setForm({ ...form, include_weekends: checked })
}
/>
</Field>
<Box sx={{ mb: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, mb: 0.5 }}>
<Typography
component="span"
variant="body2"
sx={{ fontWeight: 600, color: "text.secondary" }}
>
Zaměstnanci
</Typography>
<Typography
component="button"
type="button"
onClick={toggleAllUsers}
variant="caption"
sx={{
background: "none",
border: "none",
p: 0,
cursor: "pointer",
fontWeight: 500,
color: "primary.main",
}}
>
{users.length > 0 && form.user_ids.length === users.length
? "Odznačit vše"
: "Vybrat vše"}
</Typography>
</Box>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 0.25,
maxHeight: 200,
overflowY: "auto",
p: 1.5,
bgcolor: "action.hover",
borderRadius: 2,
border: 1,
borderColor: "divider",
}}
>
{users.map((u) => (
<CheckboxField
key={u.id}
label={u.full_name}
checked={form.user_ids.includes(String(u.id))}
onChange={() => toggleUser(u.id)}
/>
))}
</Box>
<Typography variant="caption" color="text.secondary">
Vybráno: {form.user_ids.length} z {users.length}
</Typography>
</Box>
<Field label="Projekt">
<Select
value={form.project_id}
onChange={(val) => setForm({ ...form, project_id: val })}
options={[
{ value: "", label: "— bez projektu —" },
...projects.map((p) => ({ value: String(p.id), label: p.name })),
]}
/>
</Field>
<Field label="Kategorie">
<Select
value={form.category}
onChange={(val) => setForm({ ...form, category: val })}
options={activeCategories.map((c) => ({
value: c.key,
label: c.label,
}))}
/>
</Field>
<Field label="Text poznámky (volitelný)">
<TextField
multiline
minRows={2}
value={form.note}
onChange={(e) => setForm({ ...form, note: e.target.value })}
inputProps={{ maxLength: 500 }}
/>
</Field>
</Modal>
);
}

View File

@@ -488,6 +488,20 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
},
});
const bulkCreate = useMutation({
mutationFn: (body: any) =>
apiCall("/api/admin/plan/entries/bulk", {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
}),
onSuccess: () => {
// No optimistic patch — bulk can touch many cells; just invalidate and
// let the grid refetch the authoritative state.
invalidate();
},
});
// Roll back an optimistic patch on mutation error. Called from
// PlanWork's mutation wrappers via `rollbackMutation(mutation, key)`.
// `mutation` is a TanStack mutation result with a `_rolled` snapshot stashed
@@ -534,6 +548,7 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
createOverride,
updateOverride,
deleteOverride,
bulkCreate,
invalidate,
// Exposed so PlanWork can roll back an optimistic patch when a
// mutation throws. Pass the failed mutation and the userId it

View File

@@ -15,6 +15,7 @@ import type { PlanCellModalMode } from "../components/PlanCellModal";
import PlanGrid from "../components/PlanGrid";
import PlanCellModal from "../components/PlanCellModal";
import PlanCategoriesModal from "../components/PlanCategoriesModal";
import BulkPlanModal, { type BulkPlanForm } from "../components/BulkPlanModal";
import Forbidden from "../components/Forbidden";
import { Button, Alert, LoadingState, PageEnter } from "../ui";
import { projectListOptions, type Project } from "../lib/queries/projects";
@@ -128,6 +129,7 @@ export default function PlanWork() {
createOverride,
updateOverride,
deleteOverride,
bulkCreate,
rollbackMutation,
} = usePlanWork({ canEdit });
@@ -140,6 +142,84 @@ export default function PlanWork() {
const [catModalOpen, setCatModalOpen] = useState(false);
const [bulkOpen, setBulkOpen] = useState(false);
const [bulkSubmitting, setBulkSubmitting] = useState(false);
const [bulkForm, setBulkForm] = useState<BulkPlanForm>(() => {
const today = todayIsoLocal();
return {
date_from: today,
date_to: today,
is_range: false,
user_ids: [],
include_weekends: false,
project_id: "",
category: "work",
note: "",
};
});
const planUsers = useMemo(() => grid?.users ?? [], [grid]);
const toggleBulkUser = useCallback((userId: number) => {
setBulkForm((prev) => ({
...prev,
user_ids: prev.user_ids.includes(String(userId))
? prev.user_ids.filter((u) => u !== String(userId))
: [...prev.user_ids, String(userId)],
}));
}, []);
const toggleAllBulkUsers = useCallback(() => {
const allIds = (grid?.users ?? []).map((u) => String(u.id));
if (allIds.length === 0) return; // grid not yet loaded — no-op
setBulkForm((prev) => ({
...prev,
user_ids: prev.user_ids.length === allIds.length ? [] : allIds,
}));
}, [grid]);
const submitBulk = useCallback(async () => {
if (bulkForm.user_ids.length === 0) return;
setBulkSubmitting(true);
try {
const data: any = await bulkCreate.mutateAsync({
user_ids: bulkForm.user_ids.map(Number),
date_from: bulkForm.date_from,
date_to: bulkForm.is_range ? bulkForm.date_to : bulkForm.date_from,
include_weekends: bulkForm.include_weekends,
project_id: bulkForm.project_id ? Number(bulkForm.project_id) : null,
category: bulkForm.category,
note: bulkForm.note,
});
// apiCall returns the full envelope { success, data, message };
// the summary counts are under .data
const summary = data?.data ?? data;
const skipped =
summary?.skipped_days > 0
? ` ${summary.skipped_days} dní přeskočeno (limit 3 na den).`
: "";
alert.success(
`Vytvořeno ${summary?.created_days ?? 0} záznamů pro ${summary?.users ?? 0} zaměstnanců.${skipped}`,
);
setBulkOpen(false);
// Reset the volatile parts so reopening starts clean (project /
// category / weekends stay sticky — users often repeat those).
const today = todayIsoLocal();
setBulkForm((prev) => ({
...prev,
date_from: today,
date_to: today,
is_range: false,
user_ids: [],
note: "",
}));
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba při ukládání");
} finally {
setBulkSubmitting(false);
}
}, [bulkForm, bulkCreate, alert]);
// `lastMutated` is the (userId, date) of the most recent successful
// mutation. We pass it to PlanGrid as `pulseKey`; the matching cell
// gets a one-shot CSS pulse animation. We clear it after 700ms (the
@@ -673,6 +753,15 @@ export default function PlanWork() {
Správa kategorií
</Button>
)}
{canEdit && (
<Button
variant="outlined"
color="inherit"
onClick={() => setBulkOpen(true)}
>
Hromadné přiřazení
</Button>
)}
</Box>
{gridLoading && <LoadingState />}
@@ -727,6 +816,19 @@ export default function PlanWork() {
onClose={() => setCatModalOpen(false)}
categories={categories}
/>
<BulkPlanModal
isOpen={bulkOpen}
onClose={() => setBulkOpen(false)}
form={bulkForm}
setForm={setBulkForm}
users={planUsers}
projects={projects}
categories={categories}
onSubmit={submitBulk}
submitting={bulkSubmitting}
toggleUser={toggleBulkUser}
toggleAllUsers={toggleAllBulkUsers}
/>
</PageEnter>
);
}

View File

@@ -15,6 +15,7 @@ import {
UpdatePlanOverrideSchema,
PlanRangeQuerySchema,
PlanGridQuerySchema,
BulkPlanEntrySchema,
} from "../../schemas/plan.schema";
import {
resolveGrid,
@@ -27,6 +28,7 @@ import {
deleteOverride,
listEntries,
listOverrides,
bulkCreateEntries,
} from "../../services/plan.service";
import {
CreatePlanCategorySchema,
@@ -259,6 +261,29 @@ export default async function planRoutes(app: FastifyInstance) {
},
);
// --- POST /plan/entries/bulk ---
app.post(
"/entries/bulk",
{ preHandler: requirePermission("attendance.manage") },
async (request: FastifyRequest, reply: FastifyReply) => {
const body = parseBody(BulkPlanEntrySchema, request.body);
if ("error" in body) return error(reply, body.error, 400);
const result = await bulkCreateEntries(
body.data!,
request.authData!.userId,
);
if ("error" in result) return error(reply, result.error, result.status);
await logAudit({
request,
authData: request.authData,
action: "create",
entityType: "work_plan_entry",
description: `Hromadné přiřazení: ${body.data!.category}${result.data.users} zaměstnanců, ${body.data!.date_from}${body.data!.date_to} (${result.data.created_days} dní)`,
});
return success(reply, result.data, 201, "Hromadné přiřazení dokončeno");
},
);
// --- PATCH /plan/entries/:id ---
app.patch(
"/entries/:id",

View File

@@ -83,3 +83,27 @@ export const PlanGridQuerySchema = z.object({
date_to: isoDate,
view: z.enum(["week", "month"]).default("week"),
});
export const BulkPlanEntrySchema = z
.object({
user_ids: z
.array(intFromForm)
.min(1, "Vyberte alespoň jednoho zaměstnance")
.transform((ids) => [...new Set(ids)]),
date_from: isoDate,
date_to: isoDate,
include_weekends: z
.union([z.boolean(), z.string()])
.transform((v) => v === true || v === "true" || v === "1")
.default(false),
project_id: z
.union([z.number(), z.string(), z.null()])
.transform((v) => (v === null ? null : Number(v)))
.nullish(),
category: planCategoryEnum.default("work"),
note: z.string().max(500, "Maximálně 500 znaků").default(""),
})
.refine((v) => v.date_to >= v.date_from, {
message: "Datum do musí být stejné nebo po datumu od",
path: ["date_to"],
});

View File

@@ -761,6 +761,157 @@ export async function deleteOverride(
return { data: { ok: true }, oldData: existing, description };
}
// ---------------------------------------------------------------------------
// Bulk entry creation
// ---------------------------------------------------------------------------
/**
* Bulk-assign a project/category to many employees over a date range. For each
* employee, walk the range, keep the days that pass the weekday filter
* (weekends excluded unless include_weekends) AND are under the per-cell cap,
* group contiguous kept days into runs, and create one range entry per run by
* reusing createEntry (so cap / past-date / category validation stays DRY).
*
* Days that pass the weekday filter but are already at the cap are counted in
* skipped_days (and split the range). Weekend days, when excluded, are out of
* scope and are NOT counted as skipped.
*/
export async function bulkCreateEntries(
input: {
user_ids: number[];
date_from: string;
date_to: string;
include_weekends: boolean;
project_id?: number | null;
category: string;
note: string;
},
actorUserId: number,
): Promise<
Result<{
created_entries: number;
created_days: number;
skipped_days: number;
users: number;
}>
> {
if (input.user_ids.length === 0) {
return { error: "Vyberte alespoň jednoho zaměstnance", status: 400 };
}
if (input.date_to < input.date_from) {
return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
}
const lockFrom = assertNotPastDate(input.date_from, false);
if (lockFrom) return lockFrom;
const lockTo = assertNotPastDate(input.date_to, false);
if (lockTo) return lockTo;
const catErr = await assertActiveCategory(input.category);
if (catErr) return catErr;
const from = toDateOnly(input.date_from);
const to = toDateOnly(input.date_to);
// Bound the work (users × days). Mirror the 92-day cap on GET /plan/grid so
// one bulk call can't fan out into thousands of inserts.
const rangeDays = Math.round((to.getTime() - from.getTime()) / 86400000) + 1;
if (rangeDays > 92) {
return { error: "Maximální rozsah je 92 dní", status: 400 };
}
// The day list for the whole range (UTC midnights), built once.
const days: Date[] = [];
for (let d = new Date(from); d <= to; d.setUTCDate(d.getUTCDate() + 1)) {
days.push(new Date(d));
}
let createdEntries = 0;
let createdDays = 0;
let skippedDays = 0;
for (const userId of input.user_ids) {
// Per-day cap counts from this user's existing active entries.
const existing = await prisma.work_plan_entries.findMany({
where: {
user_id: userId,
is_deleted: false,
date_from: { lte: to },
date_to: { gte: from },
},
select: { date_from: true, date_to: true },
});
const countFor = (day: Date): number =>
existing.filter((e) => e.date_from <= day && e.date_to >= day).length;
// Eligibility per day (weekday filter + under-cap). Tally cap skips.
const eligible: boolean[] = [];
for (const day of days) {
const dow = day.getUTCDay(); // 0 = Sun, 6 = Sat
const isWeekend = dow === 0 || dow === 6;
if (isWeekend && !input.include_weekends) {
eligible.push(false);
continue;
}
if (countFor(day) >= MAX_RECORDS_PER_CELL) {
skippedDays++;
eligible.push(false);
continue;
}
eligible.push(true);
}
// Group contiguous eligible days into runs; create one range per run.
let runStart = -1;
for (let i = 0; i <= days.length; i++) {
const ok = i < days.length && eligible[i];
if (ok && runStart === -1) {
runStart = i;
} else if (!ok && runStart !== -1) {
const segFrom = days[runStart].toISOString().slice(0, 10);
const segTo = days[i - 1].toISOString().slice(0, 10);
const runDays = i - runStart;
const res = await createEntry(
{
user_id: userId,
date_from: segFrom,
date_to: segTo,
project_id: input.project_id ?? null,
category: input.category,
note: input.note,
},
actorUserId,
false,
);
if ("data" in res) {
createdEntries++;
createdDays += runDays;
} else {
// Rare race (e.g. the cap filled concurrently between the count and
// the create). Treat the run's days as skipped, but log — services
// must not swallow non-fatal failures (CLAUDE.md known-issue #4).
console.warn("[plan.service] bulkCreateEntries: segment skipped", {
userId,
segFrom,
segTo,
error: res.error,
});
skippedDays += runDays;
}
runStart = -1;
}
}
}
return {
data: {
created_entries: createdEntries,
created_days: createdDays,
skipped_days: skippedDays,
users: input.user_ids.length,
},
oldData: null,
};
}
// ---------------------------------------------------------------------------
// Raw-row list endpoints
// ---------------------------------------------------------------------------