# Plan Categories Management — Design Spec

Date: 2026-06-06
Status: Approved (approach A)
Area: Work Plan (`/plan-work`)

## Goal

Let managers (`attendance.manage`) **create, rename, recolor, and retire** the
categories used by the work plan, via a "Správa kategorií" button above the
calendar that opens a form modal with a per-category color picker. Categories
become data, not a hardcoded enum.

## Current state (what's hardcoded today)

- DB: `plan_category` **enum** (`work, preparation, travel, leave, sick,
training, other`) used by `work_plan_entries.category` and
  `work_plan_overrides.category` (both `NOT NULL`).
- Validation: `planCategoryEnum` in `src/schemas/plan.schema.ts`.
- Frontend labels: `PLAN_CATEGORIES` / `planCategoryLabel` in
  `src/admin/lib/queries/plan.ts`.
- Colors: per-category CSS variables `--plan-tape-<key>` in `src/admin/plan.css`,
  applied via `.plan-cell:has(.plan-chip--<key>)::before` (left "tape" bar) and
  `.plan-chip--<key>` (chip text/border/background via `color-mix`). 14 rules.
- Server audit: `PLAN_CATEGORY_LABELS_CS` in `src/utils/planAuditDescription.ts`.
- A decorative paper-grain gradient (`plan.css` ~lines 102–107) tints the page
  background with `--plan-tape-work` / `--plan-tape-training` at 5%. This is
  cosmetic and **not** category-semantic.

## Approach (A)

Introduce a `plan_categories` table. Keep the entry/override `category` column
as a **string key** (slug) referencing `plan_categories.key`. Change the column
type from the enum to `VARCHAR(50)`; existing values are preserved verbatim.
"Remove" means **deactivate** (`is_active = false`), never hard-delete, so
historical entries keep resolving their label and color.

Rejected: a real FK `category_id` (needs per-row backfill + read/write churn for
no user-visible benefit); keeping the enum (can't add categories).

## Data model

New table `plan_categories`:

| column     | type         | notes                                        |
| ---------- | ------------ | -------------------------------------------- |
| id         | INT PK AI    |                                              |
| key        | VARCHAR(50)  | UNIQUE, slug, immutable after create         |
| label      | VARCHAR(100) | Czech display name, editable                 |
| color      | VARCHAR(7)   | `#RRGGBB`                                    |
| sort_order | INT          | display order; new categories append (max+1) |
| is_active  | BOOLEAN      | default true; false = retired                |
| created_at | DATETIME     | default now                                  |
| updated_at | DATETIME     | auto                                         |

Column change: `work_plan_entries.category` and `work_plan_overrides.category`
`plan_category` → `VARCHAR(50) NOT NULL`. Drop the `plan_category` enum from the
Prisma schema once no column uses it.

## Migration (one tracked Prisma migration)

`npx prisma migrate dev --name plan_categories` (requires dev server stopped —
ask the user first per CLAUDE.md). The generated `migration.sql` will:

1. `CREATE TABLE plan_categories (...)`.
2. `ALTER` the two `category` columns enum → `VARCHAR(50)`.
3. `INSERT` the 7 seed rows (so production gets them — not seed-only, per the
   migration policy). Seed values (key, label, color, sort_order):

   | key         | label          | color   | sort |
   | ----------- | -------------- | ------- | ---- |
   | work        | Práce          | #2563eb | 1    |
   | preparation | Příprava       | #0d9488 | 2    |
   | travel      | Cesta / Montáž | #ca8a04 | 3    |
   | leave       | Dovolená       | #16a34a | 4    |
   | sick        | Nemoc          | #dc2626 | 5    |
   | training    | Školení        | #7c3aed | 6    |
   | other       | Jiné           | #6b7280 | 7    |

After: `npx prisma generate`, commit schema + migration folder.

## API — `/api/admin/plan/categories`

All under the existing plan route file group.

- `GET /` — returns **all** categories (active + inactive), ordered by
  `sort_order, id`. Readable by `attendance.record` OR `attendance.manage`
  (every grid viewer needs labels/colors, including for retired categories on
  historical entries).
- `POST /` — create. `attendance.manage`. Body: `{ label, color }`. `key` is
  auto-slugged from `label` (ASCII, lowercased, deduped); `sort_order = max+1`.
- `PATCH /:id` — update `label` / `color` / `is_active`. `attendance.manage`.
  `key` is immutable.
- `DELETE /:id` — deactivate (`is_active = false`). `attendance.manage`.

Service layer: `src/services/planCategory.service.ts` (plain async functions,
`{ data }` / `{ error, status }` envelope). Audit-logged via `logAudit` with a
new entity type `plan_category` (add to `EntityType` and the AuditLog +
dashboard `ENTITY_TYPE_LABELS` maps, e.g. "Plán prací – kategorie").

Validation (`src/schemas/plan.schema.ts` or a new `planCategory.schema.ts`):

- `label`: required, 1–100 chars, trimmed.
- `color`: regex `^#[0-9a-fA-F]{6}$`.
- `is_active`: boolean (PATCH only).
- Entry/override create/update: `category` becomes `z.string().min(1)`; the
  **service** validates the key exists and `is_active` (reject unknown/inactive
  with a Czech 400).

## Validation rules / edge cases

- **Slug collision:** if the slug derived from a new label already exists,
  append `-2`, `-3`, … Keys are never shown to users.
- **At least one active category:** deactivating the last active category is
  rejected (Czech 400) so the create form is never empty.
- **Deactivate in use:** allowed. Existing entries keep their key; the category
  still renders (GET returns inactive ones) but is hidden from the create
  `<select>`.
- **Rename/recolor:** applies to all entries with that key (reference is by
  key) — this is the desired "recolor everywhere" behavior.
- **No hard delete** in this iteration (deactivate only). Possible later
  enhancement: allow hard-delete when zero entries reference the key.

## Frontend

- **Button** "Správa kategorií" in `PlanWork.tsx` header, rendered only when
  `hasPermission("attendance.manage")`.
- **Modal** (`PlanCategoriesModal.tsx`, uses `FormModal`): a row per category =
  `label` text input + native `<input type="color">` swatch + active toggle (or
  a "retire/restore" control), plus an "add category" row (label + color +
  add). Mutations invalidate `["plan", "categories"]` **and** `["plan"]` (so the
  grid recolors) and `["dashboard"]`/`["audit-log"]` (audit feed).
- **Category query** `["plan","categories"]` loaded in `PlanWork`, passed to the
  grid and cell modal. The create/edit plan `<select>` lists **active**
  categories from this query (replacing hardcoded `PLAN_CATEGORIES`); display
  uses the **full** map (so retired categories still render).
- `planCategoryLabel` becomes a lookup into the categories map (with raw-key
  fallback). `PLAN_CATEGORIES` constant is removed.

## Color rendering (the notable refactor)

Replace the 14 hardcoded per-key rules with a generic, custom-property-driven
treatment:

- `PlanGrid` sets `style={{ "--cat-color": color }}` on the `.plan-cell`
  `<button>` when the cell has a category (`color` from the categories map). The
  custom property cascades to the chip and is available to the cell's `::before`
  tape.
- `plan.css`:
  - `.plan-cell::before` tape uses
    `background: var(--cat-color, var(--plan-tape-other))` — the graphite
    `#6b7280` is the defensive neutral fallback when a cell has no category
    color (replaces the seven `:has(.plan-chip--<key>)::before` rules).
  - `.plan-chip` (generic) uses `color: var(--cat-color)`,
    `border-color: color-mix(in srgb, var(--cat-color) 35%, transparent)`,
    `background: color-mix(in srgb, var(--cat-color) 10%, var(--plan-paper))`
    (replaces the seven `.plan-chip--<key>` rules).
- Visual output is identical for the existing 7; new categories work with no CSS
  changes.
- The decorative paper-grain gradient stays static (keep two literal colors or
  the two `--plan-tape-*` vars it needs); it is not category-semantic.

## Audit description

`src/services/plan.service.ts` builds the audit description with the category
label. Since labels now live in the DB, resolve the label from `plan_categories`
by key (alongside the existing `resolvePlanLabels` lookups) and pass the plain
label into `buildPlanAuditDescription`. `src/utils/planAuditDescription.ts`'s
`buildPlanAuditDescription` stays pure (receives the resolved label);
`PLAN_CATEGORY_LABELS_CS`/`planCategoryLabel` there are removed or reduced to a
fallback.

## Testing

Vitest (real DB, per project convention):

- `planCategory.service` CRUD: create (slug generation, sort_order), update
  (label/color/active), deactivate, and the "last active category" guard.
- Color/label validation rejects bad hex and empty label.
- Entry create accepts an active key and rejects an unknown/inactive key.

## Out of scope (YAGNI)

- Per-category icons.
- Drag-to-reorder (order by `sort_order, id`; new categories append).
- Hard delete of unused categories (deactivate only).

## Assumptions to confirm

- "Remove" = deactivate (history preserved). ✅ implied by approach A.
- Native browser color picker (`<input type="color">`), not a custom palette.
