Files
app/docs/superpowers/plans/2026-06-08-bulk-plan-creation.md
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

27 KiB
Raw Blame History

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.tsbulkCreateEntries 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.

// ---------------------------------------------------------------------------
// 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):

// ---------------------------------------------------------------------------
// 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:

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):

// --- 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:

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
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
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:

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:

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):

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>):

{
  canEdit && (
    <Button
      variant="outlined"
      color="inherit"
      onClick={() => setBulkOpen(true)}
    >
      Hromadné přiřazení
    </Button>
  );
}

(e) Render the modal alongside <PlanCategoriesModal ... />:

<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
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
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

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
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
git push origin master
git push origin v2.0.6
  • Step 5: Build tarball + deploy to production (REQUIRES explicit user confirmation)
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
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).