feat(odin): Phase 2a read-only agentic assistant + tool-trace UI

- agentic chat loop (max 6 round-trips, budget re-checked between iterations)
  over 10 read-only, permission-delegated tools in src/services/ai-tools.ts
- persist assistant tool trace in ai_chat_messages.content_json (+ migration)
- consulted-tools chips in OdinThread; header now shows month spend only
  (budget cap hidden from the UI, server-side 402 guard unchanged)
- seed: ai.use permission; Settings exposes the ai permission module
- docs: REVIEW consolidation; deployment-guide.md superseded by docs/release.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-10 23:31:50 +02:00
parent 2dbacc3bec
commit 674b44d047
26 changed files with 13728 additions and 2935 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,124 @@
# Deferred HIGH issues — performance & UX sprint
**Created:** 2026-06-03
**Source audit:** Parallel 5-agent production risk audit (2026-06-03)
**Context:** All 8 CRITICAL and 3 of 8 HIGH issues were fixed in the same session.
The 5 items below were intentionally deferred — they are not data-integrity or
auth risks, but they are real production pain that should be addressed in a
dedicated performance / UX sprint before the user base notices.
---
## #9 — N+1 queries in warehouse list/detail/report
**Files:**
- `src/services/warehouse.service.ts:184-199` (getBelowMinimumItems)
- `src/services/warehouse.service.ts:629-646` (somewhere in items list)
- `src/services/warehouse.service.ts:681-683`
- `src/services/warehouse.service.ts:2182-2203` (report)
**Pattern:** `items.map(async (i) => { const stock = await getStock(i.id); const available = await getAvailable(i.id); const value = await getValue(i.id); })` — fires N+1 round-trips per item. For a list of 50 items, that's 150 sequential queries.
**Production impact:** Slow page loads (1-3s on 50 items, scales linearly). Users already complain about the items page; this is the root cause for the warehouse report being the slowest page in the app.
**Fix sketch:**
- Replace the `for` loop with a single grouped query: `prisma.sklad_batches.groupBy({ by: ['item_id'], _sum: { quantity: true }, where: { item_id: { in: itemIds } } })` for stocks, similar aggregates for reservations.
- Map the grouped results back to items in JS. One query instead of N.
- For the report view, do the same — group all batches and reservations by item_id in a single round-trip, then join with items in memory.
- Estimated effort: M (4-6 hours including testing).
---
## #10 — Missing FK indexes on hot warehouse tables
**File:** `prisma/schema.prisma` (no `@@index` directives on the new warehouse tables)
**Missing indexes (verify against `prisma/schema.prisma` and add to a new migration):**
- `sklad_receipt_lines.item_id` — every `confirmReceipt` reads/writes this
- `sklad_issue_lines.batch_id` — every `confirmIssue` reads/writes this
- `sklad_issue_lines.item_id`
- `sklad_reservations.project_id` — reservation list per project
- `sklad_reservations.item_id`
- `sklad_item_locations.item_id`
- `sklad_item_locations.location_id`
- `sklad_batches.receipt_line_id` (if nullable FK)
- `sklad_inventory_lines.item_id`
- `sklad_inventory_lines.session_id`
**Production impact:** MySQL currently does table scans for these joins. With even 10k batch rows, the receipts/issues/reservations pages will start to crawl. On production data (likely already 50k+ batches), this is a real performance cliff.
**Fix sketch:**
- Add `@@index([item_id])`, `@@index([batch_id])` etc. to each model in `prisma/schema.prisma`.
- Run `npx prisma migrate dev --name warehouse_fk_indexes` (after asking user to stop dev server per CLAUDE.md).
- Run `npx prisma generate`.
- Verify with `EXPLAIN` on the slow queries after deploy.
- Estimated effort: S (1-2 hours).
**CLAUDE.md note:** Before running `prisma migrate dev`, the user must stop the dev server.
---
## #13 — Pagination does not auto-adjust after delete
**Files:** All list pages (Invoices, Offers, Projects, Orders, WarehouseXxx)
**Pattern:** User is on page 5 (e.g. 10 items per page, 47 total, pages 1-5). They delete the last item on page 5. Total becomes 46, but the page param is still 5. The list endpoint returns 0 results, the user sees an empty table, and the "next" button is disabled but there's no automatic jump back to page 4.
**Production impact:** Confusing UX. Users have to manually click "previous" or reset filters. Doesn't cause data loss but is a small papercut that erodes trust.
**Fix sketch:**
- In each list page's `useQuery` error/empty handler, detect `pagination.total === 0 && page > 1` and call `setPage(page - 1)`.
- Or more cleanly: have the API include `pagination.total_pages` and the frontend clamp `page` to `min(page, total_pages)` after each refetch.
- Estimated effort: S (2-3 hours; touches ~10-12 pages).
---
## #14 — Offer lock lifecycle has multiple silent failures
**File:** `src/admin/pages/OfferDetail.tsx:472-516`
**Pattern:** Lock acquire, heartbeat (interval), and unlock all use `.catch(() => {})`. If any of them silently fails, the user thinks they have the lock but actually don't — or worse, the lock is held by a tab that's already closed because the unload handler also swallowed the error.
**Production impact:** Two users editing the same offer can both see "You have the lock" and clobber each other's changes. Data loss in a real, low-probability but high-impact scenario.
**Fix sketch:**
- Replace `.catch(() => {})` with `.catch((err) => console.error("Offer lock error:", err))` at minimum.
- Better: surface lock acquisition failures via a toast (`alert.error("Nepodařilo se získat zámek, stránka bude pouze pro čtení.")`).
- For the unlock on unmount: best-effort `navigator.sendBeacon` or a synchronous fallback so the lock is released even if the page is closing.
- Estimated effort: S (1-2 hours).
---
## #11 (audit item, not a real bug) — TOTP replay counter rewind
**File investigated:** `src/utils/totp.ts:5-30`, `src/routes/admin/auth.ts:165-184`
**Status:** False positive. The audit suggested `verifyResult.counter` could be lower than the actual step used. After reading the implementation:
```ts
const delta = totp.validate({ token: code, window: 1 });
// ...
const counterDelta = Math.min(delta, 0); // -1 or 0, never +1
const counter = currentCounter + counterDelta;
```
`Math.min(delta, 0)` clamps to ≤ 0, so `counter` is always the **current** step or **one step in the past** — never a future step. The `counter <= lastCounter` check in the route is correct and complete. No fix needed.
If anyone revisits this in the future, this analysis is the basis for closing the ticket.
---
## Suggested order for the next sprint
1. **#10 (indexes)** — cheap, high impact, addresses a growing data cliff. Do first.
2. **#9 (N+1)** — bigger effort but the same root cause family. Tackle after indexes.
3. **#14 (offer lock)** — small but prevents data loss; fits in a single PR.
4. **#13 (pagination)** — UX polish; do last, batch with other UX cleanup.
Estimated total: 1-2 days of focused work.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,770 @@
# Warehouse (Sklad) Module Design
**Date:** 2026-05-29
**Status:** Approved
**Module:** Warehouse/Inventory management for boha-app-ts
---
## Overview
Full warehouse management module under the Administration section. Tracks material receiving, issuing, reservations, inventory, and project assignment. Uses true FIFO with batch-level tracking, document-driven architecture (header + lines), and integrates with existing projects, number sequences, and NAS file storage.
---
## Requirements
### Movements
- **Receipt (Prijem):** Material enters warehouse. Multi-item document with delivery note attachment.
- **Issue (Vydej):** Material leaves warehouse, mandatory project assignment. Multi-item document.
- **Reservation:** Virtual hold on stock before issue. Reduces available qty but not physical stock. Issue consumes reservation.
- **Inventory (Inventura):** Corrective movements based on physical count vs system count.
- **Storno:** Cancel a confirmed receipt or issue. Creates opposite corrective movements, preserves audit trail.
### Catalog
- Each material has: catalog number, name, description, category, unit, min quantity.
- Categories are user-defined (CRUD).
- Units are fixed list: ks, m, kg, bal, sada, m2, m3, l.
- Items can be soft-deactivated (is_active=false).
### Pricing
- Purchase price per unit, recorded bez DPH.
- True FIFO: each receipt line creates a batch. Issues consume oldest batches first.
- Batch-level tracking with remaining quantity.
### Delivery Notes
- File attachment (PDF/image) uploaded to NAS.
- Delivery note number and date stored as metadata on receipt header.
### Suppliers
- Separate `sklad_suppliers` table (ICO, DIC, contact info).
- Not shared with customers table.
### Locations
- Warehouse has named locations/shelves (code + name, e.g. "A1 - Regal A, police 1").
- Junction table tracks qty per item per location.
### Projects
- Every issue is mandatory FK to `projects` table.
- No free/issues without project assignment.
### Min Stock Alerts
- Each item has optional `min_quantity`.
- Dashboard highlights items below minimum.
- No email notification (UI alert only).
### Immutability
- All movements are immutable once confirmed.
- Corrections via storno (cancel receipt/issue) which creates opposite movements.
- Draft movements can be edited freely.
### Numbering
- Auto-numbering via `number_sequences` on confirm.
- Types: `warehouse_receipt` (e.g. PRI-2026-001), `warehouse_issue` (e.g. VYD-2026-001).
### Issue Documents
- No PDF generation for issues. Record only in system.
### Reports
- Stock status: all items with qty, min_qty, value, below-min flag.
- Project consumption: material consumed per project (filter by project, date range).
- Movement log: all receipts + issues with filters.
- Below minimum: items under min_quantity.
---
## Architecture: Document-Driven
Each movement type is a document (header + lines), matching the existing invoice/offer pattern.
### Document Flow
```
DRAFT → CONFIRMED (affects stock)
DRAFT → CANCELLED (no stock effect)
CONFIRMED → CANCELLED (storno: reverses stock changes)
```
Only CONFIRMED documents affect `sklad_batches`, `sklad_item_locations`, and reservations.
---
## Database Schema
### Enums
```prisma
enum sklad_receipt_status {
DRAFT
CONFIRMED
CANCELLED
}
enum sklad_issue_status {
DRAFT
CONFIRMED
CANCELLED
}
enum sklad_reservation_status {
ACTIVE
FULFILLED
CANCELLED
}
enum sklad_inventory_status {
DRAFT
CONFIRMED
}
```
### Master Data
```prisma
model sklad_categories {
id Int @id @default(autoincrement())
name String @db.VarChar(100)
description String? @db.Text
sort_order Int @default(0)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
items sklad_items[]
@@map("sklad_categories")
}
model sklad_suppliers {
id Int @id @default(autoincrement())
name String @db.VarChar(255)
ico String? @db.VarChar(20)
dic String? @db.VarChar(20)
contact_person String? @db.VarChar(255)
email String? @db.VarChar(255)
phone String? @db.VarChar(50)
address String? @db.Text
notes String? @db.Text
is_active Boolean @default(true)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
receipts sklad_receipts[]
@@map("sklad_suppliers")
}
model sklad_locations {
id Int @id @default(autoincrement())
code String @unique @db.VarChar(20)
name String @db.VarChar(100)
description String? @db.Text
is_active Boolean @default(true)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
items sklad_item_locations[]
@@map("sklad_locations")
}
model sklad_items {
id Int @id @default(autoincrement())
item_number String? @unique @db.VarChar(50)
name String @db.VarChar(255)
description String? @db.Text
category_id Int?
unit String @db.VarChar(20)
min_quantity Decimal? @db.Decimal(12, 3)
is_active Boolean @default(true)
notes String? @db.Text
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
category sklad_categories? @relation(fields: [category_id], references: [id])
batches sklad_batches[]
item_locations sklad_item_locations[]
@@index([category_id], map: "sklad_items_category_id")
@@map("sklad_items")
}
```
### FIFO Batches & Location Tracking
```prisma
model sklad_batches {
id Int @id @default(autoincrement())
item_id Int
receipt_line_id Int
quantity Decimal @db.Decimal(12, 3)
original_qty Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2)
received_at DateTime? @db.DateTime(0)
is_consumed Boolean @default(false)
item sklad_items @relation(fields: [item_id], references: [id])
receipt_line sklad_receipt_lines @relation(fields: [receipt_line_id], references: [id])
@@index([item_id, is_consumed, received_at], map: "sklad_batches_fifo")
@@map("sklad_batches")
}
model sklad_item_locations {
id Int @id @default(autoincrement())
item_id Int
location_id Int
quantity Decimal @default(0) @db.Decimal(12, 3)
item sklad_items @relation(fields: [item_id], references: [id])
location sklad_locations @relation(fields: [location_id], references: [id])
@@unique([item_id, location_id], map: "sklad_item_locations_unique")
@@map("sklad_item_locations")
}
```
### Receipts
```prisma
model sklad_receipts {
id Int @id @default(autoincrement())
receipt_number String? @db.VarChar(50)
supplier_id Int?
delivery_note_number String? @db.VarChar(100)
delivery_note_date DateTime? @db.DateTime(0)
received_by Int?
notes String? @db.Text
status sklad_receipt_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
supplier sklad_suppliers? @relation(fields: [supplier_id], references: [id])
received_by_user users? @relation(fields: [received_by], references: [id])
lines sklad_receipt_lines[]
attachments sklad_receipt_attachments[]
@@map("sklad_receipts")
}
model sklad_receipt_lines {
id Int @id @default(autoincrement())
receipt_id Int
item_id Int
quantity Decimal @db.Decimal(12, 3)
unit_price Decimal @db.Decimal(12, 2)
location_id Int?
notes String? @db.VarChar(255)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id])
item sklad_items @relation(fields: [item_id], references: [id])
location sklad_locations? @relation(fields: [location_id], references: [id])
batch sklad_batches?
@@index([receipt_id], map: "sklad_receipt_lines_receipt_id")
@@map("sklad_receipt_lines")
}
model sklad_receipt_attachments {
id Int @id @default(autoincrement())
receipt_id Int
file_name String @db.VarChar(255)
file_mime String @db.VarChar(100)
file_size Int
file_path String @db.VarChar(500)
created_at DateTime? @default(now()) @db.DateTime(0)
receipt sklad_receipts @relation(fields: [receipt_id], references: [id])
@@map("sklad_receipt_attachments")
}
```
### Issues
```prisma
model sklad_issues {
id Int @id @default(autoincrement())
issue_number String? @db.VarChar(50)
project_id Int
issued_by Int?
notes String? @db.Text
status sklad_issue_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
project projects @relation(fields: [project_id], references: [id])
issued_by_user users? @relation(fields: [issued_by], references: [id])
lines sklad_issue_lines[]
@@map("sklad_issues")
}
model sklad_issue_lines {
id Int @id @default(autoincrement())
issue_id Int
item_id Int
batch_id Int
quantity Decimal @db.Decimal(12, 3)
location_id Int?
reservation_id Int?
notes String? @db.VarChar(255)
issue sklad_issues @relation(fields: [issue_id], references: [id])
item sklad_items @relation(fields: [item_id], references: [id])
batch sklad_batches @relation(fields: [batch_id], references: [id])
location sklad_locations? @relation(fields: [location_id], references: [id])
reservation sklad_reservations? @relation(fields: [reservation_id], references: [id])
@@index([issue_id], map: "sklad_issue_lines_issue_id")
@@map("sklad_issue_lines")
}
```
### Reservations
```prisma
model sklad_reservations {
id Int @id @default(autoincrement())
item_id Int
project_id Int
quantity Decimal @db.Decimal(12, 3)
remaining_qty Decimal @db.Decimal(12, 3)
reserved_by Int?
notes String? @db.Text
status sklad_reservation_status @default(ACTIVE)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
item sklad_items @relation(fields: [item_id], references: [id])
project projects @relation(fields: [project_id], references: [id])
reserved_by_user users? @relation(fields: [reserved_by], references: [id])
issue_lines sklad_issue_lines[]
@@index([item_id, status], map: "sklad_reservations_item_status")
@@map("sklad_reservations")
}
```
### Inventory
```prisma
model sklad_inventory_sessions {
id Int @id @default(autoincrement())
session_number String? @db.VarChar(50)
notes String? @db.Text
status sklad_inventory_status @default(DRAFT)
created_at DateTime? @default(now()) @db.DateTime(0)
modified_at DateTime? @db.DateTime(0)
lines sklad_inventory_lines[]
@@map("sklad_inventory_sessions")
}
model sklad_inventory_lines {
id Int @id @default(autoincrement())
session_id Int
item_id Int
location_id Int?
system_qty Decimal @db.Decimal(12, 3)
actual_qty Decimal @db.Decimal(12, 3)
difference Decimal @db.Decimal(12, 3)
notes String? @db.VarChar(255)
session sklad_inventory_sessions @relation(fields: [session_id], references: [id])
item sklad_items @relation(fields: [item_id], references: [id])
location sklad_locations? @relation(fields: [location_id], references: [id])
@@index([session_id], map: "sklad_inventory_lines_session_id")
@@map("sklad_inventory_lines")
}
```
**Total: 13 models + 5 enums**
---
## API Endpoints
All under prefix `/api/admin/warehouse`.
### Items (warehouse.manage)
| Method | Path | Description |
| ------ | ------------ | ------------------------------------------------------------- |
| GET | `/items` | List items (paginated, filter by category, search, below-min) |
| GET | `/items/:id` | Item detail with batches, locations, reservations |
| POST | `/items` | Create item |
| PUT | `/items/:id` | Update item |
| DELETE | `/items/:id` | Soft-delete (is_active=false) |
### Categories (warehouse.manage)
| Method | Path | Description |
| ------ | ----------------- | ------------------------- |
| GET | `/categories` | List all |
| POST | `/categories` | Create |
| PUT | `/categories/:id` | Update |
| DELETE | `/categories/:id` | Delete (only if no items) |
### Suppliers (warehouse.manage)
| Method | Path | Description |
| ------ | ---------------- | ----------------------------- |
| GET | `/suppliers` | List (paginated, search) |
| GET | `/suppliers/:id` | Detail |
| POST | `/suppliers` | Create |
| PUT | `/suppliers/:id` | Update |
| DELETE | `/suppliers/:id` | Soft-delete (is_active=false) |
### Locations (warehouse.manage)
| Method | Path | Description |
| ------ | ---------------- | ------------------------------------- |
| GET | `/locations` | List all |
| POST | `/locations` | Create |
| PUT | `/locations/:id` | Update |
| DELETE | `/locations/:id` | Delete (only if no items at location) |
### Receipts (warehouse.operate)
| Method | Path | Description |
| ------ | ----------------------------------------- | ------------------------------------------------------------- |
| GET | `/receipts` | List (paginated, filter by status/supplier/date) |
| GET | `/receipts/:id` | Detail with lines + attachments |
| POST | `/receipts` | Create receipt (DRAFT, with lines) |
| PUT | `/receipts/:id` | Update receipt + lines (DRAFT only) |
| POST | `/receipts/:id/confirm` | Confirm: creates batches, updates locations, generates number |
| POST | `/receipts/:id/cancel` | Cancel: storno batches if confirmed |
| POST | `/receipts/:id/attachments` | Upload delivery note (multipart) |
| DELETE | `/receipts/:id/attachments/:attachmentId` | Delete attachment |
### Issues (warehouse.operate)
| Method | Path | Description |
| ------ | --------------------- | --------------------------------------------------------------------------------------- |
| GET | `/issues` | List (paginated, filter by status/project/date) |
| GET | `/issues/:id` | Detail with lines |
| POST | `/issues` | Create issue (DRAFT, with lines) |
| PUT | `/issues/:id` | Update issue + lines (DRAFT only) |
| POST | `/issues/:id/confirm` | Confirm: decrements batches, updates locations, fulfills reservations, generates number |
| POST | `/issues/:id/cancel` | Cancel: restores batch quantities if confirmed |
### Reservations (warehouse.operate)
| Method | Path | Description |
| ------ | -------------------------- | ------------------------------------ |
| GET | `/reservations` | List (filter by item/project/status) |
| POST | `/reservations` | Create reservation |
| POST | `/reservations/:id/cancel` | Cancel reservation |
### Inventory (warehouse.inventory)
| Method | Path | Description |
| ------ | --------------------------------- | --------------------------------------- |
| GET | `/inventory-sessions` | List sessions |
| GET | `/inventory-sessions/:id` | Detail with lines |
| POST | `/inventory-sessions` | Create session (system_qty auto-filled) |
| PUT | `/inventory-sessions/:id` | Update lines (DRAFT only) |
| POST | `/inventory-sessions/:id/confirm` | Confirm: creates corrective movements |
### Reports (warehouse.view)
| Method | Path | Description |
| ------ | ------------------------------ | ---------------------------------------------------- |
| GET | `/reports/stock-status` | All items with qty, min_qty, value, below-min flag |
| GET | `/reports/project-consumption` | Material per project (filter by project, date range) |
| GET | `/reports/movement-log` | All movements with filters |
| GET | `/reports/below-minimum` | Items below min_quantity |
### Utility (warehouse.view)
| Method | Path | Description |
| ------ | -------------------------- | -------------------------------------- |
| GET | `/items/:id/available-qty` | Available qty (total stock - reserved) |
| GET | `/items/:id/batches` | FIFO batch queue for item |
---
## Business Logic
### Receipt Confirm (single Prisma transaction)
1. Validate status is DRAFT
2. Generate `receipt_number` via `number_sequences` (type: `warehouse_receipt`)
3. For each receipt line:
- Create `sklad_batches` row (quantity = original_qty = line qty, unit_price from line, received_at = now)
- Upsert `sklad_item_locations` (add qty to specified location)
4. Update receipt status to CONFIRMED
5. Log audit
### Receipt Cancel
**If DRAFT:** Set status to CANCELLED. No stock changes.
**If CONFIRMED (storno):**
1. Validate no issue lines have consumed batches from this receipt
2. For each receipt line / batch:
- If batch is fully consumed (is_consumed=true), reject cancellation
- Decrement `sklad_item_locations` by batch remaining quantity
- Delete the batch row
3. Set receipt status to CANCELLED
4. Log audit
### Issue Confirm (single Prisma transaction)
1. Validate status is DRAFT
2. For each issue line, validate: batch has enough remaining quantity
3. Generate `issue_number` via `number_sequences` (type: `warehouse_issue`)
4. For each issue line:
- Decrement `sklad_batches.quantity`, set `is_consumed=true` if 0
- Decrement `sklad_item_locations.quantity`
- If `reservation_id` set, decrement `sklad_reservations.remaining_qty`
5. Check reservations: if remaining_qty = 0, set status to FULFILLED
6. Update issue status to CONFIRMED
7. Log audit
### Issue Cancel
**If DRAFT:** Set status to CANCELLED. No stock changes.
**If CONFIRMED (storno):**
1. For each issue line:
- Increment `sklad_batches.quantity`, set `is_consumed=false` if was true
- Increment `sklad_item_locations.quantity`
- If `reservation_id` set, increment `sklad_reservations.remaining_qty`, set status back to ACTIVE if was FULFILLED
2. Set issue status to CANCELLED
3. Log audit
### Auto-FIFO on Issue Creation
When creating an issue line, if user doesn't pick a specific batch:
- Service selects oldest unconsumed batches for the item (via `sklad_batches_fifo` index)
- May span multiple batches if quantity exceeds one batch's remaining qty
- Frontend shows available batches for manual override
### Reservation Creation
1. Validate item exists and is_active
2. Calculate available_qty = total stock qty - sum of active reservation quantities for this item
3. Validate available_qty >= requested quantity
4. Create reservation with quantity = remaining_qty
5. Does NOT modify `sklad_batches` or `sklad_item_locations`
### Inventory Confirm (single Prisma transaction)
1. Validate status is DRAFT
2. For each inventory line where difference != 0:
- If difference > 0 (more stock than system): create a corrective receipt (type: inventory, auto-confirmed)
- If difference < 0 (less stock than system): create a corrective issue (type: inventory, auto-confirmed)
3. Generate `session_number` via `number_sequences` (type: `warehouse_inventory`)
4. Set status to CONFIRMED
5. Log audit
---
## Frontend
### Sidebar
Entry in `Sidebar.tsx` with permission `warehouse.view`:
- Icon: package/box icon
- Label: "Sklad"
- Path: `/warehouse`
### Pages (lazy-loaded)
| Route | Component | Permission |
| ------------------------------ | ------------------------ | ------------------- |
| `/warehouse` | Warehouse | warehouse.view |
| `/warehouse/items` | WarehouseItems | warehouse.view |
| `/warehouse/items/:id` | WarehouseItemDetail | warehouse.view |
| `/warehouse/receipts` | WarehouseReceipts | warehouse.view |
| `/warehouse/receipts/new` | WarehouseReceiptForm | warehouse.operate |
| `/warehouse/receipts/:id` | WarehouseReceiptDetail | warehouse.view |
| `/warehouse/receipts/:id/edit` | WarehouseReceiptForm | warehouse.operate |
| `/warehouse/issues` | WarehouseIssues | warehouse.view |
| `/warehouse/issues/new` | WarehouseIssueForm | warehouse.operate |
| `/warehouse/issues/:id` | WarehouseIssueDetail | warehouse.view |
| `/warehouse/issues/:id/edit` | WarehouseIssueForm | warehouse.operate |
| `/warehouse/reservations` | WarehouseReservations | warehouse.view |
| `/warehouse/inventory` | WarehouseInventory | warehouse.inventory |
| `/warehouse/inventory/new` | WarehouseInventoryForm | warehouse.inventory |
| `/warehouse/inventory/:id` | WarehouseInventoryDetail | warehouse.inventory |
| `/warehouse/reports` | WarehouseReports | warehouse.view |
| `/warehouse/suppliers` | WarehouseSuppliers | warehouse.manage |
| `/warehouse/locations` | WarehouseLocations | warehouse.manage |
| `/warehouse/categories` | WarehouseCategories | warehouse.manage |
### Warehouse Dashboard
- Summary cards: total items, total stock value, below-minimum count, active reservations
- Below-minimum alert section (highlighted)
- Recent movements (last 10)
### WarehouseReports
- Tab-based: Stock Status | Project Consumption | Movement Log | Below Minimum
- Each tab has filters (date range, project, category, supplier)
- Paginated data tables with sorting
### Key Shared Components
| Component | Purpose |
| ---------------------- | ----------------------------------------------------------------------------- |
| ItemPicker | Searchable item selector (name + catalog number), used in receipt/issue forms |
| BatchPicker | FIFO batch selector for issue lines, shows available qty per batch |
| LocationSelect | Location dropdown |
| SupplierSelect | Supplier dropdown |
| WarehouseMovementTable | Reusable multi-line editor for receipt/issue lines |
### Query Keys
```
["warehouse"] -> invalidates all
["warehouse", "items", filters] -> item list
["warehouse", "items", id] -> item detail
["warehouse", "receipts", filters] -> receipt list
["warehouse", "receipts", id] -> receipt detail
["warehouse", "issues", filters] -> issue list
["warehouse", "issues", id] -> issue detail
["warehouse", "reservations", filters] -> reservation list
["warehouse", "inventory", filters] -> inventory sessions
["warehouse", "inventory", id] -> inventory detail
["warehouse", "suppliers", filters] -> supplier list
["warehouse", "locations"] -> location list
["warehouse", "categories"] -> category list
["warehouse", "reports", type, filters] -> report data
```
Mutation invalidation: broad `["warehouse"]` for any create/update/delete.
---
## Permissions
| Permission | Display Name | Module | Description |
| --------------------- | -------------- | ------ | ------------------------------------------------------ |
| `warehouse.view` | Zobrazit sklad | sklad | View stock status, items, reports, movement history |
| `warehouse.operate` | Prijem a vydej | sklad | Create/confirm receipts, issues, reservations |
| `warehouse.manage` | Sprava skladu | sklad | Manage items catalog, suppliers, locations, categories |
| `warehouse.inventory` | Inventura | sklad | Create/confirm inventory sessions |
---
## Number Sequences
Three new types registered in `number_sequences`:
- `warehouse_receipt` - generated on receipt confirm
- `warehouse_issue` - generated on issue confirm
- `warehouse_inventory` - generated on inventory session confirm
Pattern configurable via `company_settings`, same as invoices.
---
## File Storage
Receipt attachments use existing `NasFinancialsManager`:
- Stored under `warehouse/YYYY/MM/` on NAS
- DB metadata in `sklad_receipt_attachments` (file_name, file_mime, file_size, file_path)
- Upload via `@fastify/multipart`
---
## File Structure
### New Files
```
src/
routes/admin/warehouse.ts
services/warehouse.service.ts
schemas/warehouse.schema.ts
admin/
pages/
Warehouse.tsx
WarehouseItems.tsx
WarehouseItemDetail.tsx
WarehouseReceipts.tsx
WarehouseReceiptDetail.tsx
WarehouseReceiptForm.tsx
WarehouseIssues.tsx
WarehouseIssueDetail.tsx
WarehouseIssueForm.tsx
WarehouseReservations.tsx
WarehouseInventory.tsx
WarehouseInventoryDetail.tsx
WarehouseInventoryForm.tsx
WarehouseReports.tsx
WarehouseSuppliers.tsx
WarehouseLocations.tsx
WarehouseCategories.tsx
lib/queries/warehouse.ts
components/warehouse/
ItemPicker.tsx
BatchPicker.tsx
LocationSelect.tsx
SupplierSelect.tsx
WarehouseMovementTable.tsx
```
### Modified Files
| File | Change |
| ---------------------------------- | ---------------------------------- |
| `src/server.ts` | Import + register warehouse routes |
| `src/types/index.ts` | Add 8 EntityType values |
| `src/admin/AdminApp.tsx` | Lazy imports + routes |
| `src/admin/components/Sidebar.tsx` | Add warehouse menu entry |
| `prisma/schema.prisma` | Add 13 models + 5 enums |
| `prisma/seed.ts` | Add 4 permissions |
---
## Audit Entity Types
New values added to `EntityType` union in `src/types/index.ts`:
- `warehouse_item`
- `warehouse_receipt`
- `warehouse_issue`
- `warehouse_reservation`
- `warehouse_inventory`
- `warehouse_supplier`
- `warehouse_category`
- `warehouse_location`
---
## Constraints & Edge Cases
1. **Batch consumption on cancel:** If any batch from a receipt has been partially or fully consumed by an issue, the receipt cannot be cancelled. User must first cancel the consuming issues.
2. **Reservation availability:** Available qty = sum of active batch quantities - sum of active reservation quantities. Reservation creation validates available_qty >= requested.
3. **Location qty consistency:** `sklad_item_locations` is a denormalized cache for quick lookups. It must always stay in sync with `sklad_batches`. The confirm/cancel transactions maintain this.
4. **FIFO spanning batches:** When auto-FIFO selects batches for an issue line and the quantity spans multiple batches, the service creates **multiple issue lines** (one per batch consumed), all under the same issue document. The user's original requested quantity is preserved across the split lines. This works because each `sklad_issue_lines` row has exactly one `batch_id`.
5. **Inventory corrective movements:** On confirm, each line with a difference creates an auto-confirmed receipt or issue. The `notes` field of these corrective documents references the inventory session ID and line, so they can be traced back. These corrective documents are fully audited like manual receipts/issues.
6. **Unit consistency:** Once an item is created with a unit, it cannot be changed if any movements exist. This prevents unit mismatches in stock calculations.

View File

@@ -0,0 +1,191 @@
# Manual project & order creation — design
Date: 2026-06-06
Status: approved (pending spec review)
Author: Claude + BOHA
## Problem
Today both entities are only ever _derived_ from a parent:
- A **project** is created solely as a side-effect of `createOrderFromQuotation`
(`orders.service.ts`). There is **no** `POST /projects`, no `createProject`
service, no `CreateProjectSchema`. A manual-create feature existed but was
**removed in commit `82919d3`** (2026-04-28) — the note: _"Projects can only
be created through orders (shared numbering sequence)."_ The old code pulled
`project_number` from the shared order/project pool, which consumed an order
number and left **gaps in order numbering**. That is the bug we must not repeat.
- An **order** is normally created from an offer. A manual `createOrder`
(`orders.service.ts`, offer-less) **already exists** and the route already
dispatches to it, but there is **no UI**, and it does not create a project.
We want: (1) manually create projects on `/projects`, (2) manually create
orders without an offer on `/orders`.
## Decisions (locked with the user)
1. **Project numbering: shared pool, _with_ proper tracking.** Standalone
projects take the next **shared** number via `generateSharedNumber()` — the
same pool as orders — but we add the tracking/transparency that was missing
before (see "Tracking" below). Auto-assigned; no manual number entry.
2. **Manual order → project: optional checkbox, default ON.** The order form
has a pre-checked "Vytvořit propojený projekt"; when set, the order and its
project share the order number (identical to the from-offer flow → no gap).
3. **Order form scope: header only.** Customer, customer order number,
currency/VAT, scope title/description, notes. No line-item editor.
4. **Customer: optional** on both forms (matches the existing nullable schema).
5. **Match existing house design**`FormModal` + `FormField`, the
`Vehicles.tsx` create/edit-modal pattern, the `OffersCustomers.tsx`
header-button + customer-selector pattern, existing list/badge styling.
## Why the shared pool is safe here (the tracking)
The shared sequence is effectively a single "zakázkové číslo" pool. An order and
its project share one number; a standalone project simply takes the next number
in that pool and has no order. The mechanics that make this coherent:
- **Collision-safe:** `isSharedNumberTaken()` already checks **both**
`orders.order_number` and `projects.project_number`, so the same number can
never be issued twice across the pool (`numbering.service.ts:161-167`).
- **Atomic:** `createProject` wraps `generateSharedNumber(tx)` in
`prisma.$transaction` — the existing `SELECT … FOR UPDATE` lock
(`getNextSequence`) serialises concurrent consumers.
- **Release on delete:** `deleteProject` already calls `releaseSharedNumber`,
which decrements the sequence only if the deleted number is the current
highest — so deleting a standalone project doesn't leave a permanent tail-gap
(`numbering.service.ts:116-158, 319-328`).
- **Audit trail:** `logAudit` on create/delete records the number in
`newValues`/`oldValues`, so every consumed pool number has a "who/when/what"
trail showing it went to a project (not a lost order).
- **UI transparency:** the Projects list shows a badge — **"z objednávky"**
(order-linked, `order_id != null`) vs **"samostatný"** (standalone) — so a
pool number with no order reads as intentional. The create form previews the
number it is about to assign (`previewSharedNumber()`).
## Part A — Manual projects
### Backend
- **`src/schemas/projects.schema.ts`** — add `CreateProjectSchema`
(`z.strictObject`, shared coercers from `schemas/common.ts`, Czech messages):
- `name` — required, `z.string().min(1)`.
- `customer_id` — optional number (coerced, NaN-guarded).
- `responsible_user_id` — optional number.
- `status` — optional, default `"aktivni"`.
- `start_date`, `end_date` — optional date strings.
- `notes` — optional string.
- **No** `project_number` field (auto-assigned from the pool).
- **`src/services/projects.service.ts`** — add `createProject(data)`:
- `prisma.$transaction(async (tx) => …)`: `project_number = await
generateSharedNumber(tx)`; `tx.projects.create({ … order_id: null,
quotation_id: null, status: data.status ?? "aktivni", … })`.
- Validate `customer_id` / `responsible_user_id` exist (when provided) →
return `{ error, status: 400 }` (Czech) if not.
- After commit: `nasFileManager.createProjectFolder(number, name)` when
`isConfigured()` — non-fatal, logged on failure (mirrors the old code).
- Return `{ data: project }` (or `{ error, status }`).
- **`src/routes/admin/projects.ts`**:
- `POST /` guarded by `requirePermission("projects.create")` →
`parseBody(CreateProjectSchema, …)` → `createProject` → `logAudit`
(`action: "create"`, `entityType: "project"`, `newValues`) →
`success(reply, project, 201, "Projekt vytvořen")`.
- `GET /next-number` guarded by `projects.create` → `previewSharedNumber()` →
`success(reply, { number })`. (Preview only; does not consume.)
### Frontend (`src/admin/pages/Projects.tsx` + new modal)
- Header **"Přidat projekt"** button, gated on `hasPermission("projects.create")`,
matching the `OffersCustomers.tsx` "Přidat zákazníka" header-button pattern.
- A project create `FormModal` (house pattern from `Vehicles.tsx`): `FormField`
rows for name (required), customer (select from customers list), responsible
user (select from `userListOptions`), status, start/end dates, notes, plus a
read-only "Číslo projektu: `<preview>` (přiděleno automaticky)" line fetched
from `GET /projects/next-number` when the modal opens.
- `useApiMutation` POST `/api/admin/projects`; on success invalidate
**`["projects"]`** (broad domain key, per the invalidation convention) and
close modal; surface server errors via `useAlert`.
- List: add the **"z objednávky" / "samostatný"** badge (derive from
`order_id`). Update the empty-state text (currently "Projekt se vytvoří
automaticky při vytvoření objednávky") to mention manual creation.
## Part B — Manual orders (header only, optional project)
### Backend
- **`src/schemas/orders.schema.ts`** — add `create_project` to
`CreateOrderSchema`: boolean via the existing `preprocess(v => v === true || v
=== 1 || v === "1")` idiom, `.optional().default(true)`. (Only the manual path
uses this schema; the from-quotation path uses `CreateOrderFromQuotationSchema`.)
- **`src/services/orders.service.ts` `createOrder`** — inside the existing
transaction, after the order is created, when `body.create_project` is true and
there is no quotation link: `tx.projects.create({ project_number: orderNumber,
order_id: order.id, customer_id: order.customer_id, name: scope_title ??
customer_order_number ?? orderNumber, status: "aktivni" })` — mirrors
`createOrderFromQuotation`'s project block, so order + project share the number.
Return the project id so the route can audit it.
- **`src/routes/admin/orders.ts`** — the manual `POST /` branch already exists
(`orders.create`); add a second `logAudit` for the created project when present.
### Frontend (`src/admin/pages/Orders.tsx` + new modal)
- Header **"Vytvořit objednávku"** button, gated on `orders.create`.
- Header-only `FormModal`: customer (select), customer order number, currency,
VAT rate + apply-VAT, language, scope title, scope description, notes, and a
pre-checked **"Vytvořit propojený projekt"** checkbox. Read-only next
order-number preview via the existing `GET /api/admin/orders/next-number`.
- POST `/api/admin/orders` (no `quotationId`); on success invalidate
**`["orders"]`** and **`["projects"]`**; errors via `useAlert`.
## Part C — Permissions
`projects.create` was removed from the permission set and must come back the
**migration** way (per CLAUDE.md — never seed-only for prod data):
- New migration `prisma/migrations/<ts>_add_projects_create_permission/` that
`INSERT`s the `projects.create` permission row and grants it to the **admin**
role (mirror `20260603230000_add_warehouse_permissions/migration.sql`). Use
`INSERT … ON DUPLICATE KEY UPDATE` / `INSERT IGNORE` style so re-runs are safe.
- Add `projects.create` to the `PERMISSIONS` array in `prisma/seed.ts` (dev).
- `orders.create` already exists — no change.
## Part D — Tests (`src/__tests__/`)
Real-DB Vitest (no Prisma mocks), following `numbering.test.ts` style:
- `createProject` assigns the next shared number, increments the `shared`
sequence by exactly 1, sets `order_id = null`, writes an audit row.
- `createOrder` with `create_project: true` creates an order **and** a project
sharing one `order_number`/`project_number`.
- Interleaved coherence: order → standalone project → order produces three
consecutive shared numbers with no duplicates (the tracking guarantee).
- `createOrder` with `create_project: false` creates only the order.
## Error handling
- Service returns `{ error, status }` (Czech) for bad customer/user FK and
numbering exhaustion (the `generateSharedNumber` 100-retry throw → mapped to a
500 with a Czech message by the route, logged via `app.log.error`).
- NAS folder creation is non-fatal and logged (never blocks creation).
## Out of scope (v1) — noted for later
- Editing order line-items after creation.
- Attaching a standalone project to an order later (conflicts with current
`order_id` immutability + the `has_order` delete guard).
- Manual project-number override (user chose auto-from-pool).
- A unified order+project number lookup/search.
## Affected files (summary)
- `src/schemas/projects.schema.ts` (add `CreateProjectSchema`)
- `src/schemas/orders.schema.ts` (add `create_project`)
- `src/services/projects.service.ts` (add `createProject`)
- `src/services/orders.service.ts` (`createOrder` optional project)
- `src/routes/admin/projects.ts` (`POST /`, `GET /next-number`)
- `src/routes/admin/orders.ts` (audit project on manual order)
- `src/admin/pages/Projects.tsx` (+ create modal, badge, empty-state)
- `src/admin/pages/Orders.tsx` (+ create modal)
- `src/admin/lib/queries/projects.ts`, `orders.ts` (mutations/preview queries)
- `prisma/migrations/<ts>_add_projects_create_permission/migration.sql`
- `prisma/seed.ts` (+ `projects.create`)
- `src/__tests__/manual-create.test.ts` (new)

View File

@@ -0,0 +1,191 @@
# 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 102107) 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, 1100 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.