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

3598 lines
107 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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):
```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/<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**
```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<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**
```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<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**
```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<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**
```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<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**
```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<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**
```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<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**
```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<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`:
```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<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**
```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<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`:
```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 <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**
```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<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**
```bash
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**
```bash
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`:
```typescript
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):
```typescript
const PlanWork = lazy(() => import("./pages/PlanWork"));
```
Find the line `<Route path="attendance/location/:id" element={<AttendanceLocation />} />`. Add the new route after it:
```typescript
<Route path="attendance/plan" element={<PlanWork />} />
```
Find the line `import "./attendance.css";`. Add the plan CSS import immediately after it:
```typescript
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 `]`):
```typescript
{
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**
```bash
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**
```bash
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**
```bash
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:
```bash
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.0``1.9.0`). Ask the user which version to use if uncertain.
- [ ] **Step 2: Build**
```bash
npm run build
```
Expected: clean build.
- [ ] **Step 3: Commit the version bump**
```bash
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.