- 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>
3598 lines
107 KiB
Markdown
3598 lines
107 KiB
Markdown
# 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 7–8, 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.
|