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>
This commit is contained in:
891
docs/superpowers/plans/2026-06-08-bulk-plan-creation.md
Normal file
891
docs/superpowers/plans/2026-06-08-bulk-plan-creation.md
Normal 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é už
|
||||||
|
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 Mon–Fri 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 5–6).
|
||||||
Reference in New Issue
Block a user