# Plan Categories Management 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:** Let `attendance.manage` users create, rename, recolor, and retire work-plan categories via a "Správa kategorií" modal above the calendar, replacing the hardcoded `plan_category` enum and CSS colors with database-driven categories.

**Architecture:** New `plan_categories` table (id, key, label, color, sort_order, is_active). Entry/override `category` columns become `VARCHAR(50)` storing the category **key** (existing values preserved). "Remove" = deactivate. The frontend loads categories via React Query and renders chip/tape colors from an inline `--cat-color` custom property instead of per-key CSS rules.

**Tech Stack:** Prisma 6 → MySQL, Fastify 5, Zod 4, React 18 + TanStack Query, Vitest (real test DB).

**Reference spec:** `docs/superpowers/specs/2026-06-06-plan-categories-management-design.md`

> **Formatting note:** a markdown formatter reflowed some fenced `tsx` blocks.
> Single-line JSX-prop snippets may render as an assignment, e.g.
> `categories = { categories };` — read this as "add the JSX prop
> `categories={categories}` to the named element." Likewise a lone
> `{ list.map(...) }` shown with a trailing `;` is a JSX expression, not a
> statement. The larger multi-line code blocks (service, schema, modal
> component, CSS) are correct as-is.

---

## File Structure

- Create `src/schemas/planCategory.schema.ts` — Zod schemas for category CRUD.
- Create `src/services/planCategory.service.ts` — category CRUD + key validation.
- Create `src/__tests__/planCategory.test.ts` — service tests (real DB).
- Create `src/admin/components/PlanCategoriesModal.tsx` — management modal.
- Modify `prisma/schema.prisma` — add model, change `category` columns, drop enum.
- Modify `src/types/index.ts` — add `plan_category` to `EntityType`.
- Modify `src/routes/admin/plan.ts` — category routes + entry/override category validation passthrough.
- Modify `src/services/plan.service.ts` — validate active category key on entry/override writes; resolve category label for audit.
- Modify `src/utils/planAuditDescription.ts` + `src/__tests__/planAuditDescription.test.ts` — `buildPlanAuditDescription` takes a resolved `categoryLabel`.
- Modify `src/schemas/plan.schema.ts` — `category` becomes a non-empty string (not the enum).
- Modify `src/admin/lib/queries/plan.ts` — `PlanCategory` type, `planCategoriesOptions`, `categoryMap`, `planCategoryLabel(key, map)`.
- Modify `src/admin/plan.css` — generic `--cat-color` chip/tape rules (delete 14 per-key rules).
- Modify `src/admin/components/PlanGrid.tsx` — set `--cat-color`, pass label to chips.
- Modify `src/admin/components/PlanRangeChips.tsx` — render label from prop.
- Modify `src/admin/components/PlanCellModal.tsx` — category `<select>` + view label from categories.
- Modify `src/admin/pages/PlanWork.tsx` — load categories, pass down, add button + modal.
- Modify `src/admin/pages/AuditLog.tsx` + `src/admin/utils/dashboardHelpers.ts` — `plan_category` label.

---

## Task 1: Database — `plan_categories` table, enum → varchar, seed

**Files:**

- Modify: `prisma/schema.prisma`
- Create: `prisma/migrations/<timestamp>_plan_categories/migration.sql` (generated, then hand-edited)

⚠️ This task changes the DB. Per CLAUDE.md, **ask the user to stop the dev server and confirm** before running `prisma migrate dev`.

- [ ] **Step 1: Add the model and change the columns in `prisma/schema.prisma`**

Add this model (anywhere among the models):

```prisma
model plan_categories {
  id         Int       @id @default(autoincrement())
  key        String    @unique @db.VarChar(50)
  label      String    @db.VarChar(100)
  color      String    @db.VarChar(7)
  sort_order Int       @default(0)
  is_active  Boolean   @default(true)
  created_at DateTime? @default(now()) @db.DateTime(0)
  updated_at DateTime? @default(now()) @updatedAt @db.DateTime(0)
}
```

In `model work_plan_entries`, change:

```prisma
  category   plan_category   @default(work)
```

to:

```prisma
  category   String          @default("work") @db.VarChar(50)
```

In `model work_plan_overrides`, change:

```prisma
  category   plan_category
```

to:

```prisma
  category   String        @db.VarChar(50)
```

Delete the enum block:

```prisma
enum plan_category {
  work
  preparation
  travel
  leave
  sick
  training
  other
}
```

- [ ] **Step 2: Generate the migration WITHOUT applying it**

Run: `npx prisma migrate dev --create-only --name plan_categories`
Expected: creates `prisma/migrations/<timestamp>_plan_categories/migration.sql` with the CREATE TABLE + the two MODIFY column statements. Nothing applied yet.

- [ ] **Step 3: Append the seed INSERT to the generated `migration.sql`**

Open the new `migration.sql` and add this at the END (after the generated DDL):

```sql
-- Seed the categories that were previously the plan_category enum
INSERT INTO `plan_categories` (`key`, `label`, `color`, `sort_order`, `is_active`) VALUES
  ('work',        'Práce',          '#2563eb', 1, true),
  ('preparation', 'Příprava',       '#0d9488', 2, true),
  ('travel',      'Cesta / Montáž', '#ca8a04', 3, true),
  ('leave',       'Dovolená',       '#16a34a', 4, true),
  ('sick',        'Nemoc',          '#dc2626', 5, true),
  ('training',    'Školení',        '#7c3aed', 6, true),
  ('other',       'Jiné',           '#6b7280', 7, true);
```

Verify the generated DDL changed the columns to `VARCHAR(50)` (MySQL `MODIFY`). If Prisma emitted `DROP`/recreate that would lose data — it should only `MODIFY`. If unsure, stop and inspect.

- [ ] **Step 4: Ask the user to stop the dev server, then apply**

Confirm with the user. Then run: `npx prisma migrate dev`
Expected: "All migrations have been applied" and the seed rows inserted.

- [ ] **Step 5: Regenerate the Prisma client**

Run: `npx prisma generate`
Expected: client regenerated (now `prisma.plan_categories` exists; `category` is `string`).

- [ ] **Step 6: Commit**

```bash
git add prisma/schema.prisma prisma/migrations
git commit -m "feat(plan): add plan_categories table, migrate category enum to varchar"
```

---

## Task 2: Zod schemas for category CRUD

**Files:**

- Create: `src/schemas/planCategory.schema.ts`

- [ ] **Step 1: Create the schema file**

```typescript
import { z } from "zod";

const hexColor = z
  .string()
  .regex(/^#[0-9a-fA-F]{6}$/, "Barva musí být ve formátu #RRGGBB");

export const CreatePlanCategorySchema = z.object({
  label: z
    .string()
    .trim()
    .min(1, "Název je povinný")
    .max(100, "Maximálně 100 znaků"),
  color: hexColor,
});

export const UpdatePlanCategorySchema = z
  .object({
    label: z.string().trim().min(1, "Název je povinný").max(100).optional(),
    color: hexColor.optional(),
    is_active: z.boolean().optional(),
  })
  .refine((v) => Object.keys(v).length > 0, {
    message: "Nic k aktualizaci",
  });

export type CreatePlanCategoryInput = z.infer<typeof CreatePlanCategorySchema>;
export type UpdatePlanCategoryInput = z.infer<typeof UpdatePlanCategorySchema>;
```

- [ ] **Step 2: Commit**

```bash
git add src/schemas/planCategory.schema.ts
git commit -m "feat(plan): add plan category zod schemas"
```

---

## Task 3: Category service + tests (TDD)

**Files:**

- Create: `src/services/planCategory.service.ts`
- Test: `src/__tests__/planCategory.test.ts`

- [ ] **Step 1: Write the failing test**

`src/__tests__/planCategory.test.ts`:

```typescript
import { describe, it, expect, afterAll } from "vitest";
import prisma from "../config/database";
import {
  listPlanCategories,
  createPlanCategory,
  updatePlanCategory,
  deactivatePlanCategory,
  isCategoryKeyActive,
  slugifyCategory,
} from "../services/planCategory.service";

const created: number[] = [];

afterAll(async () => {
  if (created.length) {
    await prisma.plan_categories.deleteMany({ where: { id: { in: created } } });
  }
  await prisma.$disconnect();
});

describe("slugifyCategory", () => {
  it("strips Czech diacritics and non-alphanumerics", () => {
    expect(slugifyCategory("Cesta / Montáž")).toBe("cesta_montaz");
    expect(slugifyCategory("Příprava")).toBe("priprava");
  });
});

describe("plan category service", () => {
  it("seeds are present and ordered", async () => {
    const cats = await listPlanCategories(true);
    const keys = cats.map((c) => c.key);
    expect(keys).toContain("work");
    expect(keys).toContain("other");
  });

  it("creates a category with a unique slug and next sort_order", async () => {
    const res = await createPlanCategory({
      label: "Test Kategorie ZZ",
      color: "#123456",
    });
    if ("error" in res) throw new Error(res.error);
    created.push(res.data.id);
    expect(res.data.key).toBe("test_kategorie_zz");
    expect(res.data.color).toBe("#123456");
    expect(res.data.is_active).toBe(true);
    expect(isCategoryKeyActive("test_kategorie_zz")).resolves.toBe(true);
  });

  it("dedupes a colliding slug", async () => {
    const res = await createPlanCategory({
      label: "Test Kategorie ZZ",
      color: "#222222",
    });
    if ("error" in res) throw new Error(res.error);
    created.push(res.data.id);
    expect(res.data.key).toBe("test_kategorie_zz_2");
  });

  it("updates label and color", async () => {
    const res = await createPlanCategory({
      label: "Test Upd ZZ",
      color: "#aaaaaa",
    });
    if ("error" in res) throw new Error(res.error);
    created.push(res.data.id);
    const upd = await updatePlanCategory(res.data.id, {
      label: "Test Upd2 ZZ",
      color: "#bbbbbb",
    });
    if ("error" in upd) throw new Error(upd.error);
    expect(upd.data.label).toBe("Test Upd2 ZZ");
    expect(upd.data.color).toBe("#bbbbbb");
    expect(upd.data.key).toBe("test_upd_zz"); // key immutable
  });

  it("deactivate hides the key from active check", async () => {
    const res = await createPlanCategory({
      label: "Test Deact ZZ",
      color: "#cccccc",
    });
    if ("error" in res) throw new Error(res.error);
    created.push(res.data.id);
    await deactivatePlanCategory(res.data.id);
    expect(await isCategoryKeyActive("test_deact_zz")).toBe(false);
  });
});
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `npx vitest run src/__tests__/planCategory.test.ts`
Expected: FAIL — `Cannot find module '../services/planCategory.service'`.

- [ ] **Step 3: Implement the service**

`src/services/planCategory.service.ts`:

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

export type CatResult<T> = { data: T } | { error: string; status: number };

/** ASCII slug: strip diacritics, lowercase, non-alphanumerics → underscore. */
export function slugifyCategory(label: string): string {
  return label
    .normalize("NFD")
    .replace(/[̀-ͯ]/g, "")
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "_")
    .replace(/^_+|_+$/g, "");
}

export async function listPlanCategories(includeInactive = true) {
  return prisma.plan_categories.findMany({
    where: includeInactive ? {} : { is_active: true },
    orderBy: [{ sort_order: "asc" }, { id: "asc" }],
  });
}

export async function isCategoryKeyActive(key: string): Promise<boolean> {
  const cat = await prisma.plan_categories.findFirst({
    where: { key, is_active: true },
    select: { id: true },
  });
  return cat !== null;
}

async function uniqueKey(base: string): Promise<string> {
  const root = base || "kategorie";
  let candidate = root;
  let n = 2;
  // Loop until a free key is found. Categories are few, so this is cheap.
  while (
    await prisma.plan_categories.findUnique({
      where: { key: candidate },
      select: { id: true },
    })
  ) {
    candidate = `${root}_${n++}`;
  }
  return candidate;
}

export async function createPlanCategory(input: {
  label: string;
  color: string;
}): Promise<
  CatResult<{
    id: number;
    key: string;
    label: string;
    color: string;
    sort_order: number;
    is_active: boolean;
  }>
> {
  const key = await uniqueKey(slugifyCategory(input.label));
  const max = await prisma.plan_categories.aggregate({
    _max: { sort_order: true },
  });
  const sort_order = (max._max.sort_order ?? 0) + 1;
  const data = await prisma.plan_categories.create({
    data: {
      key,
      label: input.label,
      color: input.color,
      sort_order,
      is_active: true,
    },
  });
  return { data };
}

export async function updatePlanCategory(
  id: number,
  input: { label?: string; color?: string; is_active?: boolean },
): Promise<
  CatResult<{
    id: number;
    key: string;
    label: string;
    color: string;
    sort_order: number;
    is_active: boolean;
  }>
> {
  const existing = await prisma.plan_categories.findUnique({ where: { id } });
  if (!existing) return { error: "Kategorie nenalezena", status: 404 };

  // Guard: never deactivate the last active category.
  if (input.is_active === false && existing.is_active) {
    const activeCount = await prisma.plan_categories.count({
      where: { is_active: true },
    });
    if (activeCount <= 1) {
      return {
        error: "Musí zůstat alespoň jedna aktivní kategorie",
        status: 400,
      };
    }
  }

  const data = await prisma.plan_categories.update({
    where: { id },
    data: {
      label: input.label ?? undefined,
      color: input.color ?? undefined,
      is_active: input.is_active ?? undefined,
    },
  });
  return { data };
}

export async function deactivatePlanCategory(
  id: number,
): Promise<CatResult<{ ok: true }>> {
  const res = await updatePlanCategory(id, { is_active: false });
  if ("error" in res) return res;
  return { data: { ok: true } };
}
```

- [ ] **Step 4: Run the test to verify it passes**

Run: `npx vitest run src/__tests__/planCategory.test.ts`
Expected: PASS (all cases).

- [ ] **Step 5: Commit**

```bash
git add src/services/planCategory.service.ts src/__tests__/planCategory.test.ts
git commit -m "feat(plan): add plan category service with tests"
```

---

## Task 4: Category routes + audit + EntityType

**Files:**

- Modify: `src/types/index.ts` (add `plan_category`)
- Modify: `src/routes/admin/plan.ts`

- [ ] **Step 1: Add the entity type**

In `src/types/index.ts`, in the `EntityType` union, after `"work_plan_override";` change the end to:

```typescript
  | "work_plan_override"
  | "plan_category";
```

- [ ] **Step 2: Add imports and routes in `src/routes/admin/plan.ts`**

Add to the imports at the top:

```typescript
import {
  CreatePlanCategorySchema,
  UpdatePlanCategorySchema,
} from "../../schemas/planCategory.schema";
import {
  listPlanCategories,
  createPlanCategory,
  updatePlanCategory,
  deactivatePlanCategory,
} from "../../services/planCategory.service";
```

Inside `planRoutes`, after the existing `GET /overrides` handler (before the `POST /entries` handler), add:

```typescript
// --- GET /plan/categories (any planner can read; needed for display) ---
app.get(
  "/categories",
  {
    preHandler: requireAnyPermission("attendance.manage", "attendance.record"),
  },
  async (_request: FastifyRequest, reply: FastifyReply) => {
    const cats = await listPlanCategories(true);
    return success(reply, cats);
  },
);

// --- POST /plan/categories ---
app.post(
  "/categories",
  { preHandler: requirePermission("attendance.manage") },
  async (request: FastifyRequest, reply: FastifyReply) => {
    const body = parseBody(CreatePlanCategorySchema, request.body);
    if ("error" in body) return error(reply, body.error, 400);
    const result = await createPlanCategory(body.data!);
    if ("error" in result) return error(reply, result.error, result.status);
    await logAudit({
      request,
      authData: request.authData,
      action: "create",
      entityType: "plan_category",
      entityId: result.data.id,
      newValues: result.data,
      description: `Kategorie: ${result.data.label}`,
    });
    return success(reply, result.data, 201, "Kategorie vytvořena");
  },
);

// --- PATCH /plan/categories/:id ---
app.patch(
  "/categories/: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(UpdatePlanCategorySchema, request.body);
    if ("error" in body) return error(reply, body.error, 400);
    const result = await updatePlanCategory(id, body.data!);
    if ("error" in result) return error(reply, result.error, result.status);
    await logAudit({
      request,
      authData: request.authData,
      action: "update",
      entityType: "plan_category",
      entityId: id,
      newValues: result.data,
      description: `Kategorie: ${result.data.label}`,
    });
    return success(reply, result.data, 200, "Kategorie aktualizována");
  },
);

// --- DELETE /plan/categories/:id (deactivate) ---
app.delete(
  "/categories/:id",
  { preHandler: requirePermission("attendance.manage") },
  async (request: FastifyRequest, reply: FastifyReply) => {
    const id = parseId((request.params as any).id, reply);
    if (id === null) return;
    const result = await deactivatePlanCategory(id);
    if ("error" in result) return error(reply, result.error, result.status);
    await logAudit({
      request,
      authData: request.authData,
      action: "delete",
      entityType: "plan_category",
      entityId: id,
      description: "Kategorie deaktivována",
    });
    return success(reply, { ok: true }, 200, "Kategorie deaktivována");
  },
);
```

- [ ] **Step 3: Typecheck**

Run: `npx tsc -p tsconfig.server.json --noEmit`
Expected: EXIT 0.

- [ ] **Step 4: Commit**

```bash
git add src/types/index.ts src/routes/admin/plan.ts
git commit -m "feat(plan): add plan category CRUD routes with audit"
```

---

## Task 5: Dynamic category validation on entries/overrides + audit label

**Files:**

- Modify: `src/schemas/plan.schema.ts`
- Modify: `src/utils/planAuditDescription.ts`
- Modify: `src/__tests__/planAuditDescription.test.ts`
- Modify: `src/services/plan.service.ts`

- [ ] **Step 1: Loosen the category validation in `src/schemas/plan.schema.ts`**

Replace:

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

with:

```typescript
// Category is now a dynamic key (validated against plan_categories in the
// service). Keep it a non-empty string here.
const planCategoryEnum = z.string().trim().min(1, "Kategorie je povinná");
```

Leave every `planCategoryEnum` usage as-is (the `.default("work")` on create still works for a string).

- [ ] **Step 2: Change `buildPlanAuditDescription` to take a resolved label**

In `src/utils/planAuditDescription.ts`, change the `category` field to `categoryLabel` and use it directly. Replace the `PlanAuditDescriptionInput.category` field and the `planCategoryLabel(opts.category)` call:

In the interface:

```typescript
/** Already-resolved Czech category label (resolved from plan_categories). */
categoryLabel: string;
```

(remove the `category: string;` field)

In `buildPlanAuditDescription`, replace:

```typescript
const parts: string[] = [
  opts.userName,
  dateLabel,
  planCategoryLabel(opts.category),
];
```

with:

```typescript
const parts: string[] = [opts.userName, dateLabel, opts.categoryLabel];
```

Keep `PLAN_CATEGORY_LABELS_CS` / `planCategoryLabel` exported (still used as a fallback by the service), but they are no longer called inside `buildPlanAuditDescription`.

- [ ] **Step 3: Update the audit-description tests**

In `src/__tests__/planAuditDescription.test.ts`, in every `buildPlanAuditDescription({...})` call replace the `category: "work"` (etc.) field with the resolved label, e.g. `categoryLabel: "Práce"`. Concretely:

- `category: "work"` → `categoryLabel: "Práce"`
- `category: "leave"` → `categoryLabel: "Dovolená"`
- `category: "sick"` → `categoryLabel: "Nemoc"`

(The `planCategoryLabel` describe block stays unchanged.)

- [ ] **Step 4: Resolve the label and validate the key in `src/services/plan.service.ts`**

Add a helper near `resolvePlanLabels`:

```typescript
import { planCategoryLabel as fallbackCategoryLabel } from "../utils/planAuditDescription";

/** Resolve a category key to its Czech label from the DB, falling back to
 * the built-in map then the raw key. */
async function resolveCategoryLabel(key: string): Promise<string> {
  const cat = await prisma.plan_categories.findUnique({
    where: { key },
    select: { label: true },
  });
  return cat?.label ?? fallbackCategoryLabel(key);
}

/** Returns an error result if the category key is not an active category. */
async function assertActiveCategory(
  key: string,
): Promise<{ error: string; status: number } | null> {
  const cat = await prisma.plan_categories.findFirst({
    where: { key, is_active: true },
    select: { id: true },
  });
  if (!cat) return { error: "Neplatná nebo neaktivní kategorie", status: 400 };
  return null;
}
```

In `createEntry`, after the past-date locks and before `prisma.work_plan_entries.create`, add:

```typescript
const catErr = await assertActiveCategory(input.category);
if (catErr) return catErr;
```

In `updateEntry`, after computing locks and before the `prisma...update`, add (only validate if category provided):

```typescript
if (input.category) {
  const catErr = await assertActiveCategory(input.category);
  if (catErr) return catErr;
}
```

Apply the same two patterns to `createOverride` (validate `input.category` before the transaction) and `updateOverride` (validate `input.category` if provided).

Then, in each place that currently builds the description with `category: <row>.category`, resolve the label first and pass `categoryLabel`. For example in `createEntry` replace:

```typescript
const { userName, projectName } = await resolvePlanLabels(
  created.user_id,
  created.project_id,
);
const description = buildPlanAuditDescription({
  userName,
  category: created.category,
  projectName,
  dateFrom: input.date_from,
  dateTo: input.date_to,
  force,
});
```

with:

```typescript
const { userName, projectName } = await resolvePlanLabels(
  created.user_id,
  created.project_id,
);
const categoryLabel = await resolveCategoryLabel(created.category);
const description = buildPlanAuditDescription({
  userName,
  categoryLabel,
  projectName,
  dateFrom: input.date_from,
  dateTo: input.date_to,
  force,
});
```

Apply the same `category: X.category` → `categoryLabel: await resolveCategoryLabel(X.category)` change in `updateEntry`, `deleteEntry`, `createOverride` (both the created row and the `replaced` row), `updateOverride`, and `deleteOverride`.

- [ ] **Step 5: Typecheck and run tests**

Run: `npx tsc -p tsconfig.server.json --noEmit`
Expected: EXIT 0.
Run: `npx vitest run`
Expected: all tests PASS (including the updated `planAuditDescription` and new `planCategory` tests).

- [ ] **Step 6: Commit**

```bash
git add src/schemas/plan.schema.ts src/utils/planAuditDescription.ts src/__tests__/planAuditDescription.test.ts src/services/plan.service.ts
git commit -m "feat(plan): validate dynamic category keys, resolve audit label from DB"
```

---

## Task 6: Frontend categories query + types + helpers

**Files:**

- Modify: `src/admin/lib/queries/plan.ts`

- [ ] **Step 1: Add the type, query, and helpers**

Add the import at the top of `src/admin/lib/queries/plan.ts` (it already imports `queryOptions`, `apiFetch`, `jsonQuery`):

```typescript
// (queryOptions and jsonQuery are already imported at the top of this file)
```

Add after the `ResolvedCell` interface:

```typescript
export interface PlanCategory {
  id: number;
  key: string;
  label: string;
  color: string;
  sort_order: number;
  is_active: boolean;
}

export const planCategoriesOptions = () =>
  queryOptions({
    queryKey: ["plan", "categories"],
    queryFn: () => jsonQuery<PlanCategory[]>("/api/admin/plan/categories"),
  });

/** Build a key → category lookup from the categories list. */
export function categoryMap(
  categories: PlanCategory[] | undefined,
): Record<string, PlanCategory> {
  const map: Record<string, PlanCategory> = {};
  for (const c of categories ?? []) map[c.key] = c;
  return map;
}
```

Then change `planCategoryLabel` to be map-driven, and DELETE the now-unused internal `PLAN_CATEGORY_LABELS` const. **Keep `PLAN_CATEGORIES` for now** — `PlanCellModal` still imports it; it is removed in Task 8 once the `<select>` no longer uses it (this keeps every commit building). Replace the old `planCategoryLabel` (and remove `PLAN_CATEGORY_LABELS`) with:

```typescript
/** Czech label for a plan category key, resolved from the loaded categories
 * map, falling back to the raw key. The `map` arg is optional so callers not
 * yet threading categories still compile (they show the raw key until wired). */
export function planCategoryLabel(
  key: string,
  map?: Record<string, PlanCategory>,
): string {
  return map?.[key]?.label ?? key;
}
```

(`cellProjectLabel` stays unchanged. `PLAN_CATEGORIES` is intentionally left in place until Task 8.)

- [ ] **Step 2: Commit**

```bash
git add src/admin/lib/queries/plan.ts
git commit -m "feat(plan): add categories query, type, and map helpers (frontend)"
```

---

## Task 7: Wire categories through the grid + color refactor (CSS)

**Files:**

- Modify: `src/admin/plan.css`
- Modify: `src/admin/components/PlanGrid.tsx`
- Modify: `src/admin/components/PlanRangeChips.tsx`
- Modify: `src/admin/pages/PlanWork.tsx`

- [ ] **Step 1: CSS — make the tape use `--cat-color`**

In `src/admin/plan.css`, change the base tape `.plan-cell::before` `background` line from:

```css
background: var(--plan-paper-rule);
```

to:

```css
background: var(--cat-color, var(--plan-paper-rule));
```

Then DELETE the seven per-key tape rules (`.plan-cell:has(.plan-chip--work)::before` … `.plan-cell:has(.plan-chip--other)::before`).

- [ ] **Step 2: CSS — make the chip use `--cat-color`**

In the base `.plan-chip` rule, replace these three lines:

```css
color: var(--plan-ink);
background: color-mix(in srgb, var(--plan-paper) 60%, var(--plan-paper-soft));
border: 1px solid var(--plan-paper-rule);
```

with:

```css
color: var(--cat-color, var(--plan-ink));
background: color-mix(
  in srgb,
  var(--cat-color, var(--plan-paper-soft)) 10%,
  var(--plan-paper)
);
border: 1px solid
  color-mix(in srgb, var(--cat-color, var(--plan-paper-rule)) 35%, transparent);
```

Then DELETE the seven per-key chip rules (`.plan-cell:has(.plan-chip--work) .plan-chip--work` … `.plan-cell:has(.plan-chip--other) .plan-chip--other`).

Update the dark override from:

```css
[data-theme="dark"] .plan-chip {
  background: color-mix(in srgb, var(--plan-cell-bg) 90%, var(--plan-paper));
}
```

to:

```css
[data-theme="dark"] .plan-chip {
  background: color-mix(
    in srgb,
    var(--cat-color, var(--plan-cell-bg)) 16%,
    var(--plan-paper)
  );
}
```

- [ ] **Step 2b: CSS — keep the decorative grain static**

The paper-grain gradient (search for `--plan-tape-work` / `--plan-tape-training` around the `.plan-grid-wrap::before` area) is cosmetic; leave it and the `--plan-tape-*` variable definitions in place. No change.

- [ ] **Step 3: PlanGrid — accept categories, set `--cat-color`, pass label**

In `src/admin/components/PlanGrid.tsx`:

Change the import:

```typescript
import {
  GridData,
  ResolvedCell,
  planCategoryLabel,
  categoryMap,
  PlanCategory,
} from "../lib/queries/plan";
```

Add `categories` to `Props`:

```typescript
  categories: PlanCategory[];
```

Destructure it in the component signature alongside `canEdit`, and build the map once (inside the component body, before the return / inside the existing `useMemo` area):

```typescript
const catMap = useMemo(() => categoryMap(categories), [categories]);
```

On the cell `<button>`, add an inline style with the color when the cell has a category. Change the `<button ...>` opening tag to include:

```tsx
                        style={
                          cell
                            ? ({ "--cat-color": catMap[cell.category]?.color } as React.CSSProperties)
                            : undefined
                        }
```

In the aria-label, change `planCategoryLabel(cell.category)` to `planCategoryLabel(cell.category, catMap)`.
Pass the label to the chips — change the `<PlanRangeChips ... />` usage to add:

```tsx
                          categoryLabel={cell ? planCategoryLabel(cell.category, catMap) : ""}
```

(If `React` is not imported, add `import { useMemo } from "react";` already exists; for the `React.CSSProperties` cast add `import type { CSSProperties } from "react";` and use `as CSSProperties`.)

- [ ] **Step 4: PlanRangeChips — render label from prop**

In `src/admin/components/PlanRangeChips.tsx`:

- Remove `planCategoryLabel` from the import (keep `cellProjectLabel`, `ResolvedCell`).
- Add `categoryLabel: string;` to `Props`.
- Destructure `categoryLabel` and render it. Replace:

```tsx
<span className={`plan-chip plan-chip--${cell.category}`}>
  {planCategoryLabel(cell.category)}
</span>
```

with:

```tsx
<span className="plan-chip">{categoryLabel}</span>
```

(The per-key class is no longer needed; color comes from the inherited `--cat-color`.)

- [ ] **Step 5: PlanWork — load categories and pass to the grid**

In `src/admin/pages/PlanWork.tsx`:
Add the import:

```typescript
import { planCategoriesOptions, PlanCategory } from "../lib/queries/plan";
```

After the projects query (`const projects: Project[] = ...`), add:

```typescript
const { data: categoriesData } = useQuery(planCategoriesOptions());
const categories: PlanCategory[] = categoriesData ?? [];
```

Pass to the grid — in `<PlanGrid ... />` add:

```tsx
categories = { categories };
```

- [ ] **Step 6: Build the client**

Run: `npm run build:client`
Expected: BUILD-EXIT 0.

- [ ] **Step 7: Commit**

```bash
git add src/admin/plan.css src/admin/components/PlanGrid.tsx src/admin/components/PlanRangeChips.tsx src/admin/pages/PlanWork.tsx
git commit -m "feat(plan): render category colors from DB via --cat-color"
```

---

## Task 8: Plan create/edit `<select>` uses active categories

**Files:**

- Modify: `src/admin/components/PlanCellModal.tsx`
- Modify: `src/admin/pages/PlanWork.tsx`

- [ ] **Step 1: Pass categories into the modal**

In `src/admin/components/PlanCellModal.tsx`:
Change the import to drop the removed `PLAN_CATEGORIES` and use the categories from props:

```typescript
import {
  ResolvedCell,
  planCategoryLabel,
  cellProjectLabel,
  PlanCategory,
} from "../lib/queries/plan";
```

Add `categories: PlanCategory[];` to the `Props` interface (next to `projects`).

In `EditForm`, derive active categories and a label map from props. Near the top of `EditForm` (after `const isOverride = ...`):

```typescript
const activeCategories = props.categories.filter((c) => c.is_active);
```

Replace the category `<select>` options (`{PLAN_CATEGORIES.map((c) => (...))}`) with:

```tsx
{
  activeCategories.map((c) => (
    <option key={c.key} value={c.key}>
      {c.label}
    </option>
  ));
}
```

In `ViewModal`, build a map and resolve the label. Replace:

```tsx
        <strong>Kategorie:</strong> {planCategoryLabel(c.category)}
```

with:

```tsx
        <strong>Kategorie:</strong>{" "}
        {planCategoryLabel(
          c.category,
          Object.fromEntries(props.categories.map((x) => [x.key, x])),
        )}
```

Finally, now that `PlanCellModal` no longer references `PLAN_CATEGORIES`,
**remove the exported `PLAN_CATEGORIES` const** from
`src/admin/lib/queries/plan.ts` (it was kept alive only for this select). Grep
to confirm no other importer remains: `npx rg "PLAN_CATEGORIES" src` should
return nothing.

- [ ] **Step 2: Pass categories from PlanWork to the modal**

In `src/admin/pages/PlanWork.tsx`, add the JSX prop `categories={categories}` to
the `<PlanCellModal ... />` element.

- [ ] **Step 3: Build the client**

Run: `npm run build:client`
Expected: BUILD-EXIT 0.

- [ ] **Step 4: Commit**

```bash
git add src/admin/components/PlanCellModal.tsx src/admin/pages/PlanWork.tsx
git commit -m "feat(plan): drive category select and view label from DB categories"
```

---

## Task 9: Category management modal + "Správa kategorií" button

**Files:**

- Create: `src/admin/components/PlanCategoriesModal.tsx`
- Modify: `src/admin/pages/PlanWork.tsx`

- [ ] **Step 1: Create the modal component**

`src/admin/components/PlanCategoriesModal.tsx`:

```tsx
import { useState } from "react";
import FormModal from "./FormModal";
import { useApiMutation } from "../lib/queries/mutations";
import { useAlert } from "../context/AlertContext";
import { PlanCategory } from "../lib/queries/plan";

interface Props {
  isOpen: boolean;
  onClose: () => void;
  categories: PlanCategory[];
}

const DEFAULT_NEW_COLOR = "#2563eb";

export default function PlanCategoriesModal({
  isOpen,
  onClose,
  categories,
}: Props) {
  const alert = useAlert();
  const [newLabel, setNewLabel] = useState("");
  const [newColor, setNewColor] = useState(DEFAULT_NEW_COLOR);

  const createCat = useApiMutation<
    { label: string; color: string },
    PlanCategory
  >({
    url: "/api/admin/plan/categories",
    method: "POST",
    invalidate: ["plan"],
  });
  const updateCat = useApiMutation<
    { id: number; label?: string; color?: string; is_active?: boolean },
    PlanCategory
  >({
    url: (v) => `/api/admin/plan/categories/${v.id}`,
    method: "PATCH",
    invalidate: ["plan"],
  });

  const handleAdd = () => {
    const label = newLabel.trim();
    if (!label) {
      alert.error("Zadejte název kategorie");
      return;
    }
    createCat.mutate(
      { label, color: newColor },
      {
        onSuccess: () => {
          setNewLabel("");
          setNewColor(DEFAULT_NEW_COLOR);
        },
        onError: (e) => alert.error(e.message),
      },
    );
  };

  const patch = (
    id: number,
    body: { label?: string; color?: string; is_active?: boolean },
  ) => {
    updateCat.mutate(
      { id, ...body },
      { onError: (e) => alert.error(e.message) },
    );
  };

  const busy = createCat.isPending || updateCat.isPending;

  return (
    <FormModal
      isOpen={isOpen}
      onClose={onClose}
      title="Správa kategorií"
      hideFooter
      size="md"
    >
      <div className="plan-cat-manager">
        {categories.map((c) => (
          <div
            key={c.id}
            className={`plan-cat-row${c.is_active ? "" : " plan-cat-row--inactive"}`}
          >
            <input
              type="color"
              className="plan-cat-color"
              value={c.color}
              disabled={busy}
              onChange={(e) => patch(c.id, { color: e.target.value })}
              aria-label={`Barva – ${c.label}`}
            />
            <input
              type="text"
              className="admin-form-input"
              defaultValue={c.label}
              disabled={busy}
              onBlur={(e) => {
                const v = e.target.value.trim();
                if (v && v !== c.label) patch(c.id, { label: v });
              }}
              aria-label={`Název – ${c.label}`}
            />
            <button
              type="button"
              className="admin-btn admin-btn-secondary admin-btn-sm"
              disabled={busy}
              onClick={() => patch(c.id, { is_active: !c.is_active })}
            >
              {c.is_active ? "Skrýt" : "Obnovit"}
            </button>
          </div>
        ))}

        <div className="plan-cat-row plan-cat-row--new">
          <input
            type="color"
            className="plan-cat-color"
            value={newColor}
            disabled={busy}
            onChange={(e) => setNewColor(e.target.value)}
            aria-label="Barva nové kategorie"
          />
          <input
            type="text"
            className="admin-form-input"
            placeholder="Nová kategorie…"
            value={newLabel}
            disabled={busy}
            onChange={(e) => setNewLabel(e.target.value)}
            onKeyDown={(e) => e.key === "Enter" && handleAdd()}
          />
          <button
            type="button"
            className="admin-btn admin-btn-primary admin-btn-sm"
            disabled={busy}
            onClick={handleAdd}
          >
            Přidat
          </button>
        </div>
      </div>
    </FormModal>
  );
}
```

- [ ] **Step 2: Add minimal layout CSS**

Append to `src/admin/plan.css`:

```css
/* Category manager modal rows */
.plan-cat-manager {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.plan-cat-row {
  display: flex;
  align-items: center;
  gap: 8px;
}
.plan-cat-row--inactive {
  opacity: 0.55;
}
.plan-cat-row .admin-form-input {
  flex: 1;
  min-width: 0;
}
.plan-cat-color {
  width: 34px;
  height: 34px;
  padding: 0;
  border: 1px solid var(--plan-paper-rule);
  border-radius: 6px;
  background: none;
  cursor: pointer;
  flex: 0 0 auto;
}
.plan-cat-row--new {
  margin-top: 6px;
  padding-top: 10px;
  border-top: 1px dashed var(--plan-paper-rule);
}
```

- [ ] **Step 3: Add the button + modal in PlanWork**

In `src/admin/pages/PlanWork.tsx`:
Add the import:

```typescript
import PlanCategoriesModal from "../components/PlanCategoriesModal";
```

Add open state near the other `useState` hooks:

```typescript
const [catModalOpen, setCatModalOpen] = useState(false);
```

In the `.plan-toolbar`, after the view-toggle `<div className="plan-view-toggle">…</div>` add (so it sits at the right with the toggle):

```tsx
{
  canEdit && (
    <button
      type="button"
      className="admin-btn admin-btn-secondary"
      onClick={() => setCatModalOpen(true)}
    >
      Správa kategorií
    </button>
  );
}
```

Render the modal near `<PlanCellModal … />`:

```tsx
<PlanCategoriesModal
  isOpen={catModalOpen}
  onClose={() => setCatModalOpen(false)}
  categories={categories}
/>
```

- [ ] **Step 4: Build the client**

Run: `npm run build:client`
Expected: BUILD-EXIT 0.

- [ ] **Step 5: Commit**

```bash
git add src/admin/components/PlanCategoriesModal.tsx src/admin/plan.css src/admin/pages/PlanWork.tsx
git commit -m "feat(plan): add Správa kategorií management modal and button"
```

---

## Task 10: Audit entity labels for `plan_category`

**Files:**

- Modify: `src/admin/pages/AuditLog.tsx`
- Modify: `src/admin/utils/dashboardHelpers.ts`

- [ ] **Step 1: Add the label in both maps**

In `src/admin/pages/AuditLog.tsx`, in `ENTITY_TYPE_LABELS`, after `work_plan_override: "Plán prací – přepsání dne",` add:

```typescript
  plan_category: "Plán prací – kategorie",
```

Do the same in `src/admin/utils/dashboardHelpers.ts` `ENTITY_TYPE_LABELS`.

- [ ] **Step 2: Build the client**

Run: `npm run build:client`
Expected: BUILD-EXIT 0.

- [ ] **Step 3: Commit**

```bash
git add src/admin/pages/AuditLog.tsx src/admin/utils/dashboardHelpers.ts
git commit -m "feat(plan): label plan_category audit rows in Czech"
```

---

## Task 11: Final verification

- [ ] **Step 1: Server typecheck**

Run: `npx tsc -p tsconfig.server.json --noEmit`
Expected: EXIT 0.

- [ ] **Step 2: Full test suite**

Run: `npx vitest run`
Expected: all PASS (existing 126 + new planCategory cases + updated planAuditDescription).

- [ ] **Step 3: Full build**

Run: `npm run build`
Expected: server + client build, EXIT 0.

- [ ] **Step 4: Manual checklist (user, in the running app)**

- As an `attendance.manage` user: "Správa kategorií" button is visible above the calendar; a view-only user does NOT see it.
- Add a category (label + color) → it appears in the create/edit plan `<select>` and its color shows on a new entry's chip + tape.
- Recolor an existing category → all entries with that category recolor after the grid refetch.
- Rename a category → the chip/label/select update.
- "Skrýt" (deactivate) a category in use → it disappears from the `<select>` but existing entries still show its label/color; cannot hide the last active one (Czech error).
- Audit log shows `Plán prací – kategorie` rows for category changes.

- [ ] **Step 5: Final commit (if any stray changes)**

```bash
git add -A
git commit -m "chore(plan): finalize category management"
```

---

## Self-review notes (addressed)

- **Spec coverage:** table + enum→varchar + seed (Task 1); CRUD API + permissions + audit (Task 4); dynamic validation + audit label (Task 5); categories query/type (Task 6); color refactor (Task 7); dynamic select + view label (Task 8); management modal + button (Task 9); audit entity labels (Task 10); tests (Task 3/5/11).
- **Deactivate semantics:** GET returns all categories (display keeps working for retired ones); `<select>` filters to active; last-active guard in `updatePlanCategory`.
- **Type consistency:** service returns `{ data } | { error, status }`; `planCategoryLabel(key, map)` signature used consistently in PlanGrid/PlanCellModal; `categories: PlanCategory[]` threaded PlanWork → PlanGrid/PlanRangeChips(label)/PlanCellModal.
- **`buildPlanAuditDescription`** now takes `categoryLabel` everywhere it is called (service Task 5, tests Task 5 step 3).
