Files
app/docs/superpowers/plans/2026-06-05-plan-praci.md
BOHA 674b44d047 feat(odin): Phase 2a read-only agentic assistant + tool-trace UI
- agentic chat loop (max 6 round-trips, budget re-checked between iterations)
  over 10 read-only, permission-delegated tools in src/services/ai-tools.ts
- persist assistant tool trace in ai_chat_messages.content_json (+ migration)
- consulted-tools chips in OdinThread; header now shows month spend only
  (budget cap hidden from the UI, server-side 402 guard unchanged)
- seed: ai.use permission; Settings exposes the ai permission module
- docs: REVIEW consolidation; deployment-guide.md superseded by docs/release.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:31:50 +02:00

107 KiB
Raw Permalink Blame History

Plán prací (Work Schedule) Module 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: Build a work-schedule module under Docházka that lets a foreman plan each employee's per-day work as date ranges (with per-day overrides) and lets every employee see the plan read-only. Single new sidebar entry, single page with two permission-driven modes.

Architecture: Two new Prisma tables (work_plan_entries for ranges, work_plan_overrides for single-day exceptions) and one new enum (plan_category). Backend is REST under /api/admin/plan following the existing routes → Zod schemas → services → Prisma pattern, with audit logging on every write. Frontend is a single page (PlanWork.tsx) that renders a PlanGrid component and a state-driven PlanCellModal (edit) or PlanCellDetailModal (view-only), reusing FormModal, FormField, ConfirmModal, AdminDatePicker. Sidebar entry gated by attendance.manage || attendance.record; page itself decides editor vs read-only mode by checking useAuth().hasPermission('attendance.manage').

Tech Stack: Prisma 6.19.2 (MySQL), Fastify 5, Zod 4, React 18 + Vite, TanStack React Query, Vitest + Supertest for backend tests.

Reference spec: docs/superpowers/specs/2026-06-05-plan-praci-design.md


File Structure

New Files

prisma/migrations/<timestamp>_add_work_plan/      # Auto-generated by `prisma migrate dev`
  └── migration.sql                                # CREATE TABLE work_plan_entries, work_plan_overrides; CREATE TYPE plan_category

src/
  schemas/plan.schema.ts                          # Zod schemas for all plan endpoints
  services/plan.service.ts                        # Business logic (resolveCell, CRUD, audit)
  routes/admin/plan.ts                            # Fastify route definitions
  __tests__/plan.test.ts                          # Backend integration tests

src/admin/
  lib/queries/plan.ts                             # React Query queryOptions + key helpers
  hooks/usePlanWork.ts                            # Page state, modal state machine, mutations
  pages/PlanWork.tsx                              # Single page (editor or view-only)
  components/
    PlanGrid.tsx                                  # Week/month grid (presentational)
    PlanCellModal.tsx                             # Edit modal (create / edit-range / override)
    PlanCellDetailModal.tsx                       # Read-only detail modal
    PlanRangeChips.tsx                            # Cell content chip with category color
    plan.css                                      # Module-specific styles only

Modified Files

File Change
prisma/schema.prisma Add plan_category enum, work_plan_entries model, work_plan_overrides model
src/server.ts Import planRoutes and register under /api/admin/plan
src/admin/AdminApp.tsx Lazy-import PlanWork and add a <Route path="attendance/plan" element={<PlanWork />} />
src/admin/AdminApp.tsx Add import "./plan.css"; (or merge into attendance.css — see Task 14)
src/admin/components/Sidebar.tsx Add Plán prací menu entry under the "Docházka" section, gated by attendance.manage || attendance.record

Phase 0: Verification Prerequisite

Before starting, confirm the developer is on a clean working tree on master and that the dev server is not running (per CLAUDE.md, the user manages the dev server — do not start it):

git status
# Expected: working tree clean OR only the spec file from brainstorming

pgrep -f "tsx watch src/server.ts" || echo "dev server not running (good)"

If the dev server is running, stop here and ask the user to stop it. Database migrations can conflict with active connections.


Phase 1: Database Foundation

Task 1: Add Prisma Enum and Models

Files:

  • Modify: prisma/schema.prisma

  • Step 1: Add the plan_category enum

Open prisma/schema.prisma and find the enums section. The current schema has these enums: attendance_leave_type. Append the new enum at the end of the enums block (search for the last enum declaration and add after it):

enum plan_category {
  work
  preparation
  travel
  leave
  sick
  training
  other
}
  • Step 2: Add the work_plan_entries model

Append the following model at the end of prisma/schema.prisma (after the last existing model):

model work_plan_entries {
  id         Int             @id @default(autoincrement())
  user_id    Int
  date_from  DateTime        @db.Date
  date_to    DateTime        @db.Date
  project_id Int?
  category   plan_category   @default(work)
  note       String          @db.VarChar(500)
  created_by Int
  created_at DateTime?       @default(now()) @db.Timestamp(0)
  updated_at DateTime?       @default(now()) @db.Timestamp(0)
  is_deleted Boolean?        @default(false)
  users      users           @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
  projects   projects?       @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction)
  creator    users           @relation("work_plan_entries_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction)

  @@index([user_id, date_from], map: "idx_wpe_user_from")
  @@index([user_id, date_to], map: "idx_wpe_user_to")
  @@index([project_id], map: "idx_wpe_project")
  @@index([date_from, date_to], map: "idx_wpe_dates")
}
  • Step 3: Add the work_plan_overrides model

Append immediately after work_plan_entries:

model work_plan_overrides {
  id         Int           @id @default(autoincrement())
  user_id    Int
  shift_date DateTime      @db.Date
  project_id Int?
  category   plan_category
  note       String        @db.VarChar(500)
  created_by Int
  created_at DateTime?     @default(now()) @db.Timestamp(0)
  updated_at DateTime?     @default(now()) @db.Timestamp(0)
  is_deleted Boolean?      @default(false)
  users      users         @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
  projects   projects?     @relation(fields: [project_id], references: [id], onDelete: SetNull, onUpdate: NoAction)
  creator    users         @relation("work_plan_overrides_creator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction)

  @@unique([user_id, shift_date], map: "uniq_wpo_user_date")
  @@index([shift_date], map: "idx_wpo_date")
}
  • Step 4: Add the back-relations on the users and projects models

Open prisma/schema.prisma and find the model users block. Add these relation fields to its existing relation list (place them adjacent to the other work-related relations, or at the end of the relations list — Prisma does not care about order):

  work_plan_entries        work_plan_entries[]         @relation("work_plan_entries_creator")
  work_plan_entries_owned  work_plan_entries[]         // entries where this user is the employee
  work_plan_overrides      work_plan_overrides[]       @relation("work_plan_overrides_creator")
  work_plan_overrides_owned work_plan_overrides[]      // overrides where this user is the employee

Find the model projects block. Add this relation field to its existing relations:

  work_plan_entries   work_plan_entries[]
  work_plan_overrides work_plan_overrides[]
  • Step 5: Generate and apply the migration
npx prisma migrate dev --name add_work_plan

Expected: a new folder prisma/migrations/<timestamp>_add_work_plan/ is created with a migration.sql containing CREATE TABLE work_plan_entries, CREATE TABLE work_plan_overrides, and the enum. The Prisma client is regenerated.

If Prisma complains about missing/dangling relations, re-read the existing users and projects models and ensure the relation names match exactly.

  • Step 6: Verify the schema is valid
npx prisma generate
npx tsc -p tsconfig.server.json --noEmit

Expected: no TypeScript errors. If tsc complains about the new plan_category type, the next task will introduce the import.

  • Step 7: Commit
git add prisma/schema.prisma prisma/migrations/
git commit -m "feat(plan): add work_plan_entries, work_plan_overrides, plan_category enum"

Phase 2: Backend Foundation

Task 2: Zod Schemas

Files:

  • Create: src/schemas/plan.schema.ts

  • Step 1: Write the schema file

Create src/schemas/plan.schema.ts with the following content:

import { z } from "zod";

const isoDate = z
  .string()
  .regex(/^\d{4}-\d{2}-\d{2}$/, "Datum musí být ve formátu YYYY-MM-DD");

const intFromForm = z
  .union([z.number(), z.string()])
  .transform((v) => Number(v))
  .refine((n) => Number.isInteger(n) && n > 0, "Neplatné ID");

const planCategoryEnum = z.enum([
  "work",
  "preparation",
  "travel",
  "leave",
  "sick",
  "training",
  "other",
]);

export const CreatePlanEntrySchema = z
  .object({
    user_id: intFromForm,
    date_from: isoDate,
    date_to: isoDate,
    project_id: z
      .union([z.number(), z.string()])
      .transform((v) => Number(v))
      .nullish(),
    category: planCategoryEnum.default("work"),
    note: z
      .string()
      .min(1, "Text poznámky je povinný")
      .max(500, "Maximálně 500 znaků"),
  })
  .refine((v) => v.date_to >= v.date_from, {
    message: "Datum do musí být stejné nebo po datumu od",
    path: ["date_to"],
  });

export const UpdatePlanEntrySchema = z
  .object({
    date_from: isoDate.optional(),
    date_to: isoDate.optional(),
    project_id: z
      .union([z.number(), z.string(), z.null()])
      .transform((v) => (v === null ? null : Number(v)))
      .nullish(),
    category: planCategoryEnum.optional(),
    note: z.string().min(1).max(500).optional(),
  })
  .refine(
    (v) =>
      v.date_from === undefined ||
      v.date_to === undefined ||
      v.date_to >= v.date_from,
    {
      message: "Datum do musí být stejné nebo po datumu od",
      path: ["date_to"],
    },
  );

export const CreatePlanOverrideSchema = z.object({
  user_id: intFromForm,
  shift_date: isoDate,
  project_id: z
    .union([z.number(), z.string()])
    .transform((v) => Number(v))
    .nullish(),
  category: planCategoryEnum,
  note: z
    .string()
    .min(1, "Text poznámky je povinný")
    .max(500, "Maximálně 500 znaků"),
});

export const UpdatePlanOverrideSchema = z.object({
  project_id: z
    .union([z.number(), z.string(), z.null()])
    .transform((v) => (v === null ? null : Number(v)))
    .nullish(),
  category: planCategoryEnum.optional(),
  note: z.string().min(1).max(500).optional(),
});

export const PlanRangeQuerySchema = z.object({
  user_id: intFromForm.optional(),
  date_from: isoDate,
  date_to: isoDate,
});

export const PlanGridQuerySchema = z.object({
  date_from: isoDate,
  date_to: isoDate,
  view: z.enum(["week", "month"]).default("week"),
});
  • Step 2: Verify it compiles
npx tsc -p tsconfig.server.json --noEmit

Expected: no errors.

  • Step 3: Commit
git add src/schemas/plan.schema.ts
git commit -m "feat(plan): zod schemas for plan endpoints"

Task 3: Plan Service — resolveCell and basic CRUD

Files:

  • Create: src/services/plan.service.ts

This task is the core business logic. The first iteration implements resolveCell, the single most important behavior. CRUD methods are added in later steps.

  • Step 1: Write the failing test for resolveCell

Create src/__tests__/plan.test.ts with the initial test setup. We will append tests in later tasks.

import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import Fastify from "fastify";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
import jwt from "jsonwebtoken";
import prisma from "../config/database";
import { config } from "../config/env";
import planRoutes from "../routes/admin/plan";
import { securityHeaders } from "../middleware/security";
import { resolveCell } from "../services/plan.service";

const N = "wh_plan_"; // test data prefix

let adminUserId: number;
let projectId: number;

beforeAll(async () => {
  // Pick the first admin user and the first project as fixtures
  const admin = await prisma.users.findFirst({
    where: { role: { name: "admin" } },
  });
  if (!admin) throw new Error("Test setup: admin user not found");
  adminUserId = admin.id;

  const project = await prisma.projects.findFirst();
  if (!project) throw new Error("Test setup: project not found");
  projectId = project.id;
});

beforeEach(async () => {
  // Clean up any leftover test data from previous runs
  await prisma.work_plan_entries.deleteMany({
    where: { note: { contains: N } },
  });
  await prisma.work_plan_overrides.deleteMany({
    where: { note: { contains: N } },
  });
});

describe("plan.service.resolveCell", () => {
  it("returns null when nothing covers the date", async () => {
    const result = await resolveCell(adminUserId, "2099-01-01");
    expect(result).toBeNull();
  });

  it("returns the entry that covers the date", async () => {
    await prisma.work_plan_entries.create({
      data: {
        user_id: adminUserId,
        date_from: new Date("2099-06-01"),
        date_to: new Date("2099-06-10"),
        category: "work",
        note: `${N}PLC upgrade`,
        created_by: adminUserId,
      },
    });
    const result = await resolveCell(adminUserId, "2099-06-05");
    expect(result).not.toBeNull();
    expect(result?.note).toBe(`${N}PLC upgrade`);
  });

  it("returns the override for that day, not the entry", async () => {
    await prisma.work_plan_entries.create({
      data: {
        user_id: adminUserId,
        date_from: new Date("2099-06-01"),
        date_to: new Date("2099-06-10"),
        category: "work",
        note: `${N}PLC upgrade`,
        created_by: adminUserId,
      },
    });
    await prisma.work_plan_overrides.create({
      data: {
        user_id: adminUserId,
        shift_date: new Date("2099-06-05"),
        category: "leave",
        note: `${N}Volno po noční`,
        created_by: adminUserId,
      },
    });
    const result = await resolveCell(adminUserId, "2099-06-05");
    expect(result?.note).toBe(`${N}Volno po noční`);
    expect(result?.category).toBe("leave");
  });

  it("ignores soft-deleted entries and overrides", async () => {
    await prisma.work_plan_entries.create({
      data: {
        user_id: adminUserId,
        date_from: new Date("2099-06-01"),
        date_to: new Date("2099-06-10"),
        category: "work",
        note: `${N}deleted entry`,
        is_deleted: true,
        created_by: adminUserId,
      },
    });
    const result = await resolveCell(adminUserId, "2099-06-05");
    expect(result).toBeNull();
  });
});
  • Step 2: Run the test to confirm it fails
npm test -- --run plan.test

Expected: FAIL — Cannot find module '../services/plan.service' or resolveCell is not exported.

  • Step 3: Create the service file with resolveCell

Create src/services/plan.service.ts:

import prisma from "../config/database";

/**
 * Resolved plan cell for a single (user, date). Either from a range entry
 * (work_plan_entries) or from a single-day override (work_plan_overrides).
 */
export interface ResolvedCell {
  source: "entry" | "override";
  entryId: number | null;
  overrideId: number | null;
  user_id: number;
  shift_date: string;
  project_id: number | null;
  category: string;
  note: string;
  rangeFrom: string | null;
  rangeTo: string | null;
}

/**
 * Compute the effective plan cell for (userId, dateStr).
 * Precedence: override > entry > null.
 * Soft-deleted rows are ignored.
 * If multiple entries cover the same day, the latest by created_at wins
 * and a warning is logged.
 */
export async function resolveCell(
  userId: number,
  dateStr: string,
): Promise<ResolvedCell | null> {
  const date = new Date(dateStr);

  // 1. Override for this day
  const override = await prisma.work_plan_overrides.findFirst({
    where: { user_id: userId, shift_date: date, is_deleted: false },
  });
  if (override) {
    return {
      source: "override",
      entryId: null,
      overrideId: override.id,
      user_id: override.user_id,
      shift_date: dateStr,
      project_id: override.project_id,
      category: override.category,
      note: override.note,
      rangeFrom: null,
      rangeTo: null,
    };
  }

  // 2. Entry whose range covers the day
  const entries = await prisma.work_plan_entries.findMany({
    where: {
      user_id: userId,
      date_from: { lte: date },
      date_to: { gte: date },
      is_deleted: false,
    },
    orderBy: { created_at: "desc" },
  });

  if (entries.length === 0) return null;

  if (entries.length > 1) {
    // Multiple entries overlap. Use the latest. A persistent audit-log
    // entry would be ideal here, but logAudit() currently requires a
    // FastifyRequest — services have no request. Use console.warn as a
    // stand-in until a service-context audit logger is introduced.
    console.warn(
      `[plan.service] multiple entries cover user ${userId} on ${dateStr}; using the latest (entry id ${entries[0].id})`,
    );
  }

  const entry = entries[0];
  return {
    source: "entry",
    entryId: entry.id,
    overrideId: null,
    user_id: entry.user_id,
    shift_date: dateStr,
    project_id: entry.project_id,
    category: entry.category,
    note: entry.note,
    rangeFrom: entry.date_from.toISOString().slice(0, 10),
    rangeTo: entry.date_to.toISOString().slice(0, 10),
  };
}
  • Step 4: Run the test to confirm it passes
npm test -- --run plan.test

Expected: 4 tests pass.

  • Step 5: Commit
git add src/services/plan.service.ts src/__tests__/plan.test.ts
git commit -m "feat(plan): resolveCell with override-precedence semantics"

Task 4: Plan Service — resolveGrid

Files:

  • Modify: src/services/plan.service.ts
  • Modify: src/__tests__/plan.test.ts

resolveGrid(userIds, dateFrom, dateTo) returns the effective cell for each (user, date) pair. The grid endpoint will call this.

  • Step 1: Append failing tests for resolveGrid

Add to src/__tests__/plan.test.ts (after the existing describe block):

import { resolveGrid } from "../services/plan.service";

describe("plan.service.resolveGrid", () => {
  it("returns a 2D map keyed by userId then date", async () => {
    await prisma.work_plan_entries.create({
      data: {
        user_id: adminUserId,
        date_from: new Date("2099-06-01"),
        date_to: new Date("2099-06-03"),
        category: "work",
        note: `${N}A`,
        created_by: adminUserId,
      },
    });
    const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-05");
    expect(cells[adminUserId]["2099-06-01"]?.note).toBe(`${N}A`);
    expect(cells[adminUserId]["2099-06-02"]?.note).toBe(`${N}A`);
    expect(cells[adminUserId]["2099-06-03"]?.note).toBe(`${N}A`);
    expect(cells[adminUserId]["2099-06-04"]).toBeNull();
    expect(cells[adminUserId]["2099-06-05"]).toBeNull();
  });

  it("applies override on a covered day", async () => {
    await prisma.work_plan_entries.create({
      data: {
        user_id: adminUserId,
        date_from: new Date("2099-06-01"),
        date_to: new Date("2099-06-03"),
        category: "work",
        note: `${N}A`,
        created_by: adminUserId,
      },
    });
    await prisma.work_plan_overrides.create({
      data: {
        user_id: adminUserId,
        shift_date: new Date("2099-06-02"),
        category: "leave",
        note: `${N}B`,
        created_by: adminUserId,
      },
    });
    const cells = await resolveGrid([adminUserId], "2099-06-01", "2099-06-03");
    expect(cells[adminUserId]["2099-06-01"]?.note).toBe(`${N}A`);
    expect(cells[adminUserId]["2099-06-02"]?.note).toBe(`${N}B`);
    expect(cells[adminUserId]["2099-06-03"]?.note).toBe(`${N}A`);
  });
});
  • Step 2: Run the test to confirm it fails
npm test -- --run plan.test

Expected: FAIL — resolveGrid is not exported.

  • Step 3: Add resolveGrid to the service

Append to src/services/plan.service.ts:

/**
 * Compute the effective plan cell for every (userId, date) in the
 * given range. Returns a 2D map: cells[userId][dateStr] = ResolvedCell | null.
 *
 * Implementation: load all entries and overrides for the range in two
 * queries, then iterate the dates and resolve each cell in O(1) per
 * (user, date). This avoids N+1 calls to resolveCell.
 */
export async function resolveGrid(
  userIds: number[],
  dateFromStr: string,
  dateToStr: string,
): Promise<Record<number, Record<string, ResolvedCell | null>>> {
  const dateFrom = new Date(dateFromStr);
  const dateTo = new Date(dateToStr);

  const [entries, overrides] = await Promise.all([
    prisma.work_plan_entries.findMany({
      where: {
        user_id: { in: userIds },
        is_deleted: false,
        date_from: { lte: dateTo },
        date_to: { gte: dateFrom },
      },
      orderBy: { created_at: "desc" },
    }),
    prisma.work_plan_overrides.findMany({
      where: {
        user_id: { in: userIds },
        is_deleted: false,
        shift_date: { gte: dateFrom, lte: dateTo },
      },
    }),
  ]);

  // Build a date list (inclusive)
  const dates: string[] = [];
  for (
    let d = new Date(dateFrom);
    d <= dateTo;
    d.setUTCDate(d.getUTCDate() + 1)
  ) {
    dates.push(d.toISOString().slice(0, 10));
  }

  const result: Record<number, Record<string, ResolvedCell | null>> = {};
  for (const uid of userIds) {
    result[uid] = {};
    for (const dateStr of dates) {
      // Override first
      const override = overrides.find(
        (o) =>
          o.user_id === uid &&
          o.shift_date.toISOString().slice(0, 10) === dateStr,
      );
      if (override) {
        result[uid][dateStr] = {
          source: "override",
          entryId: null,
          overrideId: override.id,
          user_id: override.user_id,
          shift_date: dateStr,
          project_id: override.project_id,
          category: override.category,
          note: override.note,
          rangeFrom: null,
          rangeTo: null,
        };
        continue;
      }
      // First (latest by created_at) matching entry
      const entry = entries.find(
        (e) =>
          e.user_id === uid &&
          e.date_from <= new Date(dateStr) &&
          e.date_to >= new Date(dateStr),
      );
      if (entry) {
        result[uid][dateStr] = {
          source: "entry",
          entryId: entry.id,
          overrideId: null,
          user_id: entry.user_id,
          shift_date: dateStr,
          project_id: entry.project_id,
          category: entry.category,
          note: entry.note,
          rangeFrom: entry.date_from.toISOString().slice(0, 10),
          rangeTo: entry.date_to.toISOString().slice(0, 10),
        };
      } else {
        result[uid][dateStr] = null;
      }
    }
  }

  return result;
}
  • Step 4: Run the test to confirm it passes
npm test -- --run plan.test

Expected: 6 tests pass.

  • Step 5: Commit
git add src/services/plan.service.ts src/__tests__/plan.test.ts
git commit -m "feat(plan): resolveGrid for batch cell resolution"

Task 5: Plan Service — listPlanUsers

Files:

  • Modify: src/services/plan.service.ts
  • Modify: src/__tests__/plan.test.ts

listPlanUsers() returns users with the attendance.record permission, ordered by role/team then name. The page sidebar and grid use this.

  • Step 1: Append the failing test
import { listPlanUsers } from "../services/plan.service";

describe("plan.service.listPlanUsers", () => {
  it("returns at least the admin user", async () => {
    const users = await listPlanUsers();
    const found = users.find((u) => u.id === adminUserId);
    expect(found).toBeDefined();
    expect(found?.has_attendance_record).toBe(true);
  });
});
  • Step 2: Run the test to confirm it fails
npm test -- --run plan.test

Expected: FAIL — listPlanUsers is not exported.

  • Step 3: Add listPlanUsers to the service

Inspect the existing users model to find the role/permission relationship, then append:

export interface PlanUser {
  id: number;
  full_name: string;
  username: string;
  role_name: string | null;
  is_active: boolean;
  has_attendance_record: boolean;
}

/**
 * Return users that should appear as columns in the plan grid:
 * everyone with the `attendance.record` permission, ordered by
 * role/team name then full_name. Admins are included by default.
 *
 * Implementation note: we join the `role_permissions` and `permissions`
 * tables (as the existing users service does — see src/services/users.service.ts)
 * and filter in SQL. We exclude soft-deleted users if the model has
 * is_deleted (check schema; if not present, omit the where clause).
 */
export async function listPlanUsers(): Promise<PlanUser[]> {
  const rows = await prisma.users.findMany({
    where: {
      is_deleted: false,
      role: {
        role_permissions: {
          some: { permission: { name: "attendance.record" } },
        },
      },
    },
    include: {
      role: {
        select: { name: true },
      },
    },
    orderBy: [{ role: { name: "asc" } }, { full_name: "asc" }],
  });

  return rows.map((u) => ({
    id: u.id,
    full_name: u.full_name,
    username: u.username,
    role_name: u.role?.name ?? null,
    is_active: u.is_active ?? true,
    has_attendance_record: true,
  }));
}

Adapt the relation name to your actual schema. Inspect prisma/role_permissions and prisma/permissions to confirm field names; the example above assumes role.role_permissions.permission.name. If the model has different relation names, adjust. Run npx prisma generate after any schema tweak.

  • Step 4: Run the test to confirm it passes
npm test -- --run plan.test

Expected: 7 tests pass.

  • Step 5: Commit
git add src/services/plan.service.ts src/__tests__/plan.test.ts
git commit -m "feat(plan): listPlanUsers with role/permission filter"

Task 6: Plan Service — Past-Date Lock Helper

Files:

  • Modify: src/services/plan.service.ts
  • Modify: src/__tests__/plan.test.ts

assertNotPastDate(dateStr, force) returns { error, status } if the date is in the past and force is not set. Service-level guard shared by all write paths.

  • Step 1: Append the failing tests
import { assertNotPastDate } from "../services/plan.service";

describe("plan.service.assertNotPastDate", () => {
  it("returns error for a past date", () => {
    const result = assertNotPastDate("2000-01-01", false);
    expect(result).toEqual({
      error:
        "Nelze upravovat plán pro datum v minulosti. Pro nouzovou opravu použijte ?force=1.",
      status: 403,
    });
  });

  it("returns null for a future date", () => {
    const result = assertNotPastDate("2099-01-01", false);
    expect(result).toBeNull();
  });

  it("returns null for a past date with force=true", () => {
    const result = assertNotPastDate("2000-01-01", true);
    expect(result).toBeNull();
  });
});
  • Step 2: Run the test to confirm it fails
npm test -- --run plan.test

Expected: FAIL — assertNotPastDate is not exported.

  • Step 3: Add the helper to the service

Append to src/services/plan.service.ts:

/**
 * Guard helper: returns { error, status } if `dateStr` is in the past
 * and `force` is not set. Returns null if the date is editable.
 *
 * The comparison uses local-date string equality. Per CLAUDE.md the
 * process runs in Europe/Prague, but for past-date guard purposes
 * any "yesterday or earlier" date is locked.
 */
export function assertNotPastDate(
  dateStr: string,
  force: boolean,
): { error: string; status: number } | null {
  if (force) return null;

  const today = new Date();
  const todayStr = today.toISOString().slice(0, 10);
  if (dateStr < todayStr) {
    return {
      error:
        "Nelze upravovat plán pro datum v minulosti. Pro nouzovou opravu použijte ?force=1.",
      status: 403,
    };
  }
  return null;
}
  • Step 4: Run the test to confirm it passes
npm test -- --run plan.test

Expected: 10 tests pass.

  • Step 5: Commit
git add src/services/plan.service.ts src/__tests__/plan.test.ts
git commit -m "feat(plan): assertNotPastDate guard for write paths"

Task 7: Plan Service — Entry CRUD

Files:

  • Modify: src/services/plan.service.ts
  • Modify: src/__tests__/plan.test.ts

createEntry, updateEntry, deleteEntry for work_plan_entries. Each returns { data } or { error, status } and writes to audit_logs.

  • Step 1: Append the failing tests
import {
  createEntry,
  updateEntry,
  deleteEntry,
} from "../services/plan.service";

let noPermUserId: number;
let noPermToken: string;
let adminToken: string;
let app: Awaited<ReturnType<typeof buildApp>>;

async function buildApp() {
  const app = Fastify({ logger: false });
  await app.register(cookie);
  await app.register(rateLimit, { max: 1000, timeWindow: "1 minute" });
  app.addHook("onRequest", securityHeaders);
  await app.register(planRoutes, { prefix: "/api/admin/plan" });
  return app;
}

function generateToken(user: {
  id: number;
  username: string;
  roleName: string | null;
}) {
  return jwt.sign(
    { sub: user.id, username: user.username, role: user.roleName },
    config.jwt.secret,
    { expiresIn: "15m" },
  );
}

beforeAll(async () => {
  app = await buildApp();
  const admin = await prisma.users.findFirst({
    where: { role: { name: "admin" } },
    include: { role: true },
  });
  adminUserId = admin!.id;
  adminToken = generateToken({
    id: admin!.id,
    username: admin!.username,
    roleName: admin!.role?.name ?? null,
  });

  // Create a no-permission user for scoping tests
  const noPermRole = await prisma.roles.create({
    data: { name: `noperm_${Date.now()}`, display_name: "No Perm Test" },
  });
  const noPermUser = await prisma.users.create({
    data: {
      username: `noperm_${Date.now()}`,
      full_name: "No Perm Test",
      email: `noperm_${Date.now()}@test.local`,
      password_hash:
        "$2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali",
      role_id: noPermRole.id,
      is_active: true,
    },
  });
  noPermUserId = noPermUser.id;
  noPermToken = generateToken({
    id: noPermUser.id,
    username: noPermUser.username,
    roleName: noPermRole.name,
  });
});

afterAll(async () => {
  await app.close();
});

describe("plan.service.createEntry", () => {
  it("creates an entry and returns the row with oldData=null", async () => {
    const result = await createEntry(
      {
        user_id: adminUserId,
        date_from: "2099-07-01",
        date_to: "2099-07-05",
        category: "work",
        note: `${N}new entry`,
      },
      adminUserId,
      false,
    );
    expect("data" in result).toBe(true);
    if ("data" in result) {
      expect(result.data.note).toBe(`${N}new entry`);
      // The route handler relies on `oldData` to build audit entries; for
      // a brand-new row it must be `null` (not undefined) so the audit
      // shape is unambiguous.
      expect(result.oldData).toBeNull();
    }
  });

  it("rejects a past date without force", async () => {
    const result = await createEntry(
      {
        user_id: adminUserId,
        date_from: "2000-01-01",
        date_to: "2000-01-02",
        category: "work",
        note: `${N}past`,
      },
      adminUserId,
      false,
    );
    expect("error" in result).toBe(true);
    if ("error" in result) expect(result.status).toBe(403);
  });

  it("allows a past date with force", async () => {
    const result = await createEntry(
      {
        user_id: adminUserId,
        date_from: "2000-01-01",
        date_to: "2000-01-02",
        category: "work",
        note: `${N}past forced`,
      },
      adminUserId,
      true,
    );
    expect("data" in result).toBe(true);
    // Audit-log verification for the force description is done in the
    // HTTP-level test (Task 12), which exercises the full route → service
    // → audit path.
  });

  it("rejects when date_to < date_from", async () => {
    const result = await createEntry(
      {
        user_id: adminUserId,
        date_from: "2099-07-10",
        date_to: "2099-07-05",
        category: "work",
        note: `${N}invalid range`,
      },
      adminUserId,
      false,
    );
    expect("error" in result).toBe(true);
  });
});

describe("plan.service.updateEntry", () => {
  it("updates the note and returns oldData with the pre-update row", async () => {
    const created = await createEntry(
      {
        user_id: adminUserId,
        date_from: "2099-08-01",
        date_to: "2099-08-03",
        category: "work",
        note: `${N}original`,
      },
      adminUserId,
      false,
    );
    if (!("data" in created)) throw new Error("setup failed");

    const updated = await updateEntry(
      created.data.id,
      { note: `${N}updated` },
      adminUserId,
      false,
    );
    expect("data" in updated).toBe(true);
    if ("data" in updated) {
      expect(updated.data.note).toBe(`${N}updated`);
      // `oldData` is what the route passes to logAudit as the prior row.
      expect((updated as any).oldData?.note).toBe(`${N}original`);
    }
  });
});

describe("plan.service.deleteEntry", () => {
  it("soft-deletes the row and returns oldData with the pre-delete row", async () => {
    const created = await createEntry(
      {
        user_id: adminUserId,
        date_from: "2099-09-01",
        date_to: "2099-09-01",
        category: "work",
        note: `${N}to delete`,
      },
      adminUserId,
      false,
    );
    if (!("data" in created)) throw new Error("setup failed");

    const result = await deleteEntry(created.data.id, adminUserId, false);
    expect("data" in result).toBe(true);
    // `oldData` is what the route passes to logAudit as the prior row.
    expect((result as any).oldData?.note).toBe(`${N}to delete`);

    const stillThere = await prisma.work_plan_entries.findUnique({
      where: { id: created.data.id },
    });
    expect(stillThere?.is_deleted).toBe(true);
  });
});
  • Step 2: Run the test to confirm it fails
npm test -- --run plan.test

Expected: FAIL — createEntry is not exported.

  • Step 3: Add CRUD functions to the service

Append to src/services/plan.service.ts:

type Result<T> =
  | { data: T; oldData: unknown | null }
  | { error: string; status: number };

function toDateOnly(dateStr: string): Date {
  return new Date(dateStr + "T00:00:00.000Z");
}

function todayStr(): string {
  return new Date().toISOString().slice(0, 10);
}

/** Create a work_plan_entries row. Past dates require force=true.
 * Returns the created row in `data` and `oldData: null` (the route handler
 * uses these to build an audit log entry). */
export async function createEntry(
  input: {
    user_id: number;
    date_from: string;
    date_to: string;
    project_id?: number | null;
    category: string;
    note: string;
  },
  actorUserId: number,
  force: boolean,
): Promise<
  Result<{
    id: number;
    user_id: number;
    date_from: string;
    date_to: string;
    project_id: number | null;
    category: string;
    note: string;
    created_by: number;
    created_at: Date;
    updated_at: Date;
    is_deleted: boolean | null;
  }>
> {
  // Range check
  if (input.date_to < input.date_from) {
    return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
  }
  // Past-date lock on the from-date (the earliest editable point)
  const lock = assertNotPastDate(input.date_from, force);
  if (lock) return lock;

  const created = await prisma.work_plan_entries.create({
    data: {
      user_id: input.user_id,
      date_from: toDateOnly(input.date_from),
      date_to: toDateOnly(input.date_to),
      project_id: input.project_id ?? null,
      category: input.category as any,
      note: input.note,
      created_by: actorUserId,
    },
  });

  return { data: created as any, oldData: null };
}

/** Update a work_plan_entries row. Partial update.
 * Returns the updated row in `data` and the pre-update row in `oldData`
 * (the route handler uses both to build an audit log entry). */
export async function updateEntry(
  id: number,
  input: {
    date_from?: string;
    date_to?: string;
    project_id?: number | null;
    category?: string;
    note?: string;
  },
  actorUserId: number,
  force: boolean,
): Promise<Result<any>> {
  const existing = await prisma.work_plan_entries.findUnique({ where: { id } });
  if (!existing || existing.is_deleted) {
    return { error: "Záznam nenalezen", status: 404 };
  }

  // Past-date lock on the existing or new from-date
  const fromStr =
    input.date_from ?? existing.date_from.toISOString().slice(0, 10);
  const lock = assertNotPastDate(fromStr, force);
  if (lock) return lock;

  if (input.date_from && input.date_to && input.date_to < input.date_from) {
    return { error: "Datum do musí být stejné nebo po datumu od", status: 400 };
  }

  const updated = await prisma.work_plan_entries.update({
    where: { id },
    data: {
      date_from: input.date_from ? toDateOnly(input.date_from) : undefined,
      date_to: input.date_to ? toDateOnly(input.date_to) : undefined,
      project_id: input.project_id === undefined ? undefined : input.project_id,
      category: input.category as any,
      note: input.note,
    },
  });

  return { data: updated, oldData: existing };
}

/** Soft-delete a work_plan_entries row.
 * Returns `{ ok: true }` in `data` and the pre-delete row in `oldData`. */
export async function deleteEntry(
  id: number,
  actorUserId: number,
  force: boolean,
): Promise<Result<{ ok: true }>> {
  const existing = await prisma.work_plan_entries.findUnique({ where: { id } });
  if (!existing || existing.is_deleted) {
    return { error: "Záznam nenalezen", status: 404 };
  }
  const fromStr = existing.date_from.toISOString().slice(0, 10);
  const lock = assertNotPastDate(fromStr, force);
  if (lock) return lock;

  await prisma.work_plan_entries.update({
    where: { id },
    data: { is_deleted: true },
  });

  return { data: { ok: true }, oldData: existing };
}

Note on Prisma input types: the category: input.category as any cast avoids having to import the Prisma enum at runtime. The Zod schema already constrains the value to a valid enum member, so the cast is safe. If your team prefers strict types, replace with category: input.category as keyof typeof plan_category after importing the enum from @prisma/client.

  • Step 4: Run the test to confirm it passes
npm test -- --run plan.test

Expected: 16 tests pass (4 resolveCell + 2 resolveGrid + 1 listPlanUsers + 3 assertNotPastDate + 4 createEntry + 1 updateEntry + 1 deleteEntry).

  • Step 5: Commit
git add src/services/plan.service.ts src/__tests__/plan.test.ts
git commit -m "feat(plan): entry CRUD with past-date lock and audit log"

Task 8: Plan Service — Override CRUD

Files:

  • Modify: src/services/plan.service.ts
  • Modify: src/__tests__/plan.test.ts

Same shape as Task 7, but for work_plan_overrides. The service must enforce "at most one active override per (user_id, shift_date)" in application code (per the spec's MySQL unique-index caveat).

  • Step 1: Append the failing tests
import {
  createOverride,
  updateOverride,
  deleteOverride,
} from "../services/plan.service";

describe("plan.service.createOverride", () => {
  it("creates an override and returns the row with oldData=null", async () => {
    const result = await createOverride(
      {
        user_id: adminUserId,
        shift_date: "2099-10-01",
        category: "leave",
        note: `${N}day off`,
      },
      adminUserId,
      false,
    );
    expect("data" in result).toBe(true);
    if ("data" in result) {
      expect(result.data.note).toBe(`${N}day off`);
      expect(result.oldData).toBeNull();
    }
  });

  it("replaces an existing override for the same user-day, returning replacedData", async () => {
    const first = await createOverride(
      {
        user_id: adminUserId,
        shift_date: "2099-10-02",
        category: "leave",
        note: `${N}first`,
      },
      adminUserId,
      false,
    );
    if (!("data" in first)) throw new Error("setup");

    const second = await createOverride(
      {
        user_id: adminUserId,
        shift_date: "2099-10-02",
        category: "sick",
        note: `${N}second`,
      },
      adminUserId,
      false,
    );
    expect("data" in second).toBe(true);
    // `replacedData` is the auto-soft-deleted row; the route uses it to
    // emit a "delete" audit log entry in addition to the "create" entry.
    expect((second as any).replacedData?.id).toBe(first.data.id);
    const all = await prisma.work_plan_overrides.findMany({
      where: { user_id: adminUserId, shift_date: new Date("2099-10-02") },
    });
    // The first should be soft-deleted, the second active
    expect(all.find((o) => o.note === `${N}first`)?.is_deleted).toBe(true);
    expect(all.find((o) => o.note === `${N}second`)?.is_deleted).toBe(false);
  });

  it("rejects a past date without force", async () => {
    const result = await createOverride(
      {
        user_id: adminUserId,
        shift_date: "2000-01-01",
        category: "leave",
        note: `${N}past`,
      },
      adminUserId,
      false,
    );
    expect("error" in result).toBe(true);
  });
});

describe("plan.service.updateOverride", () => {
  it("updates the note and returns oldData with the pre-update row", async () => {
    const created = await createOverride(
      {
        user_id: adminUserId,
        shift_date: "2099-11-01",
        category: "leave",
        note: `${N}original`,
      },
      adminUserId,
      false,
    );
    if (!("data" in created)) throw new Error("setup");
    const updated = await updateOverride(
      created.data.id,
      { note: `${N}updated` },
      adminUserId,
      false,
    );
    expect("data" in updated).toBe(true);
    if ("data" in updated) {
      expect(updated.data.note).toBe(`${N}updated`);
      expect((updated as any).oldData?.note).toBe(`${N}original`);
    }
  });
});

describe("plan.service.deleteOverride", () => {
  it("soft-deletes the override and returns oldData with the pre-delete row", async () => {
    const created = await createOverride(
      {
        user_id: adminUserId,
        shift_date: "2099-12-01",
        category: "leave",
        note: `${N}to delete`,
      },
      adminUserId,
      false,
    );
    if (!("data" in created)) throw new Error("setup");
    const result = await deleteOverride(created.data.id, adminUserId, false);
    expect("data" in result).toBe(true);
    expect((result as any).oldData?.note).toBe(`${N}to delete`);
    const still = await prisma.work_plan_overrides.findUnique({
      where: { id: created.data.id },
    });
    expect(still?.is_deleted).toBe(true);
  });
});
  • Step 2: Run the test to confirm it fails
npm test -- --run plan.test

Expected: FAIL — createOverride is not exported.

  • Step 3: Add override CRUD to the service

Append to src/services/plan.service.ts:

/** Create a work_plan_overrides row. Replaces any active override for the
 * same (user_id, shift_date) — soft-deletes the old one. Past dates
 * require force=true.
 * Returns the new row in `data` and the pre-delete replaced row (if any)
 * in `replacedData` so the route can emit a "delete" audit log for the
 * auto-soft-deleted row, plus a "create" audit log for the new row. */
export async function createOverride(
  input: {
    user_id: number;
    shift_date: string;
    project_id?: number | null;
    category: string;
    note: string;
  },
  actorUserId: number,
  force: boolean,
): Promise<Result<any> & { replacedData?: unknown }> {
  const lock = assertNotPastDate(input.shift_date, force);
  if (lock) return lock;

  // Application-level enforcement: at most one active override per (user, date).
  // The DB unique constraint exists as a safety net but may be temporarily
  // violated by soft-deleted rows.
  const existing = await prisma.work_plan_overrides.findFirst({
    where: {
      user_id: input.user_id,
      shift_date: toDateOnly(input.shift_date),
      is_deleted: false,
    },
  });
  let replacedData: unknown = null;
  if (existing) {
    await prisma.work_plan_overrides.update({
      where: { id: existing.id },
      data: { is_deleted: true },
    });
    replacedData = existing;
  }

  const created = await prisma.work_plan_overrides.create({
    data: {
      user_id: input.user_id,
      shift_date: toDateOnly(input.shift_date),
      project_id: input.project_id ?? null,
      category: input.category as any,
      note: input.note,
      created_by: actorUserId,
    },
  });

  return { data: created, oldData: null, replacedData };
}

/** Update a work_plan_overrides row.
 * Returns the updated row in `data` and the pre-update row in `oldData`. */
export async function updateOverride(
  id: number,
  input: { project_id?: number | null; category?: string; note?: string },
  actorUserId: number,
  force: boolean,
): Promise<Result<any>> {
  const existing = await prisma.work_plan_overrides.findUnique({
    where: { id },
  });
  if (!existing || existing.is_deleted) {
    return { error: "Záznam nenalezen", status: 404 };
  }
  const dateStr = existing.shift_date.toISOString().slice(0, 10);
  const lock = assertNotPastDate(dateStr, force);
  if (lock) return lock;

  const updated = await prisma.work_plan_overrides.update({
    where: { id },
    data: {
      project_id: input.project_id === undefined ? undefined : input.project_id,
      category: input.category as any,
      note: input.note,
    },
  });

  return { data: updated, oldData: existing };
}

/** Soft-delete a work_plan_overrides row.
 * Returns `{ ok: true }` in `data` and the pre-delete row in `oldData`. */
export async function deleteOverride(
  id: number,
  actorUserId: number,
  force: boolean,
): Promise<Result<{ ok: true }>> {
  const existing = await prisma.work_plan_overrides.findUnique({
    where: { id },
  });
  if (!existing || existing.is_deleted) {
    return { error: "Záznam nenalezen", status: 404 };
  }
  const dateStr = existing.shift_date.toISOString().slice(0, 10);
  const lock = assertNotPastDate(dateStr, force);
  if (lock) return lock;

  await prisma.work_plan_overrides.update({
    where: { id },
    data: { is_deleted: true },
  });

  return { data: { ok: true }, oldData: existing };
}
  • Step 4: Run the test to confirm it passes
npm test -- --run plan.test

Expected: 21 tests pass.

  • Step 5: Commit
git add src/services/plan.service.ts src/__tests__/plan.test.ts
git commit -m "feat(plan): override CRUD with replace-existing behavior"

Task 9: Plan Service — listEntries and listOverrides

Files:

  • Modify: src/services/plan.service.ts
  • Modify: src/__tests__/plan.test.ts

Raw-row list endpoints used by the edit modal. Employee scoping lives here.

  • Step 1: Append the failing tests
import { listEntries, listOverrides } from "../services/plan.service";

describe("plan.service.listEntries (employee scoping)", () => {
  it("returns entries for the actor user when scoped", async () => {
    await createEntry(
      {
        user_id: adminUserId,
        date_from: "2098-01-01",
        date_to: "2098-01-05",
        category: "work",
        note: `${N}scope test`,
      },
      adminUserId,
      false,
    );
    const rows = await listEntries(
      { user_id: adminUserId, date_from: "2098-01-01", date_to: "2098-01-31" },
      adminUserId,
      true, // isAdmin
    );
    expect(rows.length).toBeGreaterThan(0);
  });

  it("scopes non-admin to actor user_id when querying for another user", async () => {
    await createEntry(
      {
        user_id: adminUserId,
        date_from: "2098-02-01",
        date_to: "2098-02-05",
        category: "work",
        note: `${N}admin entry`,
      },
      adminUserId,
      false,
    );
    const rows = await listEntries(
      { user_id: adminUserId, date_from: "2098-02-01", date_to: "2098-02-28" },
      noPermUserId,
      false, // not admin
    );
    expect(rows).toEqual([]);
  });
});

describe("plan.service.listOverrides (employee scoping)", () => {
  it("scopes non-admin to actor user_id", async () => {
    await createOverride(
      {
        user_id: adminUserId,
        shift_date: "2098-03-01",
        category: "leave",
        note: `${N}admin override`,
      },
      adminUserId,
      false,
    );
    const rows = await listOverrides(
      { user_id: adminUserId, date_from: "2098-03-01", date_to: "2098-03-31" },
      noPermUserId,
      false,
    );
    expect(rows).toEqual([]);
  });
});
  • Step 2: Run the test to confirm it fails
npm test -- --run plan.test

Expected: FAIL.

  • Step 3: Add the list functions

Append to src/services/plan.service.ts:

/** List raw work_plan_entries rows in a date range, excluding soft-deleted. */
export async function listEntries(
  query: { user_id?: number; date_from: string; date_to: string },
  actorUserId: number,
  isAdmin: boolean,
) {
  const effectiveUserId = isAdmin ? query.user_id : actorUserId;
  return prisma.work_plan_entries.findMany({
    where: {
      user_id: effectiveUserId,
      is_deleted: false,
      date_from: { lte: toDateOnly(query.date_to) },
      date_to: { gte: toDateOnly(query.date_from) },
    },
    orderBy: [{ date_from: "asc" }, { id: "asc" }],
  });
}

/** List raw work_plan_overrides rows in a date range, excluding soft-deleted. */
export async function listOverrides(
  query: { user_id?: number; date_from: string; date_to: string },
  actorUserId: number,
  isAdmin: boolean,
) {
  const effectiveUserId = isAdmin ? query.user_id : actorUserId;
  return prisma.work_plan_overrides.findMany({
    where: {
      user_id: effectiveUserId,
      is_deleted: false,
      shift_date: {
        gte: toDateOnly(query.date_from),
        lte: toDateOnly(query.date_to),
      },
    },
    orderBy: [{ shift_date: "asc" }, { id: "asc" }],
  });
}
  • Step 4: Run the test to confirm it passes
npm test -- --run plan.test

Expected: 24 tests pass.

  • Step 5: Commit
git add src/services/plan.service.ts src/__tests__/plan.test.ts
git commit -m "feat(plan): listEntries/listOverrides with employee scoping"

Phase 3: Routes

Task 10: Route File Skeleton

Files:

  • Create: src/routes/admin/plan.ts

  • Step 1: Create the route file with the registry skeleton

Create src/routes/admin/plan.ts:

import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import {
  requireAuth,
  requirePermission,
  requireAnyPermission,
} from "../../middleware/auth";
import { success, error, parseId } from "../../utils/response";
import { parseBody } from "../../schemas/common";
import { logAudit } from "../../services/audit";
import {
  CreatePlanEntrySchema,
  UpdatePlanEntrySchema,
  CreatePlanOverrideSchema,
  UpdatePlanOverrideSchema,
  PlanRangeQuerySchema,
  PlanGridQuerySchema,
} from "../../schemas/plan.schema";
import {
  resolveGrid,
  listPlanUsers,
  createEntry,
  updateEntry,
  deleteEntry,
  createOverride,
  updateOverride,
  deleteOverride,
  listEntries,
  listOverrides,
} from "../../services/plan.service";

const MAX_RANGE_DAYS = 92;

function isAdminLike(authData: any): boolean {
  return authData?.role === "admin";
}

function daysBetween(from: string, to: string): number {
  const a = new Date(from + "T00:00:00.000Z").getTime();
  const b = new Date(to + "T00:00:00.000Z").getTime();
  return Math.round((b - a) / (1000 * 60 * 60 * 24));
}

export default async function planRoutes(app: FastifyInstance) {
  app.addHook("preHandler", requireAuth);

  // --- GET /plan/users ---
  app.get(
    "/users",
    {
      preHandler: requireAnyPermission(
        "attendance.manage",
        "attendance.record",
      ),
    },
    async (_request: FastifyRequest, reply: FastifyReply) => {
      const users = await listPlanUsers();
      return success(reply, users);
    },
  );

  // --- GET /plan/grid ---
  app.get(
    "/grid",
    {
      preHandler: requireAnyPermission(
        "attendance.manage",
        "attendance.record",
      ),
    },
    async (request: FastifyRequest, reply: FastifyReply) => {
      const parsed = PlanGridQuerySchema.safeParse(request.query);
      if (!parsed.success) {
        return error(reply, parsed.error.errors[0].message, 400);
      }
      const { date_from, date_to, view } = parsed.data;
      if (daysBetween(date_from, date_to) > MAX_RANGE_DAYS) {
        return error(reply, "Maximální rozsah je 92 dní", 400);
      }
      const users = await listPlanUsers();
      const cells = await resolveGrid(
        users.map((u) => u.id),
        date_from,
        date_to,
      );
      return success(reply, { view, date_from, date_to, users, cells });
    },
  );

  // --- GET /plan/entries ---
  app.get(
    "/entries",
    {
      preHandler: requireAnyPermission(
        "attendance.manage",
        "attendance.record",
      ),
    },
    async (request: FastifyRequest, reply: FastifyReply) => {
      const parsed = PlanRangeQuerySchema.safeParse(request.query);
      if (!parsed.success)
        return error(reply, parsed.error.errors[0].message, 400);
      const rows = await listEntries(
        parsed.data,
        request.authData!.userId,
        isAdminLike(request.authData),
      );
      return success(reply, rows);
    },
  );

  // --- GET /plan/overrides ---
  app.get(
    "/overrides",
    {
      preHandler: requireAnyPermission(
        "attendance.manage",
        "attendance.record",
      ),
    },
    async (request: FastifyRequest, reply: FastifyReply) => {
      const parsed = PlanRangeQuerySchema.safeParse(request.query);
      if (!parsed.success)
        return error(reply, parsed.error.errors[0].message, 400);
      const rows = await listOverrides(
        parsed.data,
        request.authData!.userId,
        isAdminLike(request.authData),
      );
      return success(reply, rows);
    },
  );

  // --- POST /plan/entries ---
  app.post(
    "/entries",
    { preHandler: requirePermission("attendance.manage") },
    async (request: FastifyRequest, reply: FastifyReply) => {
      const body = parseBody(CreatePlanEntrySchema, request.body);
      if ("error" in body) return error(reply, body.error, 400);
      const force = request.query && (request.query as any).force === "1";
      const result = await createEntry(
        body.data!,
        request.authData!.userId,
        force,
      );
      if ("error" in result) return error(reply, result.error, result.status);
      if (result.data) {
        await logAudit({
          request,
          authData: request.authData,
          action: "create",
          entityType: "work_plan_entry",
          entityId: (result.data as any).id,
          newValues: result.data as any,
          description: force ? "force-edit past date" : undefined,
        });
      }
      return success(reply, result.data, 201, "Plán vytvořen");
    },
  );

  // --- PATCH /plan/entries/:id ---
  app.patch(
    "/entries/:id",
    { preHandler: requirePermission("attendance.manage") },
    async (request: FastifyRequest, reply: FastifyReply) => {
      const id = parseId((request.params as any).id, reply);
      if (id === null) return;
      const body = parseBody(UpdatePlanEntrySchema, request.body);
      if ("error" in body) return error(reply, body.error, 400);
      const force = request.query && (request.query as any).force === "1";
      const result = await updateEntry(
        id,
        body.data!,
        request.authData!.userId,
        force,
      );
      if ("error" in result) return error(reply, result.error, result.status);
      if (result.data) {
        await logAudit({
          request,
          authData: request.authData,
          action: "update",
          entityType: "work_plan_entry",
          entityId: id,
          oldValues: result.oldData as any,
          newValues: result.data as any,
          description: force ? "force-edit past date" : undefined,
        });
      }
      return success(reply, result.data, 200, "Plán aktualizován");
    },
  );

  // --- DELETE /plan/entries/:id ---
  app.delete(
    "/entries/:id",
    { preHandler: requirePermission("attendance.manage") },
    async (request: FastifyRequest, reply: FastifyReply) => {
      const id = parseId((request.params as any).id, reply);
      if (id === null) return;
      const force = request.query && (request.query as any).force === "1";
      const result = await deleteEntry(id, request.authData!.userId, force);
      if ("error" in result) return error(reply, result.error, result.status);
      if (result.data) {
        await logAudit({
          request,
          authData: request.authData,
          action: "delete",
          entityType: "work_plan_entry",
          entityId: id,
          oldValues: result.oldData as any,
          description: force ? "force-edit past date" : undefined,
        });
      }
      return success(reply, { ok: true }, 200, "Plán smazán");
    },
  );

  // --- POST /plan/overrides ---
  app.post(
    "/overrides",
    { preHandler: requirePermission("attendance.manage") },
    async (request: FastifyRequest, reply: FastifyReply) => {
      const body = parseBody(CreatePlanOverrideSchema, request.body);
      if ("error" in body) return error(reply, body.error, 400);
      const force = request.query && (request.query as any).force === "1";
      const result = (await createOverride(
        body.data!,
        request.authData!.userId,
        force,
      )) as any;
      if ("error" in result) return error(reply, result.error, result.status);
      if (result.data) {
        // If a previous override was auto-soft-deleted to make room, audit
        // the implicit delete with a "replace" description.
        if (result.replacedData) {
          await logAudit({
            request,
            authData: request.authData,
            action: "delete",
            entityType: "work_plan_override",
            entityId: (result.replacedData as any).id,
            oldValues: result.replacedData as any,
            description: "auto-soft-delete before replace",
          });
        }
        await logAudit({
          request,
          authData: request.authData,
          action: "create",
          entityType: "work_plan_override",
          entityId: (result.data as any).id,
          newValues: result.data as any,
          description: force ? "force-edit past date" : undefined,
        });
      }
      return success(reply, result.data, 201, "Přepsání dne vytvořeno");
    },
  );

  // --- PATCH /plan/overrides/:id ---
  app.patch(
    "/overrides/:id",
    { preHandler: requirePermission("attendance.manage") },
    async (request: FastifyRequest, reply: FastifyReply) => {
      const id = parseId((request.params as any).id, reply);
      if (id === null) return;
      const body = parseBody(UpdatePlanOverrideSchema, request.body);
      if ("error" in body) return error(reply, body.error, 400);
      const force = request.query && (request.query as any).force === "1";
      const result = await updateOverride(
        id,
        body.data!,
        request.authData!.userId,
        force,
      );
      if ("error" in result) return error(reply, result.error, result.status);
      if (result.data) {
        await logAudit({
          request,
          authData: request.authData,
          action: "update",
          entityType: "work_plan_override",
          entityId: id,
          oldValues: result.oldData as any,
          newValues: result.data as any,
          description: force ? "force-edit past date" : undefined,
        });
      }
      return success(reply, result.data, 200, "Přepsání aktualizováno");
    },
  );

  // --- DELETE /plan/overrides/:id ---
  app.delete(
    "/overrides/:id",
    { preHandler: requirePermission("attendance.manage") },
    async (request: FastifyRequest, reply: FastifyReply) => {
      const id = parseId((request.params as any).id, reply);
      if (id === null) return;
      const force = request.query && (request.query as any).force === "1";
      const result = await deleteOverride(id, request.authData!.userId, force);
      if ("error" in result) return error(reply, result.error, result.status);
      if (result.data) {
        await logAudit({
          request,
          authData: request.authData,
          action: "delete",
          entityType: "work_plan_override",
          entityId: id,
          oldValues: result.oldData as any,
          description: force ? "force-edit past date" : undefined,
        });
      }
      return success(reply, { ok: true }, 200, "Přepsání smazáno");
    },
  );
}

Check that the parseBody import path matches your src/schemas/common.ts export. Open that file; if it exports a function with a different signature (e.g. returning { success, data }), adjust the call sites in this file to match.

  • Step 2: Verify the routes compile
npx tsc -p tsconfig.server.json --noEmit

Expected: no errors.

  • Step 3: Commit
git add src/routes/admin/plan.ts
git commit -m "feat(plan): REST routes for plan endpoints"

Task 11: Wire Routes in server.ts

Files:

  • Modify: src/server.ts

  • Step 1: Add the import

Open src/server.ts and find the line import warehouseRoutes from "./routes/admin/warehouse";. Add the plan import immediately after it:

import planRoutes from "./routes/admin/plan";
  • Step 2: Register the route prefix

Find the line await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });. Add the plan registration immediately after it:

await app.register(planRoutes, { prefix: "/api/admin/plan" });
  • Step 3: Verify it compiles
npx tsc -p tsconfig.server.json --noEmit

Expected: no errors.

  • Step 4: Commit
git add src/server.ts
git commit -m "feat(plan): wire plan routes into the server"

Task 12: End-to-End Route Tests

Files:

  • Modify: src/__tests__/plan.test.ts

The test file already builds the app via buildApp(). Add HTTP-level tests that exercise the routes.

  • Step 1: Append the route tests
async function authGet(path: string, token: string) {
  return app.inject({
    method: "GET",
    url: path,
    headers: { Authorization: `Bearer ${token}` },
  });
}
async function authPost(path: string, token: string, body: any) {
  return app.inject({
    method: "POST",
    url: path,
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    payload: body,
  });
}
async function authPatch(path: string, token: string, body: any) {
  return app.inject({
    method: "PATCH",
    url: path,
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    payload: body,
  });
}
async function authDelete(path: string, token: string) {
  return app.inject({
    method: "DELETE",
    url: path,
    headers: { Authorization: `Bearer ${token}` },
  });
}

describe("HTTP /api/admin/plan", () => {
  it("GET /users requires auth", async () => {
    const res = await app.inject({ method: "GET", url: "/users" });
    expect(res.statusCode).toBe(401);
  });

  it("GET /users returns plan users for an admin", async () => {
    const res = await authGet("/users", adminToken);
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.success).toBe(true);
    expect(Array.isArray(body.data)).toBe(true);
  });

  it("GET /grid returns days, users, cells", async () => {
    await createEntry(
      {
        user_id: adminUserId,
        date_from: "2097-06-01",
        date_to: "2097-06-05",
        category: "work",
        note: `${N}grid test`,
      },
      adminUserId,
      false,
    );
    const res = await authGet(
      "/grid?date_from=2097-06-01&date_to=2097-06-05",
      adminToken,
    );
    expect(res.statusCode).toBe(200);
    const body = res.json();
    expect(body.data.users).toBeDefined();
    expect(body.data.cells[adminUserId]).toBeDefined();
    expect(body.data.cells[adminUserId]["2097-06-01"].note).toBe(
      `${N}grid test`,
    );
  });

  it("GET /grid rejects ranges > 92 days", async () => {
    const res = await authGet(
      "/grid?date_from=2097-01-01&date_to=2097-12-31",
      adminToken,
    );
    expect(res.statusCode).toBe(400);
  });

  it("POST /entries requires attendance.manage", async () => {
    const res = await authPost("/entries", noPermToken, {
      user_id: adminUserId,
      date_from: "2097-07-01",
      date_to: "2097-07-01",
      category: "work",
      note: `${N}forbidden`,
    });
    expect(res.statusCode).toBe(403);
  });

  it("POST /entries works for admin", async () => {
    const res = await authPost("/entries", adminToken, {
      user_id: adminUserId,
      date_from: "2097-08-01",
      date_to: "2097-08-01",
      category: "work",
      note: `${N}http create`,
    });
    expect(res.statusCode).toBe(201);
  });

  it("POST /overrides with force=1 allows past date", async () => {
    const res = await authPost("/overrides?force=1", adminToken, {
      user_id: adminUserId,
      shift_date: "2000-01-15",
      category: "leave",
      note: `${N}forced past`,
    });
    expect(res.statusCode).toBe(201);

    // The route handler must have written an audit log row with the
    // "force-edit past date" description. This is the HTTP-level proof
    // that the route→service→audit pipeline works end-to-end (the service
    // no longer writes audits itself — the route does).
    const audit = await prisma.audit_logs.findFirst({
      where: {
        entity_type: "work_plan_override",
        description: { contains: "force-edit past date" },
      },
      orderBy: { id: "desc" },
    });
    expect(audit).not.toBeNull();
  });

  it("POST /entries writes a work_plan_entry audit row", async () => {
    const res = await authPost("/entries", adminToken, {
      user_id: adminUserId,
      date_from: "2096-04-01",
      date_to: "2096-04-01",
      category: "work",
      note: `${N}audit verify`,
    });
    expect(res.statusCode).toBe(201);
    const id = (res.json() as any).data.id;
    const audit = await prisma.audit_logs.findFirst({
      where: { entity_type: "work_plan_entry", entity_id: id },
    });
    expect(audit).not.toBeNull();
    expect(audit?.action).toBe("create");
  });
});
  • Step 2: Run the test to confirm it passes
npm test -- --run plan.test

Expected: 32 tests pass (24 service + 8 HTTP).

  • Step 3: Commit
git add src/__tests__/plan.test.ts
git commit -m "test(plan): HTTP route tests with auth, scoping, and force flag"

Phase 4: Frontend

Task 13: React Query Hooks

Files:

  • Create: src/admin/lib/queries/plan.ts

  • Create: src/admin/hooks/usePlanWork.ts

  • Step 1: Create the query module

Create src/admin/lib/queries/plan.ts:

import { queryOptions } from "@tanstack/react-query";
import apiFetch from "../../utils/api";
import { jsonQuery } from "../apiAdapter";

export interface PlanUser {
  id: number;
  full_name: string;
  username: string;
  role_name: string | null;
  is_active: boolean;
  has_attendance_record: boolean;
}

export interface ResolvedCell {
  source: "entry" | "override";
  entryId: number | null;
  overrideId: number | null;
  user_id: number;
  shift_date: string;
  project_id: number | null;
  category: string;
  note: string;
  rangeFrom: string | null;
  rangeTo: string | null;
}

export interface GridData {
  view: "week" | "month";
  date_from: string;
  date_to: string;
  users: PlanUser[];
  cells: Record<number, Record<string, ResolvedCell | null>>;
}

export const planKeys = {
  all: ["plan"] as const,
  grid: (dateFrom: string, dateTo: string, view: "week" | "month") =>
    ["plan", "grid", { dateFrom, dateTo, view }] as const,
  entries: (userId: number | null, dateFrom: string, dateTo: string) =>
    ["plan", "entries", { userId, dateFrom, dateTo }] as const,
  overrides: (userId: number | null, dateFrom: string, dateTo: string) =>
    ["plan", "overrides", { userId, dateFrom, dateTo }] as const,
  users: () => ["plan", "users"] as const,
};

export const gridQuery = (
  dateFrom: string,
  dateTo: string,
  view: "week" | "month",
) =>
  queryOptions({
    queryKey: planKeys.grid(dateFrom, dateTo, view),
    queryFn: () =>
      apiFetch(
        `/api/admin/plan/grid?date_from=${dateFrom}&date_to=${dateTo}&view=${view}`,
      ).then((r) => jsonQuery<GridData>(r)),
  });

export const usersQuery = () =>
  queryOptions({
    queryKey: planKeys.users(),
    queryFn: () =>
      apiFetch("/api/admin/plan/users").then((r) => jsonQuery<PlanUser[]>(r)),
  });

Check the apiAdapter and apiFetch import paths. Open src/admin/lib/apiAdapter.ts and src/admin/utils/api.ts to confirm the helper signatures. Adjust the imports if the project uses different paths or wrappers.

  • Step 2: Create the page state hook

Create src/admin/hooks/usePlanWork.ts:

import { useState, useCallback, useMemo } from "react";
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
import apiFetch from "../utils/api";
import { planKeys, GridData, ResolvedCell } from "../lib/queries/plan";

export type ViewMode = "week" | "month";
export type ModalMode =
  | { kind: "closed" }
  | { kind: "create"; userId: number; date: string }
  | { kind: "edit-range"; entryId: number; userId: number; date: string }
  | { kind: "edit-override"; overrideId: number; userId: number; date: string }
  | { kind: "day-in-range"; userId: number; date: string; entryId: number }
  | { kind: "view"; userId: number; date: string };

function isoDate(d: Date): string {
  return d.toISOString().slice(0, 10);
}

function startOfWeek(d: Date): Date {
  const day = d.getUTCDay(); // 0 = Sun
  const diff = day === 0 ? -6 : 1 - day; // Monday-start
  const r = new Date(d);
  r.setUTCDate(d.getUTCDate() + diff);
  return r;
}

function addDays(d: Date, n: number): Date {
  const r = new Date(d);
  r.setUTCDate(d.getUTCDate() + n);
  return r;
}

export interface UsePlanWorkArgs {
  initialDate?: Date;
  canEdit: boolean;
}

export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
  const qc = useQueryClient();
  const [view, setView] = useState<ViewMode>("week");
  const [anchor, setAnchor] = useState<Date>(initialDate ?? new Date());
  const [filterActive, setFilterActive] = useState(true);
  const [modal, setModal] = useState<ModalMode>({ kind: "closed" });

  const range = useMemo(() => {
    if (view === "week") {
      const from = startOfWeek(anchor);
      const to = addDays(from, 6);
      return { from, to };
    }
    const from = new Date(
      Date.UTC(anchor.getUTCFullYear(), anchor.getUTCMonth(), 1),
    );
    const to = new Date(
      Date.UTC(anchor.getUTCFullYear(), anchor.getUTCMonth() + 1, 0),
    );
    return { from, to };
  }, [view, anchor]);

  const gridQuery = useQuery({
    queryKey: planKeys.grid(isoDate(range.from), isoDate(range.to), view),
    queryFn: () =>
      apiFetch(
        `/api/admin/plan/grid?date_from=${isoDate(range.from)}&date_to=${isoDate(range.to)}&view=${view}`,
      ).then((r) => r.json().then((b: any) => b.data as GridData)),
  });

  const invalidate = useCallback(() => {
    qc.invalidateQueries({ queryKey: planKeys.all });
  }, [qc]);

  // --- Mutations ---
  const createEntry = useMutation({
    mutationFn: (body: any) =>
      apiFetch("/api/admin/plan/entries", {
        method: "POST",
        body: JSON.stringify(body),
        headers: { "Content-Type": "application/json" },
      }),
    onSuccess: invalidate,
  });

  const updateEntry = useMutation({
    mutationFn: ({
      id,
      body,
      force,
    }: {
      id: number;
      body: any;
      force?: boolean;
    }) =>
      apiFetch(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, {
        method: "PATCH",
        body: JSON.stringify(body),
        headers: { "Content-Type": "application/json" },
      }),
    onSuccess: invalidate,
  });

  const deleteEntry = useMutation({
    mutationFn: ({ id, force }: { id: number; force?: boolean }) =>
      apiFetch(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, {
        method: "DELETE",
      }),
    onSuccess: invalidate,
  });

  const createOverride = useMutation({
    mutationFn: (body: any) =>
      apiFetch("/api/admin/plan/overrides", {
        method: "POST",
        body: JSON.stringify(body),
        headers: { "Content-Type": "application/json" },
      }),
    onSuccess: invalidate,
  });

  const updateOverride = useMutation({
    mutationFn: ({
      id,
      body,
      force,
    }: {
      id: number;
      body: any;
      force?: boolean;
    }) =>
      apiFetch(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, {
        method: "PATCH",
        body: JSON.stringify(body),
        headers: { "Content-Type": "application/json" },
      }),
    onSuccess: invalidate,
  });

  const deleteOverride = useMutation({
    mutationFn: ({ id, force }: { id: number; force?: boolean }) =>
      apiFetch(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, {
        method: "DELETE",
      }),
    onSuccess: invalidate,
  });

  return {
    view,
    setView,
    anchor,
    setAnchor,
    filterActive,
    setFilterActive,
    range,
    grid: gridQuery.data,
    gridLoading: gridQuery.isLoading,
    modal,
    setModal,
    canEdit,
    createEntry,
    updateEntry,
    deleteEntry,
    createOverride,
    updateOverride,
    deleteOverride,
    invalidate,
  };
}

export function getCell(
  grid: GridData | undefined,
  userId: number,
  date: string,
): ResolvedCell | null {
  return grid?.cells?.[userId]?.[date] ?? null;
}
  • Step 3: Verify the frontend compiles
npx tsc -p tsconfig.json --noEmit

Expected: no errors. If there are path issues, check tsconfig.json's paths mapping for @/* or similar aliases.

  • Step 4: Commit
git add src/admin/lib/queries/plan.ts src/admin/hooks/usePlanWork.ts
git commit -m "feat(plan): react query options and page state hook"

Task 14: Grid Component

Files:

  • Create: src/admin/components/PlanGrid.tsx

  • Create: src/admin/components/PlanRangeChips.tsx

  • Create: src/admin/plan.css (or merge into attendance.css — see Step 0)

  • Step 0: Decide where the CSS lives

Open src/admin/attendance.css and add a comment header: /* === Plán prací (work schedule) module === */. If the file is already large (>1000 lines), create a separate src/admin/plan.css instead. Pick one and stick with it. The rest of this plan assumes src/admin/plan.css.

  • Step 1: Add the CSS file

Create src/admin/plan.css:

/* === Plán prací (work schedule) module === */

.plan-grid-wrap {
  overflow: auto;
  border: 1px solid var(--border, #e5e7eb);
  border-radius: 8px;
  max-height: calc(100vh - 240px);
}

.plan-grid {
  border-collapse: separate;
  border-spacing: 0;
  width: 100%;
  font-size: 13px;
}

.plan-grid thead th {
  position: sticky;
  top: 0;
  background: var(--bg-secondary, #f9fafb);
  z-index: 2;
  padding: 8px;
  border-bottom: 1px solid var(--border, #e5e7eb);
  text-align: left;
  font-weight: 600;
  white-space: nowrap;
}

.plan-grid thead th.plan-grid-date-col {
  left: 0;
  z-index: 3;
  min-width: 110px;
}

.plan-grid tbody td {
  padding: 6px 8px;
  border-bottom: 1px solid var(--border, #e5e7eb);
  vertical-align: top;
  min-width: 140px;
}

.plan-grid tbody td.plan-grid-date-col {
  position: sticky;
  left: 0;
  background: var(--bg-secondary, #f9fafb);
  z-index: 1;
  font-weight: 500;
  color: var(--text-muted, #6b7280);
  white-space: nowrap;
}

.plan-grid tr.plan-grid-weekend td {
  background: var(--bg-weekend, #fefce8);
}
.plan-grid tr.plan-grid-weekend td.plan-grid-date-col {
  background: var(--bg-weekend-strong, #fef3c7);
}

.plan-cell {
  display: block;
  width: 100%;
  text-align: left;
  background: transparent;
  border: 1px dashed transparent;
  border-radius: 6px;
  padding: 4px 6px;
  cursor: pointer;
  min-height: 40px;
}
.plan-cell:hover {
  border-color: var(--primary, #2563eb);
}
.plan-cell--readonly {
  cursor: default;
}
.plan-cell--readonly:hover {
  border-color: transparent;
}

.plan-chip {
  display: inline-block;
  border-radius: 4px;
  padding: 2px 6px;
  font-size: 12px;
  font-weight: 500;
  color: #1f2937;
  background: var(--chip-default, #e5e7eb);
}
.plan-chip--work {
  background: #dbeafe;
}
.plan-chip--preparation {
  background: #ccfbf1;
}
.plan-chip--travel {
  background: #fde68a;
}
.plan-chip--leave {
  background: #bbf7d0;
}
.plan-chip--sick {
  background: #fecaca;
}
.plan-chip--training {
  background: #e9d5ff;
}
.plan-chip--other {
  background: #e5e7eb;
}

.plan-cell-note {
  display: block;
  margin-top: 2px;
  font-size: 11px;
  color: var(--text-muted, #6b7280);
  line-height: 1.3;
}

.plan-toolbar {
  display: flex;
  gap: 12px;
  align-items: center;
  margin-bottom: 12px;
  flex-wrap: wrap;
}
  • Step 2: Create the chip component

Create src/admin/components/PlanRangeChips.tsx:

import { ResolvedCell } from "../lib/queries/plan";

interface Props {
  cell: ResolvedCell | null;
  readonly?: boolean;
}

const CATEGORY_LABELS: Record<string, string> = {
  work: "Práce",
  preparation: "Příprava",
  travel: "Cesta / Montáž",
  leave: "Dovolená",
  sick: "Nemoc",
  training: "Školení",
  other: "Jiné",
};

export default function PlanRangeChips({ cell, readonly }: Props) {
  if (!cell) return null;
  return (
    <div>
      <span className={`plan-chip plan-chip--${cell.category}`}>
        {CATEGORY_LABELS[cell.category] ?? cell.category}
      </span>
      {cell.project_id && (
        <span style={{ marginLeft: 6, fontSize: 12, fontWeight: 600 }}>
          {/* Project name resolution happens in the parent which has users+projects context */}
          {/* For v1 we show the project_id in the cell; full project name lookup is added in Task 16 */}
          {`#${cell.project_id}`}
        </span>
      )}
      {cell.note && <span className="plan-cell-note">{cell.note}</span>}
    </div>
  );
}
  • Step 3: Create the grid component

Create src/admin/components/PlanGrid.tsx:

import { GridData, ResolvedCell } from "../lib/queries/plan";
import PlanRangeChips from "./PlanRangeChips";

interface Props {
  data: GridData | undefined;
  canEdit: boolean;
  onCellClick: (userId: number, date: string, cell: ResolvedCell | null) => void;
}

const CZECH_WEEKDAYS = ["Ne", "Po", "Út", "St", "Čt", "Pá", "So"];

function eachDay(from: string, to: string): string[] {
  const out: string[] = [];
  const a = new Date(from + "T00:00:00.000Z");
  const b = new Date(to + "T00:00:00.000Z");
  for (let d = new Date(a); d <= b; d.setUTCDate(d.getUTCDate() + 1)) {
    out.push(d.toISOString().slice(0, 10));
  }
  return out;
}

function czechWeekday(dateStr: string): string {
  return CZECH_WEEKDAYS[new Date(dateStr + "T00:00:00.000Z").getUTCDay()];
}

function isWeekend(dateStr: string): boolean {
  const day = new Date(dateStr + "T00:00:00.000Z").getUTCDay();
  return day === 0 || day === 6;
}

export default function PlanGrid({ data, canEdit, onCellClick }: Props) {
  if (!data) return <div className="admin-loading"><div className="admin-spinner" /></div>;
  const days = eachDay(data.date_from, data.date_to);
  const users = data.users;

  return (
    <div className="plan-grid-wrap">
      <table className="plan-grid">
        <thead>
          <tr>
            <th className="plan-grid-date-col">Datum</th>
            {users.map((u) => (
              <th key={u.id}>{u.full_name}</th>
            ))}
          </tr>
        </thead>
        <tbody>
          {days.map((date) => (
            <tr key={date} className={isWeekend(date) ? "plan-grid-weekend" : ""}>
              <td className="plan-grid-date-col">
                {czechWeekday(date)} {date.slice(8)}.{date.slice(5, 7)}.
              </td>
              {users.map((u) => {
                const cell = data.cells[u.id]?.[date] ?? null;
                const cls = `plan-cell${canEdit ? "" : " plan-cell--readonly"}`;
                return (
                  <td key={u.id}>
                    <button
                      type="button"
                      className={cls}
                      onClick={() => onCellClick(u.id, date, cell)}
                    >
                      <PlanRangeChips cell={cell} readonly={!canEdit} />
                    </button>
                  </td>
                );
              })}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
  • Step 4: Verify the frontend compiles
npx tsc -p tsconfig.json --noEmit

Expected: no errors.

  • Step 5: Commit
git add src/admin/components/PlanGrid.tsx src/admin/components/PlanRangeChips.tsx src/admin/plan.css
git commit -m "feat(plan): grid component with sticky headers and weekend tint"

Task 15: Edit Modal

Files:

  • Create: src/admin/components/PlanCellModal.tsx

  • Step 1: Create the edit modal component

Create src/admin/components/PlanCellModal.tsx:

import { useState, useEffect } from "react";
import FormModal from "./FormModal";
import FormField from "./FormField";
import AdminDatePicker from "./AdminDatePicker";
import ConfirmModal from "./ConfirmModal";
import { ResolvedCell } from "../lib/queries/plan";

interface Project {
  id: number;
  name: string;
}

interface Props {
  open: boolean;
  mode:
    | { kind: "create"; userId: number; date: string }
    | { kind: "edit-range"; entryId: number; userId: number; date: string; range: { date_from: string; date_to: string; project_id: number | null; category: string; note: string } }
    | { kind: "edit-override"; overrideId: number; userId: number; date: string; cell: ResolvedCell }
    | { kind: "day-in-range"; userId: number; date: string; range: { id: number; date_from: string; date_to: string; project_id: number | null; category: string; note: string } }
    | { kind: "view"; userId: number; date: string; cell: ResolvedCell }
    | { kind: "closed" };
  projects: Project[];
  onClose: () => void;
  onSaveEntry: (body: any) => Promise<void>;
  onUpdateEntry: (id: number, body: any) => Promise<void>;
  onDeleteEntry: (id: number) => Promise<void>;
  onSaveOverride: (body: any) => Promise<void>;
  onUpdateOverride: (id: number, body: any) => Promise<void>;
  onDeleteOverride: (id: number) => Promise<void>;
  onCreateOverrideFromRange: (entryId: number, userId: number, date: string) => Promise<void>;
}

const CATEGORIES = [
  { value: "work", label: "Práce" },
  { value: "preparation", label: "Příprava" },
  { value: "travel", label: "Cesta / Montáž" },
  { value: "leave", label: "Dovolená" },
  { value: "sick", label: "Nemoc" },
  { value: "training", label: "Školení" },
  { value: "other", label: "Jiné" },
];

export default function PlanCellModal(props: Props) {
  const { open, mode, projects, onClose } = props;
  if (!open || mode.kind === "closed") return null;
  if (mode.kind === "view") return <ViewModal {...props} />;
  if (mode.kind === "day-in-range") return <DayInRangeModal {...props} mode={mode} />;

  // create / edit-range / edit-override all use the same form
  return <EditForm {...props} />;
}

function EditForm(props: Props) {
  const { mode, onClose, onSaveEntry, onUpdateEntry, onDeleteEntry,
          onSaveOverride, onUpdateOverride, onDeleteOverride, projects } = props;
  const [submitting, setSubmitting] = useState(false);
  const [confirmDelete, setConfirmDelete] = useState(false);

  const isEntry = mode.kind === "create" || mode.kind === "edit-range";
  const isOverride = mode.kind === "edit-override";

  const initial = (() => {
    if (mode.kind === "create") {
      return {
        user_id: mode.userId,
        date_from: mode.date,
        date_to: mode.date,
        is_range: false,
        project_id: null as number | null,
        category: "work",
        note: "",
      };
    }
    if (mode.kind === "edit-range") {
      return {
        user_id: mode.userId,
        date_from: mode.range.date_from,
        date_to: mode.range.date_to,
        is_range: mode.range.date_from !== mode.range.date_to,
        project_id: mode.range.project_id,
        category: mode.range.category,
        note: mode.range.note,
      };
    }
    // edit-override
    return {
      user_id: mode.userId,
      date_from: mode.date,
      date_to: mode.date,
      is_range: false,
      project_id: mode.cell.project_id,
      category: mode.cell.category,
      note: mode.cell.note,
    };
  })();

  const [userId, setUserId] = useState<number>(initial.user_id);
  const [dateFrom, setDateFrom] = useState(initial.date_from);
  const [dateTo, setDateTo] = useState(initial.date_to);
  const [isRange, setIsRange] = useState(initial.is_range);
  const [projectId, setProjectId] = useState<number | null>(initial.project_id);
  const [category, setCategory] = useState(initial.category);
  const [note, setNote] = useState(initial.note);

  const title =
    mode.kind === "create" ? "Nový záznam plánu" :
    mode.kind === "edit-range" ? "Upravit rozsah" :
    "Upravit přepsání dne";

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setSubmitting(true);
    try {
      if (mode.kind === "create") {
        await onSaveEntry({
          user_id: userId,
          date_from: dateFrom,
          date_to: isRange ? dateTo : dateFrom,
          project_id: projectId,
          category,
          note,
        });
      } else if (mode.kind === "edit-range") {
        await onUpdateEntry(mode.entryId, {
          date_from: dateFrom,
          date_to: isRange ? dateTo : dateFrom,
          project_id: projectId,
          category,
          note,
        });
      } else {
        await onUpdateOverride(mode.overrideId, {
          project_id: projectId,
          category,
          note,
        });
      }
      onClose();
    } finally {
      setSubmitting(false);
    }
  };

  const handleDelete = async () => {
    setSubmitting(true);
    try {
      if (mode.kind === "edit-range") {
        await onDeleteEntry(mode.entryId);
      } else if (mode.kind === "edit-override") {
        await onDeleteOverride(mode.overrideId);
      }
      onClose();
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <>
      <FormModal
        open={open}
        title={title}
        onClose={onClose}
        onSubmit={handleSubmit}
        submitting={submitting}
        submitLabel={mode.kind === "create" ? "Vytvořit" : "Uložit"}
        extraActions={
          mode.kind !== "create" ? (
            <button
              type="button"
              className="admin-button admin-button--danger"
              onClick={() => setConfirmDelete(true)}
            >
              Smazat
            </button>
          ) : null
        }
      >
        <FormField label="Datum od">
          <AdminDatePicker value={dateFrom} onChange={setDateFrom} disabled={isOverride} />
        </FormField>
        {isEntry && (
          <FormField label="Rozsah dnů">
            <label>
              <input
                type="checkbox"
                checked={isRange}
                onChange={(e) => setIsRange(e.target.checked)}
                disabled={isOverride}
              />{" "}
              Více dní
            </label>
          </FormField>
        )}
        {isEntry && isRange && (
          <FormField label="Datum do">
            <AdminDatePicker value={dateTo} onChange={setDateTo} />
          </FormField>
        )}
        <FormField label="Projekt">
          <select
            value={projectId ?? ""}
            onChange={(e) =>
              setProjectId(e.target.value ? Number(e.target.value) : null)
            }
          >
            <option value=""> bez projektu </option>
            {projects.map((p) => (
              <option key={p.id} value={p.id}>
                {p.name}
              </option>
            ))}
          </select>
        </FormField>
        <FormField label="Kategorie">
          <select value={category} onChange={(e) => setCategory(e.target.value)}>
            {CATEGORIES.map((c) => (
              <option key={c.value} value={c.value}>
                {c.label}
              </option>
            ))}
          </select>
        </FormField>
        <FormField label="Text poznámky" required>
          <textarea
            value={note}
            onChange={(e) => setNote(e.target.value)}
            maxLength={500}
            rows={3}
            required
          />
        </FormField>
      </FormModal>

      <ConfirmModal
        open={confirmDelete}
        title="Smazat záznam plánu?"
        message="Tato akce je nevratná (záznam bude soft-delete)."
        confirmLabel="Smazat"
        onConfirm={handleDelete}
        onCancel={() => setConfirmDelete(false)}
      />
    </>
  );
}

function DayInRangeModal(props: Props & { mode: Extract<Props["mode"], { kind: "day-in-range" }> }) {
  const { mode, onClose, onUpdateEntry, onCreateOverrideFromRange } = props;
  const [busy, setBusy] = useState(false);

  const handleEditRange = async () => {
    setBusy(true);
    try {
      // Switch the modal to edit-range by closing and reopening — for v1
      // the parent will pass a new mode prop. Here we just signal via callback.
      // Implementation: call onUpdateEntry to update the range, but the parent
      // will handle opening the edit form. For simplicity we close and the
      // parent re-opens the form in edit-range mode.
      onClose();
      // Caller is expected to set modal to edit-range; if it doesn't, user
      // can click the cell again. (This is a small UX wrinkle documented
      // as Task 16 polish.)
    } finally {
      setBusy(false);
    }
  };

  return (
    <FormModal
      open={props.open}
      title={`Den ${mode.date} je součástí rozsahu`}
      onClose={onClose}
      onSubmit={() => {}}
      submitting={busy}
      submitLabel=""
      hideSubmit
      extraActions={
        <>
          <button
            type="button"
            className="admin-button"
            onClick={async () => {
              setBusy(true);
              try {
                await onCreateOverrideFromRange(mode.entryId, mode.userId, mode.date);
                onClose();
              } finally { setBusy(false); }
            }}
          >
            Upravit pouze tento den
          </button>
        </>
      }
    >
      <p>Tento den je součástí rozsahu: <strong>{mode.range.date_from}  {mode.range.date_to}</strong></p>
      <p>Co chcete udělat?</p>
      <ul>
        <li>Upravit celý rozsah  otevře editor rozsahu s aktuálními hodnotami.</li>
        <li>Upravit pouze tento den  vytvoří přepsání (override) pro toto datum.</li>
        <li>Zrušit přiřazení tohoto dne  vytvoří override s kategorií Jiné" a prázdnou poznámkou.</li>
        <li>Zavřít — ponechá rozsah beze změny.</li>
      </ul>
    </FormModal>
  );
}

function ViewModal(props: Props) {
  const { mode, onClose } = props;
  if (mode.kind !== "view") return null;
  const c = mode.cell;
  return (
    <FormModal
      open={props.open}
      title="Detail plánu"
      onClose={onClose}
      onSubmit={() => {}}
      submitting={false}
      submitLabel=""
      hideSubmit
    >
      <p><strong>Datum:</strong> {c.shift_date}</p>
      <p><strong>Kategorie:</strong> {c.category}</p>
      <p><strong>Projekt:</strong> {c.project_id ?? "—"}</p>
      <p><strong>Text:</strong> {c.note}</p>
      {c.rangeFrom && c.rangeTo && (
        <p><strong>Patří do rozsahu:</strong> {c.rangeFrom}  {c.rangeTo}</p>
      )}
    </FormModal>
  );
}

Check the FormModal and ConfirmModal props. Open those files and confirm the prop names (open, title, onClose, onSubmit, submitting, submitLabel, extraActions, hideSubmit). Adjust this file if the actual API differs.

  • Step 2: Verify the frontend compiles
npx tsc -p tsconfig.json --noEmit

Expected: no errors. If the FormModal extraActions prop doesn't accept React fragments, replace <>...</> with a single element (e.g. a <div> wrapper).

  • Step 3: Commit
git add src/admin/components/PlanCellModal.tsx
git commit -m "feat(plan): edit modal with create / edit-range / override / view modes"

Task 16: Page

Files:

  • Create: src/admin/pages/PlanWork.tsx

  • Modify: src/admin/AdminApp.tsx

  • Modify: src/admin/components/Sidebar.tsx

  • Modify: src/admin/AdminApp.tsx (CSS import)

  • Step 1: Create the page

Create src/admin/pages/PlanWork.tsx:

import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import Forbidden from "../components/Forbidden";
import FormModal from "../components/FormModal";
import apiFetch from "../utils/api";
import { usePlanWork, ModalMode } from "../hooks/usePlanWork";
import PlanGrid from "../components/PlanGrid";
import PlanCellModal from "../components/PlanCellModal";
import { ResolvedCell, planKeys } from "../lib/queries/plan";

interface Project { id: number; name: string; }

export default function PlanWork() {
  const { hasPermission } = useAuth();
  const alert = useAlert();

  // Permission gate: view-only mode if user has only attendance.record
  const canEdit = hasPermission("attendance.manage");
  const canView = hasPermission("attendance.manage") || hasPermission("attendance.record");
  if (!canView) return <Forbidden />;

  const pw = usePlanWork({ canEdit });

  // Projects for the project dropdown
  const projectsQuery = useQuery({
    queryKey: ["projects", "for-plan"],
    queryFn: () => apiFetch("/api/admin/projects?limit=500").then((r) => r.json().then((b: any) => b.data as Project[])),
    staleTime: 5 * 60 * 1000,
  });
  const projects = projectsQuery.data ?? [];

  // Compute the cell's range data when the modal opens in day-in-range mode
  const [rangeForModal, setRangeForModal] = useState<{ id: number; date_from: string; date_to: string; project_id: number | null; category: string; note: string } | null>(null);

  // Wrap the modal in a richer "view" form that knows the range
  useEffect(() => {
    if (pw.modal.kind !== "day-in-range") {
      setRangeForModal(null);
      return;
    }
    const cell = pw.grid?.cells[pw.modal.userId]?.[pw.modal.date];
    if (!cell || !cell.entryId) return;
    // Look up the entry by id from the cells map (we have the source cell).
    // We need date_from/date_to which are stored in cell.rangeFrom/rangeTo.
    if (cell.rangeFrom && cell.rangeTo) {
      setRangeForModal({
        id: cell.entryId,
        date_from: cell.rangeFrom,
        date_to: cell.rangeTo,
        project_id: cell.project_id,
        category: cell.category,
        note: cell.note,
      });
    }
  }, [pw.modal, pw.grid]);

  const handleCellClick = (userId: number, date: string, cell: ResolvedCell | null) => {
    if (!canEdit) {
      if (!cell) {
        // View mode, empty cell: nothing to show
        return;
      }
      pw.setModal({ kind: "view", userId, date, cell });
      return;
    }
    if (!cell) {
      pw.setModal({ kind: "create", userId, date });
      return;
    }
    if (cell.source === "override" && cell.overrideId) {
      pw.setModal({ kind: "edit-override", overrideId: cell.overrideId, userId, date, cell });
      return;
    }
    if (cell.source === "entry" && cell.entryId) {
      // It's part of a range — open the day-in-range chooser
      pw.setModal({ kind: "day-in-range", userId, date, entryId: cell.entryId });
      return;
    }
  };

  // --- Mutation wrappers with error handling ---
  const withError = async (label: string, fn: () => Promise<any>) => {
    try {
      await fn();
    } catch (e: any) {
      const msg = e?.response?.data?.error || e?.message || "Operace selhala";
      alert.error(`${label}: ${msg}`);
      throw e;
    }
  };

  // --- Handlers passed to the modal ---
  const modalProps = {
    open: pw.modal.kind !== "closed",
    mode: pw.modal.kind === "closed"
      ? ({ kind: "closed" } as const)
      : pw.modal.kind === "create"
      ? pw.modal
      : pw.modal.kind === "edit-range"
      ? { ...pw.modal, range: rangeForModal ?? { date_from: pw.modal.date, date_to: pw.modal.date, project_id: null, category: "work", note: "" } }
      : pw.modal.kind === "edit-override"
      ? pw.modal
      : pw.modal.kind === "day-in-range"
      ? { ...pw.modal, range: rangeForModal ?? { date_from: pw.modal.date, date_to: pw.modal.date, project_id: null, category: "work", note: "" } }
      : pw.modal,
    projects,
    onClose: () => pw.setModal({ kind: "closed" }),
    onSaveEntry: async (body: any) => withError("Uložení plánu", () => pw.createEntry.mutateAsync(body).then(() => alert.success("Plán vytvořen"))),
    onUpdateEntry: async (id: number, body: any) => withError("Aktualizace plánu", () => pw.updateEntry.mutateAsync({ id, body }).then(() => alert.success("Plán aktualizován"))),
    onDeleteEntry: async (id: number) => withError("Smazání plánu", () => pw.deleteEntry.mutateAsync({ id }).then(() => alert.success("Plán smazán"))),
    onSaveOverride: async (body: any) => withError("Uložení přepsání", () => pw.createOverride.mutateAsync(body).then(() => alert.success("Přepsání vytvořeno"))),
    onUpdateOverride: async (id: number, body: any) => withError("Aktualizace přepsání", () => pw.updateOverride.mutateAsync({ id, body }).then(() => alert.success("Přepsání aktualizováno"))),
    onDeleteOverride: async (id: number) => withError("Smazání přepsání", () => pw.deleteOverride.mutateAsync({ id }).then(() => alert.success("Přepsání smazáno"))),
    onCreateOverrideFromRange: async (entryId: number, userId: number, date: string) => {
      const cell = pw.grid?.cells[userId]?.[date];
      if (!cell) return;
      await withError("Vytvoření přepsání", () => pw.createOverride.mutateAsync({
        user_id: userId,
        shift_date: date,
        project_id: null,
        category: "other",
        note: "",
      }).then(() => alert.success("Den byl odebrán z rozsahu")));
    },
  };

  return (
    <div>
      <h2>Plán prací</h2>
      <div className="plan-toolbar">
        <div className="admin-segmented">
          <button
            type="button"
            className={pw.view === "week" ? "active" : ""}
            onClick={() => pw.setView("week")}
          >
            Týden
          </button>
          <button
            type="button"
            className={pw.view === "month" ? "active" : ""}
            onClick={() => pw.setView("month")}
          >
            Měsíc
          </button>
        </div>
        <button type="button" onClick={() => pw.setAnchor(new Date(pw.anchor.getTime() - (pw.view === "week" ? 7 : 30) * 86400000))}>
           Předchozí
        </button>
        <button type="button" onClick={() => pw.setAnchor(new Date())}>
          Dnes
        </button>
        <button type="button" onClick={() => pw.setAnchor(new Date(pw.anchor.getTime() + (pw.view === "week" ? 7 : 30) * 86400000))}>
          Další 
        </button>
        <label style={{ marginLeft: 12 }}>
          <input
            type="checkbox"
            checked={pw.filterActive}
            onChange={(e) => pw.setFilterActive(e.target.checked)}
          />{" "}
          Jen aktivní
        </label>
        <span style={{ marginLeft: "auto", color: "#6b7280", fontSize: 13 }}>
          {pw.range.from.toISOString().slice(0, 10)}  {pw.range.to.toISOString().slice(0, 10)}
        </span>
      </div>

      {pw.gridLoading ? (
        <div className="admin-loading"><div className="admin-spinner" /></div>
      ) : (
        <PlanGrid data={pw.grid} canEdit={canEdit} onCellClick={handleCellClick} />
      )}

      <PlanCellModal {...modalProps} />
    </div>
  );
}
  • Step 2: Lazy-load and route the page

Open src/admin/AdminApp.tsx. Find the line const AttendanceLocation = lazy(() => import("./pages/AttendanceLocation"));. Add the PlanWork import after the AttendanceLocation lazy import (in the "Docházka" group):

const PlanWork = lazy(() => import("./pages/PlanWork"));

Find the line <Route path="attendance/location/:id" element={<AttendanceLocation />} />. Add the new route after it:

              <Route path="attendance/plan" element={<PlanWork />} />

Find the line import "./attendance.css";. Add the plan CSS import immediately after it:

import "./plan.css";
  • Step 3: Add the sidebar entry

Open src/admin/components/Sidebar.tsx. Find the "Docházka" section. Add a new menu item after the "Správa bilancí" item (the last one in the section, just before the closing ]):

      {
        path: "/attendance/plan",
        label: "Plán prací",
        permission: ["attendance.manage", "attendance.record"],
        icon: (
          <svg
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            strokeWidth="2"
          >
            <rect x="3" y="4" width="18" height="18" rx="2" />
            <line x1="16" y1="2" x2="16" y2="6" />
            <line x1="8" y1="2" x2="8" y2="6" />
            <line x1="3" y1="10" x2="21" y2="10" />
            <rect x="7" y="14" width="3" height="3" />
            <rect x="14" y="14" width="3" height="3" />
          </svg>
        ),
      },
  • Step 4: Verify the frontend compiles
npx tsc -p tsconfig.json --noEmit

Expected: no errors. The first run may surface small prop mismatches with FormModal, ConfirmModal, or AdminDatePicker — adjust the modal file to match the actual API.

  • Step 5: Commit
git add src/admin/pages/PlanWork.tsx src/admin/AdminApp.tsx src/admin/components/Sidebar.tsx
git commit -m "feat(plan): page, route, sidebar entry, CSS import"

Task 17: Manual Smoke Test

Files: none (no code changes expected).

  • Step 1: Build the production bundle
npm run build

Expected: no errors. If the build fails, fix the issues before continuing.

  • Step 2: Start the dev server (manual)

Per CLAUDE.md, the user manages the dev server. Ask the user to start it:

The frontend and backend are wired. Could you start the dev server so I can verify the page renders and the grid loads? (npm run dev or your usual workflow.)

Wait for the user to confirm it's running.

  • Step 3: Smoke test checklist

Manually verify in the browser:

  1. Log in as an admin user. Open Docházka → Plán prací. The page should render with a week view grid.
  2. Click an empty cell. The edit modal should open with default values.
  3. Fill in a project, category "Práce", and a note. Submit. The cell should now show a chip with the project and note.
  4. Click the same cell. The "Den je součástí rozsahu" chooser should appear. Click "Upravit pouze tento den" — the override form opens.
  5. Switch to month view via the toggle. The grid should reload with a month of dates.
  6. Log out, log in as a regular employee (with attendance.record only). The page should render in read-only mode: cells are non-clickable for editing, and clicking a populated cell opens a view-only detail modal.
  7. Check the audit log: open Nastavení → Audit log, search for work_plan_entry or work_plan_override entity_type. The mutations from step 3 should appear.
  • Step 4: Commit any smoke-test fixes

If anything had to be fixed during the smoke test, commit them individually with clear messages:

git add -A
git commit -m "fix(plan): <describe the issue>"

Phase 5: Release Preparation

Task 18: Bump Version, Build, and Tag

Files:

  • Modify: package.json (manual version bump)

  • Step 1: Bump the version in package.json

Open package.json. Find the "version" field. Bump the minor version (e.g. 1.8.01.9.0). Ask the user which version to use if uncertain.

  • Step 2: Build
npm run build

Expected: clean build.

  • Step 3: Commit the version bump
git add package.json
git commit -m "chore(release): bump version to <new-version>"
git tag -a v<new-version> -m "Plán prací module"

Do not push. The release push + tarball + deploy to production is the user's responsibility per the project's release process (CLAUDE.md "Release Process" section).

  • Step 4: Hand off to the user

Tell the user:

The plan module is complete. All backend tests pass (npm test), the production build is clean (npm run build), and the manual smoke test passed. The release artifacts are ready.

Per the project's release process (CLAUDE.md), the next steps are yours:

  1. Push to Gitea: git push origin master && git push origin v<new-version>
  2. Create the tarball: tar -czf app-ts-<new-version>.tar.gz dist dist-client prisma package.json package-lock.json scripts
  3. Deploy via SSH: scp to boha_admin@192.168.50.100:/tmp/, extract to /var/www/app-ts, run npm install --omit=dev, npx prisma migrate deploy, pm2 restart app-ts --update-env
  4. Smoke-test on production: log in, open Docházka → Plán prací, add a one-day entry, reload, confirm it shows.

Self-Review Notes (for the plan author)

  • Spec coverage: all 9 spec sections (Core, Effective-cell resolution, Editing rules, Categories, Visibility & permissions, Grid, Audit, Non-goals, Architecture) have at least one implementing task. The new enum is in Task 1, the two new tables in Task 1, the data model in Task 1, the effective-cell resolution in Task 3 (service) and Task 4 (grid), the past-date lock in Task 6, CRUD in Tasks 78, list in Task 9, routes in Task 10, server wiring in Task 11, frontend state in Task 13, grid in Task 14, edit modal in Task 15, page in Task 16, smoke test in Task 17, release in Task 18.
  • Placeholder scan: no "TBD" / "TODO" in the task steps. All "Check" notes in steps 13.1, 15.1, 15.2, 16.4 reference existing files the implementer will read and adjust to — they are not placeholders.
  • Type consistency: the service exports resolveCell, resolveGrid, listPlanUsers, assertNotPastDate, createEntry, updateEntry, deleteEntry, createOverride, updateOverride, deleteOverride, listEntries, listOverrides — these are imported consistently in the route file (Task 10) and the test file. The frontend ResolvedCell, PlanUser, GridData types are defined in src/admin/lib/queries/plan.ts (Task 13.1) and used by PlanGrid (Task 14.3), PlanCellModal (Task 15.1), and the page (Task 16.1). The modal prop mode uses a discriminated union that is referenced consistently.