# 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/_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 `} />` | | `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): ```bash 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): ```prisma 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): ```prisma 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`: ```prisma 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): ```prisma 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: ```prisma work_plan_entries work_plan_entries[] work_plan_overrides work_plan_overrides[] ``` - [ ] **Step 5: Generate and apply the migration** ```bash npx prisma migrate dev --name add_work_plan ``` Expected: a new folder `prisma/migrations/_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** ```bash 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** ```bash 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: ```typescript 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** ```bash npx tsc -p tsconfig.server.json --noEmit ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash 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. ```typescript 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** ```bash 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`: ```typescript 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 { 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** ```bash npm test -- --run plan.test ``` Expected: 4 tests pass. - [ ] **Step 5: Commit** ```bash 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): ```typescript 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** ```bash 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`: ```typescript /** * 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>> { 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> = {}; 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** ```bash npm test -- --run plan.test ``` Expected: 6 tests pass. - [ ] **Step 5: Commit** ```bash 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** ```typescript 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** ```bash 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: ```typescript 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 { 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** ```bash npm test -- --run plan.test ``` Expected: 7 tests pass. - [ ] **Step 5: Commit** ```bash 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** ```typescript 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** ```bash 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`: ```typescript /** * 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** ```bash npm test -- --run plan.test ``` Expected: 10 tests pass. - [ ] **Step 5: Commit** ```bash 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** ```typescript import { createEntry, updateEntry, deleteEntry, } from "../services/plan.service"; let noPermUserId: number; let noPermToken: string; let adminToken: string; let app: Awaited>; 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** ```bash 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`: ```typescript type Result = | { 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> { 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> { 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** ```bash 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** ```bash 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** ```typescript 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** ```bash 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`: ```typescript /** 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 & { 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> { 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> { 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** ```bash npm test -- --run plan.test ``` Expected: 21 tests pass. - [ ] **Step 5: Commit** ```bash 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** ```typescript 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** ```bash npm test -- --run plan.test ``` Expected: FAIL. - [ ] **Step 3: Add the list functions** Append to `src/services/plan.service.ts`: ```typescript /** 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** ```bash npm test -- --run plan.test ``` Expected: 24 tests pass. - [ ] **Step 5: Commit** ```bash 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`: ```typescript 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** ```bash npx tsc -p tsconfig.server.json --noEmit ``` Expected: no errors. - [ ] **Step 3: Commit** ```bash 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: ```typescript 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: ```typescript await app.register(planRoutes, { prefix: "/api/admin/plan" }); ``` - [ ] **Step 3: Verify it compiles** ```bash npx tsc -p tsconfig.server.json --noEmit ``` Expected: no errors. - [ ] **Step 4: Commit** ```bash 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** ```typescript 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** ```bash npm test -- --run plan.test ``` Expected: 32 tests pass (24 service + 8 HTTP). - [ ] **Step 3: Commit** ```bash 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`: ```typescript 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>; } 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(r)), }); export const usersQuery = () => queryOptions({ queryKey: planKeys.users(), queryFn: () => apiFetch("/api/admin/plan/users").then((r) => jsonQuery(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`: ```typescript 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("week"); const [anchor, setAnchor] = useState(initialDate ?? new Date()); const [filterActive, setFilterActive] = useState(true); const [modal, setModal] = useState({ 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** ```bash 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** ```bash 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`: ```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`: ```typescript import { ResolvedCell } from "../lib/queries/plan"; interface Props { cell: ResolvedCell | null; readonly?: boolean; } const CATEGORY_LABELS: Record = { 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 (
{CATEGORY_LABELS[cell.category] ?? cell.category} {cell.project_id && ( {/* 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}`} )} {cell.note && {cell.note}}
); } ``` - [ ] **Step 3: Create the grid component** Create `src/admin/components/PlanGrid.tsx`: ```typescript 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
; const days = eachDay(data.date_from, data.date_to); const users = data.users; return (
{users.map((u) => ( ))} {days.map((date) => ( {users.map((u) => { const cell = data.cells[u.id]?.[date] ?? null; const cls = `plan-cell${canEdit ? "" : " plan-cell--readonly"}`; return ( ); })} ))}
Datum{u.full_name}
{czechWeekday(date)} {date.slice(8)}.{date.slice(5, 7)}.
); } ``` - [ ] **Step 4: Verify the frontend compiles** ```bash npx tsc -p tsconfig.json --noEmit ``` Expected: no errors. - [ ] **Step 5: Commit** ```bash 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`: ```typescript 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; onUpdateEntry: (id: number, body: any) => Promise; onDeleteEntry: (id: number) => Promise; onSaveOverride: (body: any) => Promise; onUpdateOverride: (id: number, body: any) => Promise; onDeleteOverride: (id: number) => Promise; onCreateOverrideFromRange: (entryId: number, userId: number, date: string) => Promise; } 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 ; if (mode.kind === "day-in-range") return ; // create / edit-range / edit-override all use the same form return ; } 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(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(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 ( <> setConfirmDelete(true)} > Smazat ) : null } > {isEntry && ( )} {isEntry && isRange && ( )}