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:
770
docs/superpowers/specs/2026-05-29-warehouse-module-design.md
Normal file
770
docs/superpowers/specs/2026-05-29-warehouse-module-design.md
Normal 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.
|
||||
@@ -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)
|
||||
@@ -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 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.
|
||||
Reference in New Issue
Block a user