# Warehouse Module Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Full warehouse/inventory management module with receipts, issues, reservations, inventory sessions, FIFO batch tracking, and reporting.

**Architecture:** Document-driven (header + lines) matching existing invoice/offer patterns. 13 Prisma models, REST API under `/api/admin/warehouse`, React frontend with 17 lazy-loaded pages. True FIFO via `sklad_batches` table linked to receipt lines.

**Tech Stack:** Prisma 6.19.2, Fastify 5, Zod 4, React 18, TanStack React Query, NAS file storage via NasFinancialsManager

---

## File Structure

### New Files

```
prisma/migrations/                          # Auto-generated by prisma migrate dev

src/
  schemas/warehouse.schema.ts               # Zod validation schemas for all warehouse entities
  services/warehouse.service.ts              # Business logic (FIFO, confirm, cancel, inventory)
  routes/admin/warehouse.ts                  # All warehouse API endpoints

  admin/
    lib/queries/warehouse.ts                 # TanStack Query options
    components/warehouse/
      ItemPicker.tsx                          # Searchable item selector
      BatchPicker.tsx                         # FIFO batch selector
      LocationSelect.tsx                      # Location dropdown
      SupplierSelect.tsx                      # Supplier dropdown
      WarehouseMovementTable.tsx              # Multi-line editor for receipt/issue lines
    pages/
      Warehouse.tsx                           # Dashboard
      WarehouseItems.tsx                      # Item catalog list
      WarehouseItemDetail.tsx                 # Item detail
      WarehouseReceipts.tsx                   # Receipt list
      WarehouseReceiptDetail.tsx              # Receipt detail
      WarehouseReceiptForm.tsx                # Create/edit receipt
      WarehouseIssues.tsx                     # Issue list
      WarehouseIssueDetail.tsx                # Issue detail
      WarehouseIssueForm.tsx                   # Create/edit issue
      WarehouseReservations.tsx               # Reservation list
      WarehouseInventory.tsx                   # Inventory sessions list
      WarehouseInventoryDetail.tsx             # Inventory detail
      WarehouseInventoryForm.tsx               # Create/edit inventory session
      WarehouseReports.tsx                     # Reports hub
      WarehouseSuppliers.tsx                   # Suppliers CRUD
      WarehouseLocations.tsx                  # Locations CRUD
      WarehouseCategories.tsx                  # Categories CRUD

src/__tests__/warehouse.test.ts              # Backend integration tests
```

### Modified Files

| File                               | Change                                      |
| ---------------------------------- | ------------------------------------------- |
| `prisma/schema.prisma`             | Add 13 models + 5 enums                     |
| `src/types/index.ts`               | Add 8 EntityType values                     |
| `src/server.ts`                    | Import + register warehouse routes          |
| `prisma/seed.ts`                   | Add 4 permissions + 3 number sequence types |
| `src/admin/AdminApp.tsx`           | Lazy imports + routes                       |
| `src/admin/components/Sidebar.tsx` | Add warehouse menu entry                    |

---

## Phase 1: Database Foundation

### Task 1: Add Prisma Models

**Files:**

- Modify: `prisma/schema.prisma`

- [ ] **Step 1: Add warehouse enums to schema.prisma**

Add these enums at the end of the existing enums section in `prisma/schema.prisma`:

```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
}
```

- [ ] **Step 2: Add warehouse models to schema.prisma**

Add these models at the end of `prisma/schema.prisma`:

```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")
}

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")
}

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")
}

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")
}

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")
}

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")
}
```

- [ ] **Step 3: Add reverse relations to existing models**

Add these relation fields to the `projects` model in `prisma/schema.prisma`:

```prisma
  sklad_issues       sklad_issues[]
  sklad_reservations sklad_reservations[]
```

Add this relation field to the `users` model in `prisma/schema.prisma`:

```prisma
  sklad_receipts_received sklad_receipts[] @relation("SkladReceivedBy")
  sklad_issues_issued     sklad_issues[]   @relation("SkladIssuedBy")
  sklad_reservations      sklad_reservations[] @relation("SkladReservedBy")
```

Note: The `users` model likely already has multiple relations, so named relation mappings may be needed to avoid ambiguity. Use `@relation("SkladReceivedBy")` on `sklad_receipts.received_by_user`, `@relation("SkladIssuedBy")` on `sklad_issues.issued_by_user`, and `@relation("SkladReservedBy")` on `sklad_reservations.reserved_by_user`. Update the corresponding relation field definitions in the warehouse models accordingly:

In `sklad_receipts`:

```prisma
  received_by_user  users? @relation("SkladReceivedBy", fields: [received_by], references: [id])
```

In `sklad_issues`:

```prisma
  issued_by_user  users? @relation("SkladIssuedBy", fields: [issued_by], references: [id])
```

In `sklad_reservations`:

```prisma
  reserved_by_user  users? @relation("SkladReservedBy", fields: [reserved_by], references: [id])
```

- [ ] **Step 4: Run migration**

```bash
npx prisma migrate dev --name add_warehouse_module
npx prisma generate
```

Expected: Migration created and applied. Prisma client regenerated.

- [ ] **Step 5: Commit**

```bash
git add prisma/schema.prisma prisma/migrations/
git commit -m "feat(warehouse): add Prisma schema for warehouse module"
```

---

### Task 2: Add Entity Types and Permissions

**Files:**

- Modify: `src/types/index.ts`
- Modify: `prisma/seed.ts`

- [ ] **Step 1: Add EntityType values**

In `src/types/index.ts`, extend the `EntityType` union to include:

```ts
export type EntityType =
  | "user"
  | "role"
  | "attendance"
  | "leave_request"
  | "invoice"
  | "quotation"
  | "order"
  | "project"
  | "customer"
  | "trip"
  | "vehicle"
  | "bank_account"
  | "company_settings"
  | "leave_balance"
  | "project_file"
  | "audit_logs"
  | "warehouse_item"
  | "warehouse_receipt"
  | "warehouse_issue"
  | "warehouse_reservation"
  | "warehouse_inventory"
  | "warehouse_supplier"
  | "warehouse_category"
  | "warehouse_location";
```

- [ ] **Step 2: Add warehouse permissions to seed.ts**

In `prisma/seed.ts`, add these entries to the `PERMISSIONS` array:

```ts
  { name: "warehouse.view", display_name: "Zobrazit sklad", module: "warehouse", description: "Prohlížet stav skladu, položky, reporty a historii pohybů" },
  { name: "warehouse.operate", display_name: "Příjem a výdej", module: "warehouse", description: "Vytvářet a potvrzovat příjmy, výdeje a rezervace" },
  { name: "warehouse.manage", display_name: "Správa skladu", module: "warehouse", description: "Spravovat katalog materiálů, dodavatele, lokace a kategorie" },
  { name: "warehouse.inventory", display_name: "Inventura", module: "warehouse", description: "Vytvářet a potvrzovat inventurní sčítkání" },
```

- [ ] **Step 3: Add number sequence types to seed.ts**

In the number sequence seeding section of `prisma/seed.ts`, add `"warehouse_receipt"`, `"warehouse_issue"`, and `"warehouse_inventory"` to the types array:

```ts
const types = [
  "quotation",
  "order",
  "invoice",
  "warehouse_receipt",
  "warehouse_issue",
  "warehouse_inventory",
];
```

- [ ] **Step 4: Re-seed and verify**

```bash
npx prisma db seed
```

Expected: 4 new warehouse permissions created, 3 new number sequence types seeded.

- [ ] **Step 5: Commit**

```bash
git add src/types/index.ts prisma/seed.ts
git commit -m "feat(warehouse): add entity types, permissions, and number sequences"
```

---

## Phase 2: Backend - Schemas & Service

### Task 3: Zod Validation Schemas

**Files:**

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

- [ ] **Step 1: Create warehouse.schema.ts**

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

// === Categories ===
export const CreateCategorySchema = z.object({
  name: z.string().min(1, "Název je povinný"),
  description: z.string().nullish(),
  sort_order: z
    .union([z.number(), z.string()])
    .transform((v) => Number(v))
    .optional()
    .default(0),
});
export const UpdateCategorySchema = z.object({
  name: z.string().min(1, "Název je povinný").optional(),
  description: z.string().nullish(),
  sort_order: z
    .union([z.number(), z.string()])
    .transform((v) => Number(v))
    .optional(),
});
export type CreateCategoryInput = z.infer<typeof CreateCategorySchema>;
export type UpdateCategoryInput = z.infer<typeof UpdateCategorySchema>;

// === Suppliers ===
export const CreateSupplierSchema = z.object({
  name: z.string().min(1, "Název je povinný"),
  ico: z.string().nullish(),
  dic: z.string().nullish(),
  contact_person: z.string().nullish(),
  email: z.string().nullish(),
  phone: z.string().nullish(),
  address: z.string().nullish(),
  notes: z.string().nullish(),
  is_active: z
    .preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
    .optional()
    .default(true),
});
export const UpdateSupplierSchema = z.object({
  name: z.string().min(1, "Název je povinný").optional(),
  ico: z.string().nullish(),
  dic: z.string().nullish(),
  contact_person: z.string().nullish(),
  email: z.string().nullish(),
  phone: z.string().nullish(),
  address: z.string().nullish(),
  notes: z.string().nullish(),
  is_active: z
    .preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
    .optional(),
});
export type CreateSupplierInput = z.infer<typeof CreateSupplierSchema>;
export type UpdateSupplierInput = z.infer<typeof UpdateSupplierSchema>;

// === Locations ===
export const CreateLocationSchema = z.object({
  code: z.string().min(1, "Kód je povinný"),
  name: z.string().min(1, "Název je povinný"),
  description: z.string().nullish(),
  is_active: z
    .preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
    .optional()
    .default(true),
});
export const UpdateLocationSchema = z.object({
  code: z.string().min(1, "Kód je povinný").optional(),
  name: z.string().min(1, "Název je povinný").optional(),
  description: z.string().nullish(),
  is_active: z
    .preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
    .optional(),
});
export type CreateLocationInput = z.infer<typeof CreateLocationSchema>;
export type UpdateLocationInput = z.infer<typeof UpdateLocationSchema>;

// === Items ===
const UNIT_LIST = ["ks", "m", "kg", "bal", "sada", "m2", "m3", "l"] as const;
export const UnitEnum = z.enum(UNIT_LIST);
export type Unit = z.infer<typeof UnitEnum>;

export const CreateItemSchema = z.object({
  item_number: z.string().nullish(),
  name: z.string().min(1, "Název je povinný"),
  description: z.string().nullish(),
  category_id: z
    .union([z.number(), z.string(), z.null()])
    .transform((v) => (v === null ? null : Number(v)))
    .nullish(),
  unit: UnitEnum,
  min_quantity: z
    .union([z.number(), z.string(), z.null()])
    .transform((v) => (v === null ? null : Number(v)))
    .nullish(),
  notes: z.string().nullish(),
  is_active: z
    .preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
    .optional()
    .default(true),
});
export const UpdateItemSchema = z.object({
  item_number: z.string().nullish(),
  name: z.string().min(1, "Název je povinný").optional(),
  description: z.string().nullish(),
  category_id: z
    .union([z.number(), z.string(), z.null()])
    .transform((v) => (v === null ? null : Number(v)))
    .nullish(),
  unit: UnitEnum.optional(),
  min_quantity: z
    .union([z.number(), z.string(), z.null()])
    .transform((v) => (v === null ? null : Number(v)))
    .nullish(),
  notes: z.string().nullish(),
  is_active: z
    .preprocess((v) => v === true || v === 1 || v === "1", z.boolean())
    .optional(),
});
export type CreateItemInput = z.infer<typeof CreateItemSchema>;
export type UpdateItemInput = z.infer<typeof UpdateItemSchema>;

// === Receipts ===
export const ReceiptLineSchema = z.object({
  item_id: z.union([z.number(), z.string()]).transform((v) => Number(v)),
  quantity: z.union([z.number(), z.string()]).transform((v) => Number(v)),
  unit_price: z.union([z.number(), z.string()]).transform((v) => Number(v)),
  location_id: z
    .union([z.number(), z.string(), z.null()])
    .transform((v) => (v === null ? null : Number(v)))
    .nullish(),
  notes: z.string().nullish(),
});

export const CreateReceiptSchema = z.object({
  supplier_id: z
    .union([z.number(), z.string(), z.null()])
    .transform((v) => (v === null ? null : Number(v)))
    .nullish(),
  delivery_note_number: z.string().nullish(),
  delivery_note_date: z.union([z.string(), z.null()]).nullish(),
  notes: z.string().nullish(),
  lines: z
    .array(ReceiptLineSchema)
    .min(1, "Příjem musí obsahovat alespoň jednu položku"),
});
export const UpdateReceiptSchema = z.object({
  supplier_id: z
    .union([z.number(), z.string(), z.null()])
    .transform((v) => (v === null ? null : Number(v)))
    .nullish(),
  delivery_note_number: z.string().nullish(),
  delivery_note_date: z.union([z.string(), z.null()]).nullish(),
  notes: z.string().nullish(),
  lines: z
    .array(ReceiptLineSchema)
    .min(1, "Příjem musí obsahovat alespoň jednu položku")
    .optional(),
});
export type CreateReceiptInput = z.infer<typeof CreateReceiptSchema>;
export type UpdateReceiptInput = z.infer<typeof UpdateReceiptSchema>;
export type ReceiptLineInput = z.infer<typeof ReceiptLineSchema>;

// === Issues ===
export const IssueLineSchema = z.object({
  item_id: z.union([z.number(), z.string()]).transform((v) => Number(v)),
  batch_id: z
    .union([z.number(), z.string()])
    .transform((v) => Number(v))
    .nullish(),
  quantity: z.union([z.number(), z.string()]).transform((v) => Number(v)),
  location_id: z
    .union([z.number(), z.string(), z.null()])
    .transform((v) => (v === null ? null : Number(v)))
    .nullish(),
  reservation_id: z
    .union([z.number(), z.string(), z.null()])
    .transform((v) => (v === null ? null : Number(v)))
    .nullish(),
  notes: z.string().nullish(),
});

export const CreateIssueSchema = z.object({
  project_id: z.union([z.number(), z.string()]).transform((v) => Number(v)),
  notes: z.string().nullish(),
  lines: z
    .array(IssueLineSchema)
    .min(1, "Výdej musí obsahovat alespoň jednu položku"),
});
export const UpdateIssueSchema = z.object({
  project_id: z
    .union([z.number(), z.string()])
    .transform((v) => Number(v))
    .optional(),
  notes: z.string().nullish(),
  lines: z
    .array(IssueLineSchema)
    .min(1, "Výdej musí obsahovat alespoň jednu položku")
    .optional(),
});
export type CreateIssueInput = z.infer<typeof CreateIssueSchema>;
export type UpdateIssueInput = z.infer<typeof UpdateIssueSchema>;
export type IssueLineInput = z.infer<typeof IssueLineSchema>;

// === Reservations ===
export const CreateReservationSchema = z.object({
  item_id: z.union([z.number(), z.string()]).transform((v) => Number(v)),
  project_id: z.union([z.number(), z.string()]).transform((v) => Number(v)),
  quantity: z.union([z.number(), z.string()]).transform((v) => Number(v)),
  notes: z.string().nullish(),
});
export type CreateReservationInput = z.infer<typeof CreateReservationSchema>;

// === Inventory ===
export const InventoryLineSchema = z.object({
  item_id: z.union([z.number(), z.string()]).transform((v) => Number(v)),
  location_id: z
    .union([z.number(), z.string(), z.null()])
    .transform((v) => (v === null ? null : Number(v)))
    .nullish(),
  actual_qty: z.union([z.number(), z.string()]).transform((v) => Number(v)),
  notes: z.string().nullish(),
});

export const CreateInventorySessionSchema = z.object({
  notes: z.string().nullish(),
  lines: z
    .array(InventoryLineSchema)
    .min(1, "Inventura musí obsahovat alespoň jednu položku"),
});
export const UpdateInventorySessionSchema = z.object({
  notes: z.string().nullish(),
  lines: z
    .array(InventoryLineSchema)
    .min(1, "Inventura musí obsahovat alespoň jednu položku")
    .optional(),
});
export type CreateInventorySessionInput = z.infer<
  typeof CreateInventorySessionSchema
>;
export type UpdateInventorySessionInput = z.infer<
  typeof UpdateInventorySessionSchema
>;
export type InventoryLineInput = z.infer<typeof InventoryLineSchema>;
```

- [ ] **Step 2: Verify schema compiles**

```bash
npx tsc --noEmit src/schemas/warehouse.schema.ts
```

Expected: No type errors.

- [ ] **Step 3: Commit**

```bash
git add src/schemas/warehouse.schema.ts
git commit -m "feat(warehouse): add Zod validation schemas"
```

---

### Task 4: Warehouse Service - Master Data & FIFO

**Files:**

- Create: `src/services/warehouse.service.ts`

- [ ] **Step 1: Create warehouse.service.ts with master data functions and FIFO logic**

```ts
import prisma from "../config/database";
import { Prisma } from "@prisma/client";

type TxClient = Omit<
  Prisma.Client,
  "$connect" | "$disconnect" | "$on" | "$transaction" | "$extends"
>;

// === Available Quantity ===

export async function getItemAvailableQty(itemId: number): Promise<number> {
  const totalStock = await prisma.sklad_batches.aggregate({
    where: { item_id: itemId, is_consumed: false },
    _sum: { quantity: true },
  });
  const totalReserved = await prisma.sklad_reservations.aggregate({
    where: { item_id: itemId, status: "ACTIVE" },
    _sum: { quantity: true },
  });
  const stock = Number(totalStock._sum.quantity ?? 0);
  const reserved = Number(totalReserved._sum.quantity ?? 0);
  return Math.max(0, stock - reserved);
}

// === FIFO Batch Selection ===

export async function selectFifoBatches(
  itemId: number,
  requestedQty: number,
  tx?: TxClient,
): Promise<Array<{ batchId: number; qty: number }>> {
  const client = tx ?? prisma;
  const batches = await client.sklad_batches.findMany({
    where: { item_id: itemId, is_consumed: false, quantity: { gt: 0 } },
    orderBy: { received_at: "asc" },
  });

  const result: Array<{ batchId: number; qty: number }> = [];
  let remaining = requestedQty;

  for (const batch of batches) {
    if (remaining <= 0) break;
    const available = Number(batch.quantity);
    const take = Math.min(available, remaining);
    result.push({ batchId: batch.id, qty: take });
    remaining -= take;
  }

  if (remaining > 0) {
    throw new Error(
      `Nedostatečné zásoby pro položku ID ${itemId}. Chybí ${remaining} jednotek.`,
    );
  }

  return result;
}

// === Item Total Stock ===

export async function getItemTotalStock(itemId: number): Promise<number> {
  const result = await prisma.sklad_batches.aggregate({
    where: { item_id: itemId, is_consumed: false },
    _sum: { quantity: true },
  });
  return Number(result._sum.quantity ?? 0);
}

// === Item Stock Value ===

export async function getItemStockValue(itemId: number): Promise<number> {
  const batches = await prisma.sklad_batches.findMany({
    where: { item_id: itemId, is_consumed: false },
    select: { quantity: true, unit_price: true },
  });
  return batches.reduce(
    (sum, b) => sum + Number(b.quantity) * Number(b.unit_price),
    0,
  );
}

// === Below Minimum Items ===

export async function getBelowMinimumItems() {
  const items = await prisma.sklad_items.findMany({
    where: { is_active: true, min_quantity: { not: null } },
    include: { category: true },
  });

  const result = [];
  for (const item of items) {
    const totalStock = await getItemTotalStock(item.id);
    const minQty = Number(item.min_quantity ?? 0);
    if (totalStock < minQty) {
      result.push({
        ...item,
        total_quantity: totalStock,
        below_by: minQty - totalStock,
      });
    }
  }
  return result;
}

// === Receipt Confirm ===

export async function confirmReceipt(receiptId: number, authUserId: number) {
  return prisma.$transaction(async (tx) => {
    const receipt = await tx.sklad_receipts.findUnique({
      where: { id: receiptId },
      include: { lines: true },
    });

    if (!receipt) return { error: "Příjem nenalezen", status: 404 };
    if (receipt.status !== "DRAFT")
      return { error: "Lze potvrdit pouze návrh", status: 400 };

    // Generate receipt number
    const seq = await tx.number_sequences.findFirst({
      where: { type: "warehouse_receipt", year: new Date().getFullYear() },
    });
    if (!seq)
      return { error: "Číselná řada pro příjmy nenalezena", status: 500 };

    const nextNum = (seq.last_number ?? 0) + 1;
    await tx.number_sequences.update({
      where: { id: seq.id },
      data: { last_number: nextNum },
    });

    const year = new Date().getFullYear();
    const receiptNumber = `PRI-${year}-${String(nextNum).padStart(3, "0")}`;

    // Create batches and update item locations for each line
    for (const line of receipt.lines) {
      // Create batch
      await tx.sklad_batches.create({
        data: {
          item_id: line.item_id,
          receipt_line_id: line.id,
          quantity: line.quantity,
          original_qty: line.quantity,
          unit_price: line.unit_price,
          received_at: new Date(),
          is_consumed: false,
        },
      });

      // Update item location
      if (line.location_id) {
        const existing = await tx.sklad_item_locations.findUnique({
          where: {
            item_id_location_id: {
              item_id: line.item_id,
              location_id: line.location_id,
            },
          },
        });
        if (existing) {
          await tx.sklad_item_locations.update({
            where: { id: existing.id },
            data: { quantity: { increment: line.quantity } },
          });
        } else {
          await tx.sklad_item_locations.create({
            data: {
              item_id: line.item_id,
              location_id: line.location_id,
              quantity: line.quantity,
            },
          });
        }
      }
    }

    // Update receipt status
    await tx.sklad_receipts.update({
      where: { id: receiptId },
      data: {
        status: "CONFIRMED",
        receipt_number: receiptNumber,
        modified_at: new Date(),
      },
    });

    return { data: { id: receiptId, receipt_number: receiptNumber } };
  });
}

// === Receipt Cancel ===

export async function cancelReceipt(receiptId: number) {
  return prisma.$transaction(async (tx) => {
    const receipt = await tx.sklad_receipts.findUnique({
      where: { id: receiptId },
      include: { lines: { include: { batch: true } } },
    });

    if (!receipt) return { error: "Příjem nenalezen", status: 404 };
    if (receipt.status === "CANCELLED")
      return { error: "Příjem je již zrušen", status: 400 };

    if (receipt.status === "CONFIRMED") {
      // Check if any batch has been consumed
      for (const line of receipt.lines) {
        if (line.batch) {
          const consumed =
            Number(line.batch.original_qty) - Number(line.batch.quantity);
          if (consumed > 0) {
            return {
              error:
                "Příjem nelze zrušit - některé šarže již byly částečně vydány. Nejprve zrušte příslušné výdeje.",
              status: 409,
            };
          }
        }
      }

      // Reverse stock changes
      for (const line of receipt.lines) {
        if (line.batch) {
          // Remove item location qty
          if (line.location_id) {
            await tx.sklad_item_locations.updateMany({
              where: { item_id: line.item_id, location_id: line.location_id },
              data: { quantity: { decrement: line.batch.quantity } },
            });
          }
          // Delete the batch
          await tx.sklad_batches.delete({ where: { id: line.batch.id } });
        }
      }
    }

    await tx.sklad_receipts.update({
      where: { id: receiptId },
      data: { status: "CANCELLED", modified_at: new Date() },
    });

    return { data: { id: receiptId } };
  });
}

// === Issue Confirm ===

export async function confirmIssue(issueId: number, authUserId: number) {
  return prisma.$transaction(async (tx) => {
    const issue = await tx.sklad_issues.findUnique({
      where: { id: issueId },
      include: { lines: true },
    });

    if (!issue) return { error: "Výdej nenalezen", status: 404 };
    if (issue.status !== "DRAFT")
      return { error: "Lze potvrdit pouze návrh", status: 400 };

    // Validate each line has enough batch quantity
    for (const line of issue.lines) {
      const batch = await tx.sklad_batches.findUnique({
        where: { id: line.batch_id },
      });
      if (!batch)
        return { error: `Šarže ID ${line.batch_id} nenalezena`, status: 404 };
      if (Number(batch.quantity) < Number(line.quantity)) {
        return {
          error: `Nedostatečné zásoby ve šarži ID ${line.batch_id}`,
          status: 400,
        };
      }
    }

    // Generate issue number
    const seq = await tx.number_sequences.findFirst({
      where: { type: "warehouse_issue", year: new Date().getFullYear() },
    });
    if (!seq)
      return { error: "Číselná řada pro výdeje nenalezena", status: 500 };

    const nextNum = (seq.last_number ?? 0) + 1;
    await tx.number_sequences.update({
      where: { id: seq.id },
      data: { last_number: nextNum },
    });

    const year = new Date().getFullYear();
    const issueNumber = `VYD-${year}-${String(nextNum).padStart(3, "0")}`;

    // Process each line
    for (const line of issue.lines) {
      // Decrement batch quantity
      const batch = await tx.sklad_batches.findUnique({
        where: { id: line.batch_id },
      });
      if (!batch) continue;

      const newQty = Number(batch.quantity) - Number(line.quantity);
      await tx.sklad_batches.update({
        where: { id: line.batch_id },
        data: {
          quantity: newQty,
          is_consumed: newQty === 0,
        },
      });

      // Decrement item location
      if (line.location_id) {
        await tx.sklad_item_locations.updateMany({
          where: { item_id: line.item_id, location_id: line.location_id },
          data: { quantity: { decrement: line.quantity } },
        });
      }

      // Fulfill reservation if linked
      if (line.reservation_id) {
        const reservation = await tx.sklad_reservations.findUnique({
          where: { id: line.reservation_id },
        });
        if (reservation) {
          const newRemaining =
            Number(reservation.remaining_qty) - Number(line.quantity);
          await tx.sklad_reservations.update({
            where: { id: line.reservation_id },
            data: {
              remaining_qty: Math.max(0, newRemaining),
              status: newRemaining <= 0 ? "FULFILLED" : "ACTIVE",
              modified_at: new Date(),
            },
          });
        }
      }
    }

    await tx.sklad_issues.update({
      where: { id: issueId },
      data: {
        status: "CONFIRMED",
        issue_number: issueNumber,
        modified_at: new Date(),
      },
    });

    return { data: { id: issueId, issue_number: issueNumber } };
  });
}

// === Issue Cancel ===

export async function cancelIssue(issueId: number) {
  return prisma.$transaction(async (tx) => {
    const issue = await tx.sklad_issues.findUnique({
      where: { id: issueId },
      include: { lines: true },
    });

    if (!issue) return { error: "Výdej nenalezen", status: 404 };
    if (issue.status === "CANCELLED")
      return { error: "Výdej je již zrušen", status: 400 };

    if (issue.status === "CONFIRMED") {
      for (const line of issue.lines) {
        // Restore batch quantity
        const batch = await tx.sklad_batches.findUnique({
          where: { id: line.batch_id },
        });
        if (batch) {
          await tx.sklad_batches.update({
            where: { id: line.batch_id },
            data: {
              quantity: { increment: line.quantity },
              is_consumed: false,
            },
          });
        }

        // Restore item location
        if (line.location_id) {
          await tx.sklad_item_locations.updateMany({
            where: { item_id: line.item_id, location_id: line.location_id },
            data: { quantity: { increment: line.quantity } },
          });
        }

        // Restore reservation
        if (line.reservation_id) {
          const reservation = await tx.sklad_reservations.findUnique({
            where: { id: line.reservation_id },
          });
          if (reservation) {
            await tx.sklad_reservations.update({
              where: { id: line.reservation_id },
              data: {
                remaining_qty: { increment: line.quantity },
                status: "ACTIVE",
                modified_at: new Date(),
              },
            });
          }
        }
      }
    }

    await tx.sklad_issues.update({
      where: { id: issueId },
      data: { status: "CANCELLED", modified_at: new Date() },
    });

    return { data: { id: issueId } };
  });
}

// === Reservation Create ===

export async function createReservation(data: {
  item_id: number;
  project_id: number;
  quantity: number;
  reserved_by?: number;
  notes?: string | null;
}) {
  const item = await prisma.sklad_items.findUnique({
    where: { id: data.item_id },
  });
  if (!item || !item.is_active)
    return { error: "Položka nenalezena nebo není aktivní", status: 404 };

  const available = await getItemAvailableQty(data.item_id);
  if (available < data.quantity) {
    return {
      error: `Nedostatečné dostupné zásoby. K dispozici: ${available}`,
      status: 400,
    };
  }

  const reservation = await prisma.sklad_reservations.create({
    data: {
      item_id: data.item_id,
      project_id: data.project_id,
      quantity: data.quantity,
      remaining_qty: data.quantity,
      reserved_by: data.reserved_by,
      notes: data.notes,
    },
  });

  return { data: reservation };
}

// === Reservation Cancel ===

export async function cancelReservation(reservationId: number) {
  const reservation = await prisma.sklad_reservations.findUnique({
    where: { id: reservationId },
  });
  if (!reservation) return { error: "Rezervace nenalezena", status: 404 };
  if (reservation.status !== "ACTIVE")
    return { error: "Lze zrušit pouze aktivní rezervaci", status: 400 };

  await prisma.sklad_reservations.update({
    where: { id: reservationId },
    data: { status: "CANCELLED", remaining_qty: 0, modified_at: new Date() },
  });

  return { data: { id: reservationId } };
}

// === Inventory Confirm ===

export async function confirmInventorySession(sessionId: number) {
  return prisma.$transaction(async (tx) => {
    const session = await tx.sklad_inventory_sessions.findUnique({
      where: { id: sessionId },
      include: { lines: true },
    });

    if (!session) return { error: "Inventura nenalezena", status: 404 };
    if (session.status !== "DRAFT")
      return { error: "Lze potvrdit pouze návrh", status: 400 };

    for (const line of session.lines) {
      const diff = Number(line.actual_qty) - Number(line.system_qty);

      if (diff > 0) {
        // More stock than system: create corrective receipt
        const correctiveReceipt = await tx.sklad_receipts.create({
          data: {
            supplier_id: null,
            notes: `Korekce z inventury #${session.id}`,
            status: "CONFIRMED",
            lines: {
              create: {
                item_id: line.item_id,
                quantity: diff,
                unit_price: 0,
                location_id: line.location_id,
                notes: `Inventura #${session.id} - nález`,
              },
            },
          },
        });

        // Create batch for the corrective receipt line
        const correctiveLine = await tx.sklad_receipt_lines.findFirst({
          where: { receipt_id: correctiveReceipt.id },
        });
        if (correctiveLine) {
          await tx.sklad_batches.create({
            data: {
              item_id: line.item_id,
              receipt_line_id: correctiveLine.id,
              quantity: diff,
              original_qty: diff,
              unit_price: 0,
              received_at: new Date(),
              is_consumed: false,
            },
          });
        }

        // Update item location
        if (line.location_id) {
          const existing = await tx.sklad_item_locations.findUnique({
            where: {
              item_id_location_id: {
                item_id: line.item_id,
                location_id: line.location_id,
              },
            },
          });
          if (existing) {
            await tx.sklad_item_locations.update({
              where: { id: existing.id },
              data: { quantity: { increment: diff } },
            });
          } else {
            await tx.sklad_item_locations.create({
              data: {
                item_id: line.item_id,
                location_id: line.location_id,
                quantity: diff,
              },
            });
          }
        }
      } else if (diff < 0) {
        // Less stock than system: create corrective issue
        const absDiff = Math.abs(diff);

        // Find oldest batch to consume
        const batches = await tx.sklad_batches.findMany({
          where: {
            item_id: line.item_id,
            is_consumed: false,
            quantity: { gt: 0 },
          },
          orderBy: { received_at: "asc" },
        });

        let remaining = absDiff;
        for (const batch of batches) {
          if (remaining <= 0) break;
          const take = Math.min(Number(batch.quantity), remaining);

          await tx.sklad_batches.update({
            where: { id: batch.id },
            data: {
              quantity: { decrement: take },
              is_consumed: Number(batch.quantity) - take === 0,
            },
          });

          remaining -= take;
        }

        // Update item location
        if (line.location_id) {
          await tx.sklad_item_locations.updateMany({
            where: { item_id: line.item_id, location_id: line.location_id },
            data: { quantity: { decrement: absDiff } },
          });
        }
      }
    }

    // Generate session number
    const seq = await tx.number_sequences.findFirst({
      where: { type: "warehouse_inventory", year: new Date().getFullYear() },
    });
    const nextNum = (seq?.last_number ?? 0) + 1;
    if (seq) {
      await tx.number_sequences.update({
        where: { id: seq.id },
        data: { last_number: nextNum },
      });
    }
    const year = new Date().getFullYear();
    const sessionNumber = `INV-${year}-${String(nextNum).padStart(3, "0")}`;

    await tx.sklad_inventory_sessions.update({
      where: { id: sessionId },
      data: {
        status: "CONFIRMED",
        session_number: sessionNumber,
        modified_at: new Date(),
      },
    });

    return { data: { id: sessionId, session_number: sessionNumber } };
  });
}
```

- [ ] **Step 2: Verify service compiles**

```bash
npx tsc --noEmit src/services/warehouse.service.ts
```

Expected: No type errors.

- [ ] **Step 3: Commit**

```bash
git add src/services/warehouse.service.ts
git commit -m "feat(warehouse): add warehouse service with FIFO and business logic"
```

---

## Phase 3: Backend - API Routes

### Task 5: Warehouse Routes - Master Data

**Files:**

- Create: `src/routes/admin/warehouse.ts` (partial - master data endpoints)

- [ ] **Step 1: Create warehouse routes file with categories, suppliers, locations, items endpoints**

This is a large file. Create `src/routes/admin/warehouse.ts` with the following structure. The file will be extended in subsequent tasks with receipts, issues, reservations, inventory, and reports.

```ts
import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requireAuth, requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common";
import {
  CreateCategorySchema, UpdateCategorySchema,
  CreateSupplierSchema, UpdateSupplierSchema,
  CreateLocationSchema, UpdateLocationSchema,
  CreateItemSchema, UpdateItemSchema,
} from "../../schemas/warehouse.schema";
import {
  getItemAvailableQty,
  getItemTotalStock,
  getItemStockValue,
  getBelowMinimumItems,
} from "../../services/warehouse.service";

export default async function warehouseRoutes(fastify: FastifyInstance): Promise<void> {

  // ==============================
  // CATEGORIES
  // ==============================

  fastify.get("/categories", { preHandler: requirePermission("warehouse.manage") }, async (_request, reply) => {
    const categories = await prisma.sklad_categories.findMany({ orderBy: { sort_order: "asc" } });
    return success(reply, categories);
  });

  fastify.post("/categories", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => {
    const parsed = parseBody(CreateCategorySchema, request.body);
    if ("error" in parsed) return error(reply, parsed.error, 400);
    const category = await prisma.sklad_categories.create({ data: parsed.data });
    await logAudit({ request, authData: request.authData, action: "create", entityType: "warehouse_category", entityId: category.id, description: `Vytvořena kategorie ${category.name}` });
    return success(reply, { id: category.id }, 201, "Kategorie byla vytvořena");
  });

  fastify.put<{ Params: { id: string } }>("/categories/:id", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => {
    const id = parseId(request.params.id, reply); if (id === null) return;
    const parsed = parseBody(UpdateCategorySchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400);
    const existing = await prisma.sklad_categories.findUnique({ where: { id } }); if (!existing) return error(reply, "Kategorie nenalezena", 404);
    const category = await prisma.sklad_categories.update({ where: { id }, data: parsed.data });
    await logAudit({ request, authData: request.authData, action: "update", entityType: "warehouse_category", entityId: id, description: `Upravena kategorie ${category.name}`, oldValues: existing, newValues: parsed.data });
    return success(reply, { id }, 200, "Kategorie byla uložena");
  });

  fastify.delete<{ Params: { id: string } }>("/categories/:id", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => {
    const id = parseId(request.params.id, reply); if (id === null) return;
    const existing = await prisma.sklad_categories.findUnique({ where: { id } }); if (!existing) return error(reply, "Kategorie nenalezena", 404);
    const itemCount = await prisma.sklad_items.count({ where: { category_id: id } });
    if (itemCount > 0) return error(reply, "Kategorii nelze smazat - obsahuje položky", 409);
    await prisma.sklad_categories.delete({ where: { id } });
    await logAudit({ request, authData: request.authData, action: "delete", entityType: "warehouse_category", entityId: id, description: `Smazána kategorie ${existing.name}` });
    return success(reply, null, 200, "Kategorie smazána");
  });

  // ==============================
  // SUPPLIERS
  // ==============================

  fastify.get("/suppliers", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => {
    const query = request.query as Record<string, unknown>;
    const { page, limit, skip, search } = parsePagination(query);
    const where: Record<string, unknown> = {};
    if (search) {
      where.OR = [
        { name: { contains: search } },
        { ico: { contains: search } },
        { contact_person: { contains: search } },
      ];
    }
    const [data, total] = await Promise.all([
      prisma.sklad_suppliers.findMany({ where, skip, take: limit, orderBy: { name: "asc" } }),
      prisma.sklad_suppliers.count({ where }),
    ]);
    return reply.send({ success: true, data, pagination: buildPaginationMeta(total, page, limit) });
  });

  fastify.get<{ Params: { id: string } }>("/suppliers/:id", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => {
    const id = parseId(request.params.id, reply); if (id === null) return;
    const supplier = await prisma.sklad_suppliers.findUnique({ where: { id } });
    if (!supplier) return error(reply, "Dodavatel nenalezen", 404);
    return success(reply, supplier);
  });

  fastify.post("/suppliers", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => {
    const parsed = parseBody(CreateSupplierSchema, request.body);
    if ("error" in parsed) return error(reply, parsed.error, 400);
    const supplier = await prisma.sklad_suppliers.create({ data: parsed.data });
    await logAudit({ request, authData: request.authData, action: "create", entityType: "warehouse_supplier", entityId: supplier.id, description: `Vytvořen dodavatel ${supplier.name}` });
    return success(reply, { id: supplier.id }, 201, "Dodavatel byl vytvořen");
  });

  fastify.put<{ Params: { id: string } }>("/suppliers/:id", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => {
    const id = parseId(request.params.id, reply); if (id === null) return;
    const parsed = parseBody(UpdateSupplierSchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400);
    const existing = await prisma.sklad_suppliers.findUnique({ where: { id } }); if (!existing) return error(reply, "Dodavatel nenalezen", 404);
    await prisma.sklad_suppliers.update({ where: { id }, data: parsed.data });
    await logAudit({ request, authData: request.authData, action: "update", entityType: "warehouse_supplier", entityId: id, description: `Upraven dodavatel ${existing.name}`, oldValues: existing, newValues: parsed.data });
    return success(reply, { id }, 200, "Dodavatel byl uložen");
  });

  fastify.delete<{ Params: { id: string } }>("/suppliers/:id", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => {
    const id = parseId(request.params.id, reply); if (id === null) return;
    const existing = await prisma.sklad_suppliers.findUnique({ where: { id } }); if (!existing) return error(reply, "Dodavatel nenalezen", 404);
    await prisma.sklad_suppliers.update({ where: { id }, data: { is_active: false } });
    await logAudit({ request, authData: request.authData, action: "update", entityType: "warehouse_supplier", entityId: id, description: `Deaktivován dodavatel ${existing.name}` });
    return success(reply, null, 200, "Dodavatel deaktivován");
  });

  // ==============================
  // LOCATIONS
  // ==============================

  fastify.get("/locations", { preHandler: requirePermission("warehouse.manage") }, async (_request, reply) => {
    const locations = await prisma.sklad_locations.findMany({ where: { is_active: true }, orderBy: { code: "asc" } });
    return success(reply, locations);
  });

  fastify.post("/locations", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => {
    const parsed = parseBody(CreateLocationSchema, request.body);
    if ("error" in parsed) return error(reply, parsed.error, 400);
    const location = await prisma.sklad_locations.create({ data: parsed.data });
    await logAudit({ request, authData: request.authData, action: "create", entityType: "warehouse_location", entityId: location.id, description: `Vytvořena lokace ${location.code}` });
    return success(reply, { id: location.id }, 201, "Lokace byla vytvořena");
  });

  fastify.put<{ Params: { id: string } }>("/locations/:id", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => {
    const id = parseId(request.params.id, reply); if (id === null) return;
    const parsed = parseBody(UpdateLocationSchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400);
    const existing = await prisma.sklad_locations.findUnique({ where: { id } }); if (!existing) return error(reply, "Lokace nenalezena", 404);
    await prisma.sklad_locations.update({ where: { id }, data: parsed.data });
    await logAudit({ request, authData: request.authData, action: "update", entityType: "warehouse_location", entityId: id, description: `Upravena lokace ${existing.code}`, oldValues: existing, newValues: parsed.data });
    return success(reply, { id }, 200, "Lokace byla uložena");
  });

  fastify.delete<{ Params: { id: string } }>("/locations/:id", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => {
    const id = parseId(request.params.id, reply); if (id === null) return;
    const existing = await prisma.sklad_locations.findUnique({ where: { id } }); if (!existing) return error(reply, "Lokace nenalezena", 404);
    const itemCount = await prisma.sklad_item_locations.count({ where: { location_id: id, quantity: { not: 0 } } });
    if (itemCount > 0) return error(reply, "Lokaci nelze smazat - obsahuje materiál", 409);
    await prisma.sklad_locations.delete({ where: { id } });
    await logAudit({ request, authData: request.authData, action: "delete", entityType: "warehouse_location", entityId: id, description: `Smazána lokace ${existing.code}` });
    return success(reply, null, 200, "Lokace smazána");
  });

  // ==============================
  // ITEMS
  // ==============================

  fastify.get("/items", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => {
    const query = request.query as Record<string, unknown>;
    const { page, limit, skip, sort, order, search } = parsePagination(query);
    const categoryFilter = query.category_id ? Number(query.category_id) : undefined;
    const belowMin = query.below_min === "true" || query.below_min === "1";

    const where: Record<string, unknown> = { is_active: true };
    if (search) {
      where.OR = [
        { name: { contains: search } },
        { item_number: { contains: search } },
      ];
    }
    if (categoryFilter) where.category_id = categoryFilter;

    const sortField = sort && ["name", "item_number", "created_at"].includes(sort) ? sort : "name";
    const sortOrder = order === "desc" ? "desc" : "asc";

    const [data, total] = await Promise.all([
      prisma.sklad_items.findMany({
        where,
        skip,
        take: limit,
        orderBy: { [sortField]: sortOrder },
        include: { category: true, item_locations: { include: { location: true } } },
      }),
      prisma.sklad_items.count({ where }),
    ]);

    // Add computed stock data
    const enriched = await Promise.all(data.map(async (item) => {
      const totalStock = await getItemTotalStock(item.id);
      const available = await getItemAvailableQty(item.id);
      const value = await getItemStockValue(item.id);
      const belowMinimum = item.min_quantity ? totalStock < Number(item.min_quantity) : false;
      return { ...item, total_quantity: totalStock, available_quantity: available, stock_value: value, below_minimum: belowMinimum };
    }));

    return reply.send({ success: true, data: enriched, pagination: buildPaginationMeta(total, page, limit) });
  });

  fastify.get<{ Params: { id: string } }>("/items/:id", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => {
    const id = parseId(request.params.id, reply); if (id === null) return;
    const item = await prisma.sklad_items.findUnique({
      where: { id },
      include: {
        category: true,
        batches: { where: { is_consumed: false }, orderBy: { received_at: "asc" } },
        item_locations: { include: { location: true } },
      },
    });
    if (!item) return error(reply, "Položka nenalezena", 404);
    const totalStock = await getItemTotalStock(id);
    const available = await getItemAvailableQty(id);
    const value = await getItemStockValue(id);
    return success(reply, { ...item, total_quantity: totalStock, available_quantity: available, stock_value: value });
  });

  fastify.post("/items", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => {
    const parsed = parseBody(CreateItemSchema, request.body);
    if ("error" in parsed) return error(reply, parsed.error, 400);
    const item = await prisma.sklad_items.create({ data: parsed.data });
    await logAudit({ request, authData: request.authData, action: "create", entityType: "warehouse_item", entityId: item.id, description: `Vytvořena položka ${item.name}` });
    return success(reply, { id: item.id }, 201, "Položka byla vytvořena");
  });

  fastify.put<{ Params: { id: string } }>("/items/:id", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => {
    const id = parseId(request.params.id, reply); if (id === null) return;
    const parsed = parseBody(UpdateItemSchema, request.body); if ("error" in parsed) return error(reply, parsed.error, 400);
    const existing = await prisma.sklad_items.findUnique({ where: { id } }); if (!existing) return error(reply, "Položka nenalezena", 404);

    // Prevent unit change if movements exist
    if (parsed.data.unit && parsed.data.unit !== existing.unit) {
      const batchCount = await prisma.sklad_batches.count({ where: { item_id: id } });
      if (batchCount > 0) return error(reply, "Jednotku nelze změnit - položka má pohyby", 409);
    }

    await prisma.sklad_items.update({ where: { id }, data: parsed.data });
    await logAudit({ request, authData: request.authData, action: "update", entityType: "warehouse_item", entityId: id, description: `Upravena položka ${existing.name}`, oldValues: existing, newValues: parsed.data });
    return success(reply, { id }, 200, "Položka byla uložena");
  });

  fastify.delete<{ Params: { id: string } }>("/items/:id", { preHandler: requirePermission("warehouse.manage") }, async (request, reply) => {
    const id = parseId(request.params.id, reply); if (id === null) return;
    const existing = await prisma.sklad_items.findUnique({ where: { id } }); if (!existing) return error(reply, "Položka nenalezena", 404);
    await prisma.sklad_items.update({ where: { id }, data: { is_active: false } });
    await logAudit({ request, authData: request.authData, action: "deactivate", entityType: "warehouse_item", entityId: id, description: `Deaktivována položka ${existing.name}` });
    return success(reply, null, 200, "Položka deaktivována");
  });

  fastify.get<{ Params: { id: string } }>("/items/:id/available-qty", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => {
    const id = parseId(request.params.id, reply); if (id === null) return;
    const available = await getItemAvailableQty(id);
    return success(reply, { item_id: id, available_quantity: available });
  });

  fastify.get<{ Params: { id: string } }>("/items/:id/batches", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => {
    const id = parseId(request.params.id, reply); if (id === null) return;
    const batches = await prisma.sklad_batches.findMany({
      where: { item_id: id, is_consumed: false, quantity: { gt: 0 } },
      orderBy: { received_at: "asc" },
    });
    return success(reply, batches);
  });
```

- [ ] **Step 2: Commit**

```bash
git add src/routes/admin/warehouse.ts
git commit -m "feat(warehouse): add master data API routes"
```

---

### Task 6: Warehouse Routes - Receipts, Issues, Reservations, Inventory, Reports

**Files:**

- Modify: `src/routes/admin/warehouse.ts` (append remaining endpoints)

- [ ] **Step 1: Add receipt endpoints**

Append to `warehouseRoutes` in `src/routes/admin/warehouse.ts`:

```ts
// ==============================
// RECEIPTS
// ==============================

fastify.get(
  "/receipts",
  { preHandler: requirePermission("warehouse.view") },
  async (request, reply) => {
    const query = request.query as Record<string, unknown>;
    const { page, limit, skip, sort, order, search } = parsePagination(query);
    const statusFilter = query.status as string | undefined;
    const supplierFilter = query.supplier_id
      ? Number(query.supplier_id)
      : undefined;
    const dateFrom = query.date_from as string | undefined;
    const dateTo = query.date_to as string | undefined;

    const where: Record<string, unknown> = {};
    if (statusFilter) where.status = statusFilter;
    if (supplierFilter) where.supplier_id = supplierFilter;
    if (dateFrom || dateTo) {
      const createdAt: Record<string, unknown> = {};
      if (dateFrom) createdAt.gte = new Date(dateFrom);
      if (dateTo) createdAt.lte = new Date(dateTo);
      where.created_at = createdAt;
    }
    if (search) {
      where.OR = [
        { receipt_number: { contains: search } },
        { delivery_note_number: { contains: search } },
        { notes: { contains: search } },
      ];
    }

    const sortField =
      sort && ["receipt_number", "created_at", "status"].includes(sort)
        ? sort
        : "created_at";
    const sortOrder = order === "asc" ? "asc" : "desc";

    const [data, total] = await Promise.all([
      prisma.sklad_receipts.findMany({
        where,
        skip,
        take: limit,
        orderBy: { [sortField]: sortOrder },
        include: {
          supplier: true,
          received_by_user: true,
          _count: { select: { lines: true } },
        },
      }),
      prisma.sklad_receipts.count({ where }),
    ]);

    return reply.send({
      success: true,
      data,
      pagination: buildPaginationMeta(total, page, limit),
    });
  },
);

fastify.get<{ Params: { id: string } }>(
  "/receipts/:id",
  { preHandler: requirePermission("warehouse.view") },
  async (request, reply) => {
    const id = parseId(request.params.id, reply);
    if (id === null) return;
    const receipt = await prisma.sklad_receipts.findUnique({
      where: { id },
      include: {
        supplier: true,
        received_by_user: true,
        lines: { include: { item: true, location: true } },
        attachments: true,
      },
    });
    if (!receipt) return error(reply, "Příjem nenalezen", 404);
    return success(reply, receipt);
  },
);

fastify.post(
  "/receipts",
  { preHandler: requirePermission("warehouse.operate") },
  async (request, reply) => {
    const parsed = parseBody(CreateReceiptSchema, request.body);
    if ("error" in parsed) return error(reply, parsed.error, 400);
    const receipt = await prisma.sklad_receipts.create({
      data: {
        supplier_id: parsed.data.supplier_id,
        delivery_note_number: parsed.data.delivery_note_number,
        delivery_note_date: parsed.data.delivery_note_date
          ? new Date(parsed.data.delivery_note_date)
          : null,
        received_by: request.authData.userId,
        notes: parsed.data.notes,
        lines: { create: parsed.data.lines },
      },
      include: { lines: true },
    });
    await logAudit({
      request,
      authData: request.authData,
      action: "create",
      entityType: "warehouse_receipt",
      entityId: receipt.id,
      description: `Vytvořen příjem s ${receipt.lines.length} položkami`,
    });
    return success(reply, { id: receipt.id }, 201, "Příjem byl vytvořen");
  },
);

fastify.put<{ Params: { id: string } }>(
  "/receipts/:id",
  { preHandler: requirePermission("warehouse.operate") },
  async (request, reply) => {
    const id = parseId(request.params.id, reply);
    if (id === null) return;
    const parsed = parseBody(UpdateReceiptSchema, request.body);
    if ("error" in parsed) return error(reply, parsed.error, 400);
    const existing = await prisma.sklad_receipts.findUnique({ where: { id } });
    if (!existing) return error(reply, "Příjem nenalezen", 404);
    if (existing.status !== "DRAFT")
      return error(reply, "Lze upravit pouze návrh", 400);

    // Update header
    await prisma.sklad_receipts.update({
      where: { id },
      data: {
        supplier_id: parsed.data.supplier_id,
        delivery_note_number: parsed.data.delivery_note_number,
        delivery_note_date: parsed.data.delivery_note_date
          ? new Date(parsed.data.delivery_note_date)
          : null,
        notes: parsed.data.notes,
        modified_at: new Date(),
      },
    });

    // Replace lines if provided
    if (parsed.data.lines) {
      await prisma.sklad_receipt_lines.deleteMany({
        where: { receipt_id: id },
      });
      await prisma.sklad_receipt_lines.createMany({
        data: parsed.data.lines.map((line) => ({ receipt_id: id, ...line })),
      });
    }

    await logAudit({
      request,
      authData: request.authData,
      action: "update",
      entityType: "warehouse_receipt",
      entityId: id,
      description: `Upraven příjem`,
    });
    return success(reply, { id }, 200, "Příjem byl uložen");
  },
);

fastify.post<{ Params: { id: string } }>(
  "/receipts/:id/confirm",
  { preHandler: requirePermission("warehouse.operate") },
  async (request, reply) => {
    const id = parseId(request.params.id, reply);
    if (id === null) return;
    const result = await confirmReceipt(id, request.authData.userId);
    if ("error" in result)
      return error(reply, result.error, result.status ?? 400);
    await logAudit({
      request,
      authData: request.authData,
      action: "update",
      entityType: "warehouse_receipt",
      entityId: id,
      description: `Potvrzen příjem ${result.data.receipt_number}`,
    });
    return success(reply, result.data, 200, "Příjem byl potvrzen");
  },
);

fastify.post<{ Params: { id: string } }>(
  "/receipts/:id/cancel",
  { preHandler: requirePermission("warehouse.operate") },
  async (request, reply) => {
    const id = parseId(request.params.id, reply);
    if (id === null) return;
    const result = await cancelReceipt(id);
    if ("error" in result)
      return error(reply, result.error, result.status ?? 400);
    await logAudit({
      request,
      authData: request.authData,
      action: "update",
      entityType: "warehouse_receipt",
      entityId: id,
      description: `Zrušen příjem`,
    });
    return success(reply, result.data, 200, "Příjem byl zrušen");
  },
);

// Receipt attachments (multipart)
await fastify.register(multipart, {
  limits: { fileSize: config.nas.maxUploadSize },
});

fastify.post<{ Params: { id: string } }>(
  "/receipts/:id/attachments",
  { preHandler: requirePermission("warehouse.operate") },
  async (request, reply) => {
    const id = parseId(request.params.id, reply);
    if (id === null) return;
    const receipt = await prisma.sklad_receipts.findUnique({ where: { id } });
    if (!receipt) return error(reply, "Příjem nenalezen", 404);

    const part = await request.file();
    if (!part) return error(reply, "Soubor nebyl nahrán", 400);

    const buffer = await part.toBuffer();
    const now = new Date();
    const year = now.getFullYear();
    const month = now.getMonth() + 1;

    if (!nasFinancialsManager.isConfigured())
      return error(reply, "Úložiště souborů není dostupné", 503);

    const result = nasFinancialsManager.saveReceivedInvoice(
      part.filename,
      year,
      month,
      buffer,
    );
    if ("error" in result) return error(reply, result.error, 500);

    const attachment = await prisma.sklad_receipt_attachments.create({
      data: {
        receipt_id: id,
        file_name: part.filename,
        file_mime: part.mimetype,
        file_size: buffer.length,
        file_path: result.filePath,
      },
    });

    await logAudit({
      request,
      authData: request.authData,
      action: "create",
      entityType: "warehouse_receipt",
      entityId: id,
      description: `Nahrána příloha ${part.filename}`,
    });
    return success(reply, { id: attachment.id }, 201, "Příloha byla nahrána");
  },
);

fastify.delete<{ Params: { id: string; attachmentId: string } }>(
  "/receipts/:id/attachments/:attachmentId",
  { preHandler: requirePermission("warehouse.operate") },
  async (request, reply) => {
    const receiptId = parseId(request.params.id, reply);
    if (receiptId === null) return;
    const attachmentId = parseId(request.params.attachmentId, reply);
    if (attachmentId === null) return;
    const attachment = await prisma.sklad_receipt_attachments.findUnique({
      where: { id: attachmentId },
    });
    if (!attachment || attachment.receipt_id !== receiptId)
      return error(reply, "Příloha nenalezena", 404);

    await prisma.sklad_receipt_attachments.delete({
      where: { id: attachmentId },
    });
    if (attachment.file_path) {
      nasFinancialsManager.deleteReceivedInvoice(attachment.file_path);
    }
    await logAudit({
      request,
      authData: request.authData,
      action: "delete",
      entityType: "warehouse_receipt",
      entityId: receiptId,
      description: `Smazána příloha ${attachment.file_name}`,
    });
    return success(reply, null, 200, "Příloha smazána");
  },
);
```

Add imports at top of file:

```ts
import multipart from "@fastify/multipart";
import config from "../../config/env";
import { nasFinancialsManager } from "../../services/nas-financials-manager";
import {
  confirmReceipt,
  cancelReceipt,
  confirmIssue,
  cancelIssue,
  createReservation,
  cancelReservation,
  confirmInventorySession,
} from "../../services/warehouse.service";
```

- [ ] **Step 2: Add issue endpoints**

```ts
// ==============================
// ISSUES
// ==============================

fastify.get(
  "/issues",
  { preHandler: requirePermission("warehouse.view") },
  async (request, reply) => {
    const query = request.query as Record<string, unknown>;
    const { page, limit, skip, sort, order, search } = parsePagination(query);
    const statusFilter = query.status as string | undefined;
    const projectFilter = query.project_id
      ? Number(query.project_id)
      : undefined;
    const dateFrom = query.date_from as string | undefined;
    const dateTo = query.date_to as string | undefined;

    const where: Record<string, unknown> = {};
    if (statusFilter) where.status = statusFilter;
    if (projectFilter) where.project_id = projectFilter;
    if (dateFrom || dateTo) {
      const createdAt: Record<string, unknown> = {};
      if (dateFrom) createdAt.gte = new Date(dateFrom);
      if (dateTo) createdAt.lte = new Date(dateTo);
      where.created_at = createdAt;
    }
    if (search) {
      where.OR = [
        { issue_number: { contains: search } },
        { notes: { contains: search } },
      ];
    }

    const sortField =
      sort && ["issue_number", "created_at", "status"].includes(sort)
        ? sort
        : "created_at";
    const sortOrder = order === "asc" ? "asc" : "desc";

    const [data, total] = await Promise.all([
      prisma.sklad_issues.findMany({
        where,
        skip,
        take: limit,
        orderBy: { [sortField]: sortOrder },
        include: {
          project: true,
          issued_by_user: true,
          _count: { select: { lines: true } },
        },
      }),
      prisma.sklad_issues.count({ where }),
    ]);

    return reply.send({
      success: true,
      data,
      pagination: buildPaginationMeta(total, page, limit),
    });
  },
);

fastify.get<{ Params: { id: string } }>(
  "/issues/:id",
  { preHandler: requirePermission("warehouse.view") },
  async (request, reply) => {
    const id = parseId(request.params.id, reply);
    if (id === null) return;
    const issue = await prisma.sklad_issues.findUnique({
      where: { id },
      include: {
        project: true,
        issued_by_user: true,
        lines: {
          include: {
            item: true,
            batch: true,
            location: true,
            reservation: true,
          },
        },
      },
    });
    if (!issue) return error(reply, "Výdej nenalezen", 404);
    return success(reply, issue);
  },
);

fastify.post(
  "/issues",
  { preHandler: requirePermission("warehouse.operate") },
  async (request, reply) => {
    const parsed = parseBody(CreateIssueSchema, request.body);
    if ("error" in parsed) return error(reply, parsed.error, 400);

    // For lines without batch_id, auto-select FIFO batches
    const enrichedLines = [];
    for (const line of parsed.data.lines) {
      if (line.batch_id) {
        enrichedLines.push(line);
      } else {
        // Auto-FIFO: may produce multiple lines if spanning batches
        const fifoBatches = await selectFifoBatches(
          line.item_id,
          line.quantity,
        );
        for (const fifo of fifoBatches) {
          enrichedLines.push({
            item_id: line.item_id,
            batch_id: fifo.batchId,
            quantity: fifo.qty,
            location_id: line.location_id,
            reservation_id: line.reservation_id,
            notes: line.notes,
          });
        }
      }
    }

    const issue = await prisma.sklad_issues.create({
      data: {
        project_id: parsed.data.project_id,
        issued_by: request.authData.userId,
        notes: parsed.data.notes,
        lines: { create: enrichedLines },
      },
      include: { lines: true },
    });
    await logAudit({
      request,
      authData: request.authData,
      action: "create",
      entityType: "warehouse_issue",
      entityId: issue.id,
      description: `Vytvořen výdej s ${issue.lines.length} položkami`,
    });
    return success(reply, { id: issue.id }, 201, "Výdej byl vytvořen");
  },
);

fastify.put<{ Params: { id: string } }>(
  "/issues/:id",
  { preHandler: requirePermission("warehouse.operate") },
  async (request, reply) => {
    const id = parseId(request.params.id, reply);
    if (id === null) return;
    const parsed = parseBody(UpdateIssueSchema, request.body);
    if ("error" in parsed) return error(reply, parsed.error, 400);
    const existing = await prisma.sklad_issues.findUnique({ where: { id } });
    if (!existing) return error(reply, "Výdej nenalezen", 404);
    if (existing.status !== "DRAFT")
      return error(reply, "Lze upravit pouze návrh", 400);

    await prisma.sklad_issues.update({
      where: { id },
      data: {
        project_id: parsed.data.project_id,
        notes: parsed.data.notes,
        modified_at: new Date(),
      },
    });

    if (parsed.data.lines) {
      // Auto-FIFO for lines without batch_id
      const enrichedLines = [];
      for (const line of parsed.data.lines) {
        if (line.batch_id) {
          enrichedLines.push(line);
        } else {
          const fifoBatches = await selectFifoBatches(
            line.item_id,
            line.quantity,
          );
          for (const fifo of fifoBatches) {
            enrichedLines.push({
              issue_id: id,
              item_id: line.item_id,
              batch_id: fifo.batchId,
              quantity: fifo.qty,
              location_id: line.location_id,
              reservation_id: line.reservation_id,
              notes: line.notes,
            });
          }
        }
      }

      await prisma.sklad_issue_lines.deleteMany({ where: { issue_id: id } });
      await prisma.sklad_issue_lines.createMany({ data: enrichedLines });
    }

    await logAudit({
      request,
      authData: request.authData,
      action: "update",
      entityType: "warehouse_issue",
      entityId: id,
      description: `Upraven výdej`,
    });
    return success(reply, { id }, 200, "Výdej byl uložen");
  },
);

fastify.post<{ Params: { id: string } }>(
  "/issues/:id/confirm",
  { preHandler: requirePermission("warehouse.operate") },
  async (request, reply) => {
    const id = parseId(request.params.id, reply);
    if (id === null) return;
    const result = await confirmIssue(id, request.authData.userId);
    if ("error" in result)
      return error(reply, result.error, result.status ?? 400);
    await logAudit({
      request,
      authData: request.authData,
      action: "update",
      entityType: "warehouse_issue",
      entityId: id,
      description: `Potvrzen výdej ${result.data.issue_number}`,
    });
    return success(reply, result.data, 200, "Výdej byl potvrzen");
  },
);

fastify.post<{ Params: { id: string } }>(
  "/issues/:id/cancel",
  { preHandler: requirePermission("warehouse.operate") },
  async (request, reply) => {
    const id = parseId(request.params.id, reply);
    if (id === null) return;
    const result = await cancelIssue(id);
    if ("error" in result)
      return error(reply, result.error, result.status ?? 400);
    await logAudit({
      request,
      authData: request.authData,
      action: "update",
      entityType: "warehouse_issue",
      entityId: id,
      description: `Zrušen výdej`,
    });
    return success(reply, result.data, 200, "Výdej byl zrušen");
  },
);
```

Add `selectFifoBatches` to the import from `warehouse.service`.

- [ ] **Step 3: Add reservation endpoints**

```ts
// ==============================
// RESERVATIONS
// ==============================

fastify.get(
  "/reservations",
  { preHandler: requirePermission("warehouse.view") },
  async (request, reply) => {
    const query = request.query as Record<string, unknown>;
    const { page, limit, skip } = parsePagination(query);
    const itemId = query.item_id ? Number(query.item_id) : undefined;
    const projectId = query.project_id ? Number(query.project_id) : undefined;
    const statusFilter = query.status as string | undefined;

    const where: Record<string, unknown> = {};
    if (itemId) where.item_id = itemId;
    if (projectId) where.project_id = projectId;
    if (statusFilter) where.status = statusFilter;

    const [data, total] = await Promise.all([
      prisma.sklad_reservations.findMany({
        where,
        skip,
        take: limit,
        orderBy: { created_at: "desc" },
        include: { item: true, project: true, reserved_by_user: true },
      }),
      prisma.sklad_reservations.count({ where }),
    ]);

    return reply.send({
      success: true,
      data,
      pagination: buildPaginationMeta(total, page, limit),
    });
  },
);

fastify.post(
  "/reservations",
  { preHandler: requirePermission("warehouse.operate") },
  async (request, reply) => {
    const parsed = parseBody(CreateReservationSchema, request.body);
    if ("error" in parsed) return error(reply, parsed.error, 400);
    const result = await createReservation({
      ...parsed.data,
      reserved_by: request.authData.userId,
    });
    if ("error" in result)
      return error(reply, result.error, result.status ?? 400);
    await logAudit({
      request,
      authData: request.authData,
      action: "create",
      entityType: "warehouse_reservation",
      entityId: result.data.id,
      description: `Vytvořena rezervace ${result.data.quantity} ks položky ID ${result.data.item_id}`,
    });
    return success(
      reply,
      { id: result.data.id },
      201,
      "Rezervace byla vytvořena",
    );
  },
);

fastify.post<{ Params: { id: string } }>(
  "/reservations/:id/cancel",
  { preHandler: requirePermission("warehouse.operate") },
  async (request, reply) => {
    const id = parseId(request.params.id, reply);
    if (id === null) return;
    const result = await cancelReservation(id);
    if ("error" in result)
      return error(reply, result.error, result.status ?? 400);
    await logAudit({
      request,
      authData: request.authData,
      action: "update",
      entityType: "warehouse_reservation",
      entityId: id,
      description: `Zrušena rezervace`,
    });
    return success(reply, result.data, 200, "Rezervace byla zrušena");
  },
);
```

- [ ] **Step 4: Add inventory endpoints**

```ts
// ==============================
// INVENTORY
// ==============================

fastify.get(
  "/inventory-sessions",
  { preHandler: requirePermission("warehouse.inventory") },
  async (request, reply) => {
    const query = request.query as Record<string, unknown>;
    const { page, limit, skip } = parsePagination(query);
    const statusFilter = query.status as string | undefined;

    const where: Record<string, unknown> = {};
    if (statusFilter) where.status = statusFilter;

    const [data, total] = await Promise.all([
      prisma.sklad_inventory_sessions.findMany({
        where,
        skip,
        take: limit,
        orderBy: { created_at: "desc" },
        include: { _count: { select: { lines: true } } },
      }),
      prisma.sklad_inventory_sessions.count({ where }),
    ]);

    return reply.send({
      success: true,
      data,
      pagination: buildPaginationMeta(total, page, limit),
    });
  },
);

fastify.get<{ Params: { id: string } }>(
  "/inventory-sessions/:id",
  { preHandler: requirePermission("warehouse.inventory") },
  async (request, reply) => {
    const id = parseId(request.params.id, reply);
    if (id === null) return;
    const session = await prisma.sklad_inventory_sessions.findUnique({
      where: { id },
      include: { lines: { include: { item: true, location: true } } },
    });
    if (!session) return error(reply, "Inventura nenalezena", 404);
    return success(reply, session);
  },
);

fastify.post(
  "/inventory-sessions",
  { preHandler: requirePermission("warehouse.inventory") },
  async (request, reply) => {
    const parsed = parseBody(CreateInventorySessionSchema, request.body);
    if ("error" in parsed) return error(reply, parsed.error, 400);

    // Auto-fill system_qty from current stock
    const lines = await Promise.all(
      parsed.data.lines.map(async (line) => {
        let systemQty = 0;
        if (line.location_id) {
          const loc = await prisma.sklad_item_locations.findUnique({
            where: {
              item_id_location_id: {
                item_id: line.item_id,
                location_id: line.location_id,
              },
            },
          });
          systemQty = Number(loc?.quantity ?? 0);
        } else {
          systemQty = await getItemTotalStock(line.item_id);
        }
        const diff = Number(line.actual_qty) - systemQty;
        return {
          item_id: line.item_id,
          location_id: line.location_id,
          system_qty: systemQty,
          actual_qty: Number(line.actual_qty),
          difference: diff,
          notes: line.notes,
        };
      }),
    );

    const session = await prisma.sklad_inventory_sessions.create({
      data: {
        notes: parsed.data.notes,
        lines: { create: lines },
      },
      include: { lines: true },
    });
    await logAudit({
      request,
      authData: request.authData,
      action: "create",
      entityType: "warehouse_inventory",
      entityId: session.id,
      description: `Vytvořena inventura s ${session.lines.length} položkami`,
    });
    return success(reply, { id: session.id }, 201, "Inventura byla vytvořena");
  },
);

fastify.put<{ Params: { id: string } }>(
  "/inventory-sessions/:id",
  { preHandler: requirePermission("warehouse.inventory") },
  async (request, reply) => {
    const id = parseId(request.params.id, reply);
    if (id === null) return;
    const parsed = parseBody(UpdateInventorySessionSchema, request.body);
    if ("error" in parsed) return error(reply, parsed.error, 400);
    const existing = await prisma.sklad_inventory_sessions.findUnique({
      where: { id },
    });
    if (!existing) return error(reply, "Inventura nenalezena", 404);
    if (existing.status !== "DRAFT")
      return error(reply, "Lze upravit pouze návrh", 400);

    await prisma.sklad_inventory_sessions.update({
      where: { id },
      data: { notes: parsed.data.notes, modified_at: new Date() },
    });

    if (parsed.data.lines) {
      const lines = parsed.data.lines.map((line) => {
        const systemQty = 0; // system_qty was set on creation, recalculate difference
        return {
          session_id: id,
          item_id: line.item_id,
          location_id: line.location_id,
          system_qty: 0,
          actual_qty: Number(line.actual_qty),
          difference: 0,
          notes: line.notes,
        };
      });
      // Re-fetch system quantities and recalculate differences
      const enrichedLines = await Promise.all(
        lines.map(async (line) => {
          let systemQty = 0;
          if (line.location_id) {
            const loc = await prisma.sklad_item_locations.findUnique({
              where: {
                item_id_location_id: {
                  item_id: line.item_id,
                  location_id: line.location_id!,
                },
              },
            });
            systemQty = Number(loc?.quantity ?? 0);
          } else {
            systemQty = await getItemTotalStock(line.item_id);
          }
          return {
            ...line,
            system_qty: systemQty,
            difference: Number(line.actual_qty) - systemQty,
          };
        }),
      );

      await prisma.sklad_inventory_lines.deleteMany({
        where: { session_id: id },
      });
      await prisma.sklad_inventory_lines.createMany({ data: enrichedLines });
    }

    await logAudit({
      request,
      authData: request.authData,
      action: "update",
      entityType: "warehouse_inventory",
      entityId: id,
      description: `Upravena inventura`,
    });
    return success(reply, { id }, 200, "Inventura byla uložena");
  },
);

fastify.post<{ Params: { id: string } }>(
  "/inventory-sessions/:id/confirm",
  { preHandler: requirePermission("warehouse.inventory") },
  async (request, reply) => {
    const id = parseId(request.params.id, reply);
    if (id === null) return;
    const result = await confirmInventorySession(id);
    if ("error" in result)
      return error(reply, result.error, result.status ?? 400);
    await logAudit({
      request,
      authData: request.authData,
      action: "update",
      entityType: "warehouse_inventory",
      entityId: id,
      description: `Potvrzena inventura ${result.data.session_number}`,
    });
    return success(reply, result.data, 200, "Inventura byla potvrzena");
  },
);
```

- [ ] **Step 5: Add report endpoints**

```ts
  // ==============================
  // REPORTS
  // ==============================

  fastify.get("/reports/stock-status", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => {
    const query = request.query as Record<string, unknown>;
    const categoryId = query.category_id ? Number(query.category_id) : undefined;

    const where: Record<string, unknown> = { is_active: true };
    if (categoryId) where.category_id = categoryId;

    const items = await prisma.sklad_items.findMany({
      where,
      include: { category: true },
      orderBy: { name: "asc" },
    });

    const data = await Promise.all(items.map(async (item) => {
      const totalStock = await getItemTotalStock(item.id);
      const available = await getItemAvailableQty(item.id);
      const value = await getItemStockValue(item.id);
      const belowMinimum = item.min_quantity ? totalStock < Number(item.min_quantity) : false;
      return { ...item, total_quantity: totalStock, available_quantity: available, stock_value: value, below_minimum: belowMinimum };
    }));

    return success(reply, data);
  });

  fastify.get("/reports/project-consumption", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => {
    const query = request.query as Record<string, unknown>;
    const projectId = query.project_id ? Number(query.project_id) : undefined;
    const dateFrom = query.date_from as string | undefined;
    const dateTo = query.date_to as string | undefined;

    const where: Record<string, unknown> = { status: "CONFIRMED" };
    if (projectId) where.project_id = projectId;
    if (dateFrom || dateTo) {
      const createdAt: Record<string, unknown> = {};
      if (dateFrom) createdAt.gte = new Date(dateFrom);
      if (dateTo) createdAt.lte = new Date(dateTo);
      where.created_at = createdAt;
    }

    const issues = await prisma.sklad_issues.findMany({
      where,
      include: { lines: { include: { item: true } }, project: true },
      orderBy: { created_at: "desc" },
    });

    // Aggregate by item + project
    const consumption: Record<string, { item_id: number; item_name: string; project_id: number; project_name: string; total_qty: number; total_value: number }> = {};
    for (const issue of issues) {
      for (const line of issue.lines) {
        const key = `${line.item_id}-${issue.project_id}`;
        if (!consumption[key]) {
          consumption[key] = { item_id: line.item_id, item_name: line.item.name, project_id: issue.project_id, project_name: issue.project.name ?? "", total_qty: 0, total_value: 0 };
        }
        consumption[key].total_qty += Number(line.quantity);
        consumption[key].total_value += Number(line.quantity) * Number(line.batch.unit_price);
      }
    }

    return success(reply, Object.values(consumption));
  });

  fastify.get("/reports/movement-log", { preHandler: requirePermission("warehouse.view") }, async (request, reply) => {
    const query = request.query as Record<string, unknown>;
    const { page, limit, skip } = parsePagination(query);
    const dateFrom = query.date_from as string | undefined;
    const dateTo = query.date_to as string | undefined;
    const typeFilter = query.type as string | undefined; // "receipt" or "issue"

    const movements: Array<Record<string, unknown>> = [];

    if (!typeFilter || typeFilter === "receipt") {
      const receiptWhere: Record<string, unknown> = { status: "CONFIRMED" };
      if (dateFrom || dateTo) {
        const createdAt: Record<string, unknown> = {};
        if (dateFrom) createdAt.gte = new Date(dateFrom);
        if (dateTo) createdAt.lte = new Date(dateTo);
        receiptWhere.created_at = createdAt;
      }
      const receipts = await prisma.sklad_receipts.findMany({
        where: receiptWhere,
        include: { supplier: true, lines: { include: { item: true } } },
        orderBy: { created_at: "desc" },
        skip, take: limit,
      });
      for (const r of receipts) {
        for (const line of r.lines) {
          movements.push({ type: "receipt", document_id: r.id, document_number: r.receipt_number, date: r.created_at, item_id: line.item_id, item_name: line.item.name, quantity: Number(line.quantity), unit_price: Number(line.unit_price), supplier: r.supplier?.name ?? null });
        }
      }
    }

    if (!typeFilter || typeFilter === "issue") {
      const issueWhere: Record<string, unknown> = { status: "CONFIRMED" };
      if (dateFrom || dateTo) {
        const createdAt: Record<string, unknown> = {};
        if (dateFrom) createdAt.gte = new Date(dateFrom);
        if (dateTo) createdAt.lte = new Date(dateTo);
        issueWhere.created_at = createdAt;
      }
      const issues = await prisma.sklad_issues.findMany({
        where: issueWhere,
        include: { project: true, lines: { include: { item: true, batch: true } } },
        orderBy: { created_at: "desc" },
        skip: typeFilter ? skip : 0,
        take: typeFilter ? limit : 100,
      });
      for (const i of issues) {
        for (const line of i.lines) {
          movements.push({ type: "issue", document_id: i.id, document_number: i.issue_number, date: i.created_at, item_id: line.item_id, item_name: line.item.name, quantity: Number(line.quantity), unit_price: Number(line.batch?.unit_price ?? 0), project: i.project.name ?? null });
        }
      }
    }

    movements.sort((a, b) => new Date(b.date as string).getTime() - new Date(a.date as string).getTime());
    const paged = movements.slice(skip, skip + limit);

    return reply.send({ success: true, data: paged, pagination: buildPaginationMeta(movements.length, page, limit) });
  });

  fastify.get("/reports/below-minimum", { preHandler: requirePermission("warehouse.view") }, async (_request, reply) => {
    const data = await getBelowMinimumItems();
    return success(reply, data);
  });

} // end of warehouseRoutes
```

- [ ] **Step 6: Close the function and verify compilation**

```bash
npx tsc --noEmit src/routes/admin/warehouse.ts
```

Expected: No type errors.

- [ ] **Step 7: Commit**

```bash
git add src/routes/admin/warehouse.ts
git commit -m "feat(warehouse): add full warehouse API routes"
```

---

### Task 7: Register Warehouse Routes in Server

**Files:**

- Modify: `src/server.ts`

- [ ] **Step 1: Add import and registration**

In `src/server.ts`, add the import alongside existing route imports:

```ts
import warehouseRoutes from "./routes/admin/warehouse";
```

Add the registration alongside existing route registrations:

```ts
await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
```

- [ ] **Step 2: Verify server compiles**

```bash
npx tsc --noEmit src/server.ts
```

Expected: No type errors.

- [ ] **Step 3: Commit**

```bash
git add src/server.ts
git commit -m "feat(warehouse): register warehouse routes in server"
```

---

## Phase 4: Frontend - Foundation

### Task 8: Query Hooks

**Files:**

- Create: `src/admin/lib/queries/warehouse.ts`

- [ ] **Step 1: Create warehouse query options**

```ts
import { queryOptions } from "@tanstack/react-query";
import { jsonQuery, paginatedJsonQuery } from "../apiAdapter";

// === Types ===

export interface WarehouseItem {
  id: number;
  item_number: string | null;
  name: string;
  description: string | null;
  category_id: number | null;
  unit: string;
  min_quantity: number | null;
  is_active: boolean;
  notes: string | null;
  category?: { id: number; name: string } | null;
  total_quantity?: number;
  available_quantity?: number;
  stock_value?: number;
  below_minimum?: boolean;
}

export interface WarehouseReceipt {
  id: number;
  receipt_number: string | null;
  supplier_id: number | null;
  delivery_note_number: string | null;
  delivery_note_date: string | null;
  received_by: number | null;
  notes: string | null;
  status: "DRAFT" | "CONFIRMED" | "CANCELLED";
  created_at: string;
  modified_at: string | null;
  supplier?: { id: number; name: string } | null;
  received_by_user?: {
    id: number;
    first_name: string;
    last_name: string;
  } | null;
  lines?: WarehouseReceiptLine[];
  attachments?: WarehouseReceiptAttachment[];
}

export interface WarehouseReceiptLine {
  id: number;
  receipt_id: number;
  item_id: number;
  quantity: number;
  unit_price: number;
  location_id: number | null;
  notes: string | null;
  item?: WarehouseItem;
  location?: { id: number; code: string; name: string } | null;
}

export interface WarehouseReceiptAttachment {
  id: number;
  receipt_id: number;
  file_name: string;
  file_mime: string;
  file_size: number;
  file_path: string;
  created_at: string;
}

export interface WarehouseIssue {
  id: number;
  issue_number: string | null;
  project_id: number;
  issued_by: number | null;
  notes: string | null;
  status: "DRAFT" | "CONFIRMED" | "CANCELLED";
  created_at: string;
  modified_at: string | null;
  project?: { id: number; name: string; project_number: string } | null;
  issued_by_user?: { id: number; first_name: string; last_name: string } | null;
  lines?: WarehouseIssueLine[];
}

export interface WarehouseIssueLine {
  id: number;
  issue_id: number;
  item_id: number;
  batch_id: number;
  quantity: number;
  location_id: number | null;
  reservation_id: number | null;
  notes: string | null;
  item?: WarehouseItem;
  batch?: {
    id: number;
    quantity: number;
    unit_price: number;
    received_at: string;
  } | null;
  location?: { id: number; code: string; name: string } | null;
  reservation?: { id: number; quantity: number; remaining_qty: number } | null;
}

export interface WarehouseReservation {
  id: number;
  item_id: number;
  project_id: number;
  quantity: number;
  remaining_qty: number;
  reserved_by: number | null;
  notes: string | null;
  status: "ACTIVE" | "FULFILLED" | "CANCELLED";
  created_at: string;
  modified_at: string | null;
  item?: WarehouseItem;
  project?: { id: number; name: string } | null;
  reserved_by_user?: {
    id: number;
    first_name: string;
    last_name: string;
  } | null;
}

export interface WarehouseInventorySession {
  id: number;
  session_number: string | null;
  notes: string | null;
  status: "DRAFT" | "CONFIRMED";
  created_at: string;
  modified_at: string | null;
  lines?: WarehouseInventoryLine[];
}

export interface WarehouseInventoryLine {
  id: number;
  session_id: number;
  item_id: number;
  location_id: number | null;
  system_qty: number;
  actual_qty: number;
  difference: number;
  notes: string | null;
  item?: WarehouseItem;
  location?: { id: number; code: string; name: string } | null;
}

export interface WarehouseCategory {
  id: number;
  name: string;
  description: string | null;
  sort_order: number;
}

export interface WarehouseLocation {
  id: number;
  code: string;
  name: string;
  description: string | null;
  is_active: boolean;
}

export interface WarehouseSupplier {
  id: number;
  name: string;
  ico: string | null;
  dic: string | null;
  contact_person: string | null;
  email: string | null;
  phone: string | null;
  address: string | null;
  notes: string | null;
  is_active: boolean;
}

// === Query Options ===

export const warehouseItemListOptions = (filters: {
  search?: string;
  page?: number;
  perPage?: number;
  sort?: string;
  order?: string;
  category_id?: number;
}) => {
  const params = new URLSearchParams();
  if (filters.search) params.set("search", filters.search);
  if (filters.page) params.set("page", String(filters.page));
  if (filters.perPage) params.set("limit", String(filters.perPage));
  if (filters.sort) params.set("sort", filters.sort);
  if (filters.order) params.set("order", filters.order);
  if (filters.category_id)
    params.set("category_id", String(filters.category_id));
  const qs = params.toString();
  return queryOptions({
    queryKey: ["warehouse", "items", filters],
    queryFn: () =>
      paginatedJsonQuery<WarehouseItem>(
        `/api/admin/warehouse/items${qs ? `?${qs}` : ""}`,
      ),
  });
};

export const warehouseItemDetailOptions = (id: string | undefined) =>
  queryOptions({
    queryKey: ["warehouse", "items", id],
    queryFn: () => jsonQuery<WarehouseItem>(`/api/admin/warehouse/items/${id}`),
    enabled: !!id,
  });

export const warehouseCategoryListOptions = () =>
  queryOptions({
    queryKey: ["warehouse", "categories"],
    queryFn: () =>
      jsonQuery<WarehouseCategory[]>("/api/admin/warehouse/categories"),
  });

export const warehouseSupplierListOptions = (filters: {
  search?: string;
  page?: number;
  perPage?: number;
}) => {
  const params = new URLSearchParams();
  if (filters.search) params.set("search", filters.search);
  if (filters.page) params.set("page", String(filters.page));
  if (filters.perPage) params.set("limit", String(filters.perPage));
  const qs = params.toString();
  return queryOptions({
    queryKey: ["warehouse", "suppliers", filters],
    queryFn: () =>
      paginatedJsonQuery<WarehouseSupplier>(
        `/api/admin/warehouse/suppliers${qs ? `?${qs}` : ""}`,
      ),
  });
};

export const warehouseLocationListOptions = () =>
  queryOptions({
    queryKey: ["warehouse", "locations"],
    queryFn: () =>
      jsonQuery<WarehouseLocation[]>("/api/admin/warehouse/locations"),
  });

export const warehouseReceiptListOptions = (filters: {
  search?: string;
  page?: number;
  perPage?: number;
  status?: string;
  supplier_id?: number;
  date_from?: string;
  date_to?: string;
}) => {
  const params = new URLSearchParams();
  if (filters.search) params.set("search", filters.search);
  if (filters.page) params.set("page", String(filters.page));
  if (filters.perPage) params.set("limit", String(filters.perPage));
  if (filters.status) params.set("status", filters.status);
  if (filters.supplier_id)
    params.set("supplier_id", String(filters.supplier_id));
  if (filters.date_from) params.set("date_from", filters.date_from);
  if (filters.date_to) params.set("date_to", filters.date_to);
  const qs = params.toString();
  return queryOptions({
    queryKey: ["warehouse", "receipts", filters],
    queryFn: () =>
      paginatedJsonQuery<WarehouseReceipt>(
        `/api/admin/warehouse/receipts${qs ? `?${qs}` : ""}`,
      ),
  });
};

export const warehouseReceiptDetailOptions = (id: string | undefined) =>
  queryOptions({
    queryKey: ["warehouse", "receipts", id],
    queryFn: () =>
      jsonQuery<WarehouseReceipt>(`/api/admin/warehouse/receipts/${id}`),
    enabled: !!id,
  });

export const warehouseIssueListOptions = (filters: {
  search?: string;
  page?: number;
  perPage?: number;
  status?: string;
  project_id?: number;
  date_from?: string;
  date_to?: string;
}) => {
  const params = new URLSearchParams();
  if (filters.search) params.set("search", filters.search);
  if (filters.page) params.set("page", String(filters.page));
  if (filters.perPage) params.set("limit", String(filters.perPage));
  if (filters.status) params.set("status", filters.status);
  if (filters.project_id) params.set("project_id", String(filters.project_id));
  if (filters.date_from) params.set("date_from", filters.date_from);
  if (filters.date_to) params.set("date_to", filters.date_to);
  const qs = params.toString();
  return queryOptions({
    queryKey: ["warehouse", "issues", filters],
    queryFn: () =>
      paginatedJsonQuery<WarehouseIssue>(
        `/api/admin/warehouse/issues${qs ? `?${qs}` : ""}`,
      ),
  });
};

export const warehouseIssueDetailOptions = (id: string | undefined) =>
  queryOptions({
    queryKey: ["warehouse", "issues", id],
    queryFn: () =>
      jsonQuery<WarehouseIssue>(`/api/admin/warehouse/issues/${id}`),
    enabled: !!id,
  });

export const warehouseReservationListOptions = (filters: {
  page?: number;
  perPage?: number;
  item_id?: number;
  project_id?: number;
  status?: string;
}) => {
  const params = new URLSearchParams();
  if (filters.page) params.set("page", String(filters.page));
  if (filters.perPage) params.set("limit", String(filters.perPage));
  if (filters.item_id) params.set("item_id", String(filters.item_id));
  if (filters.project_id) params.set("project_id", String(filters.project_id));
  if (filters.status) params.set("status", filters.status);
  const qs = params.toString();
  return queryOptions({
    queryKey: ["warehouse", "reservations", filters],
    queryFn: () =>
      paginatedJsonQuery<WarehouseReservation>(
        `/api/admin/warehouse/reservations${qs ? `?${qs}` : ""}`,
      ),
  });
};

export const warehouseInventoryListOptions = (filters: {
  page?: number;
  perPage?: number;
  status?: string;
}) => {
  const params = new URLSearchParams();
  if (filters.page) params.set("page", String(filters.page));
  if (filters.perPage) params.set("limit", String(filters.perPage));
  if (filters.status) params.set("status", filters.status);
  const qs = params.toString();
  return queryOptions({
    queryKey: ["warehouse", "inventory", filters],
    queryFn: () =>
      paginatedJsonQuery<WarehouseInventorySession>(
        `/api/admin/warehouse/inventory-sessions${qs ? `?${qs}` : ""}`,
      ),
  });
};

export const warehouseInventoryDetailOptions = (id: string | undefined) =>
  queryOptions({
    queryKey: ["warehouse", "inventory", id],
    queryFn: () =>
      jsonQuery<WarehouseInventorySession>(
        `/api/admin/warehouse/inventory-sessions/${id}`,
      ),
    enabled: !!id,
  });

export const warehouseStockStatusOptions = (filters?: {
  category_id?: number;
}) => {
  const params = new URLSearchParams();
  if (filters?.category_id)
    params.set("category_id", String(filters.category_id));
  const qs = params.toString();
  return queryOptions({
    queryKey: ["warehouse", "reports", "stock-status", filters],
    queryFn: () =>
      jsonQuery<WarehouseItem[]>(
        `/api/admin/warehouse/reports/stock-status${qs ? `?${qs}` : ""}`,
      ),
  });
};

export const warehouseBelowMinimumOptions = () =>
  queryOptions({
    queryKey: ["warehouse", "reports", "below-minimum"],
    queryFn: () =>
      jsonQuery<WarehouseItem[]>("/api/admin/warehouse/reports/below-minimum"),
  });
```

- [ ] **Step 2: Commit**

```bash
git add src/admin/lib/queries/warehouse.ts
git commit -m "feat(warehouse): add TanStack Query options and types"
```

---

### Task 9: Shared Components

**Files:**

- Create: `src/admin/components/warehouse/ItemPicker.tsx`
- Create: `src/admin/components/warehouse/BatchPicker.tsx`
- Create: `src/admin/components/warehouse/LocationSelect.tsx`
- Create: `src/admin/components/warehouse/SupplierSelect.tsx`
- Create: `src/admin/components/warehouse/WarehouseMovementTable.tsx`

- [ ] **Step 1: Create ItemPicker component**

```tsx
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { warehouseItemListOptions } from "../../lib/queries/warehouse";

interface ItemPickerProps {
  value: number | null;
  onChange: (itemId: number) => void;
}

export default function ItemPicker({ value, onChange }: ItemPickerProps) {
  const [search, setSearch] = useState("");
  const [open, setOpen] = useState(false);

  const { data } = useQuery(warehouseItemListOptions({ search, perPage: 20 }));

  const items = data?.data ?? [];

  return (
    <div className="admin-form-group">
      <input
        type="text"
        className="admin-form-input"
        placeholder="Hledat položku..."
        value={search}
        onChange={(e) => {
          setSearch(e.target.value);
          setOpen(true);
        }}
        onFocus={() => setOpen(true)}
      />
      {open && items.length > 0 && (
        <ul className="admin-item-picker-list">
          {items.map((item) => (
            <li
              key={item.id}
              className={`admin-item-picker-item ${value === item.id ? "active" : ""}`}
              onClick={() => {
                onChange(item.id);
                setOpen(false);
                setSearch(item.name);
              }}
            >
              <span className="admin-item-picker-name">{item.name}</span>
              {item.item_number && (
                <span className="admin-item-picker-number">
                  {item.item_number}
                </span>
              )}
              {item.available_quantity !== undefined && (
                <span className="admin-item-picker-qty">
                  {item.available_quantity} {item.unit}
                </span>
              )}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
```

- [ ] **Step 2: Create LocationSelect component**

```tsx
import { useQuery } from "@tanstack/react-query";
import { warehouseLocationListOptions } from "../../lib/queries/warehouse";

interface LocationSelectProps {
  value: number | null;
  onChange: (locationId: number | null) => void;
}

export default function LocationSelect({
  value,
  onChange,
}: LocationSelectProps) {
  const { data: locations } = useQuery(warehouseLocationListOptions());

  return (
    <select
      className="admin-form-select"
      value={value ?? ""}
      onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
    >
      <option value="">-- bez lokace --</option>
      {locations?.map((loc) => (
        <option key={loc.id} value={loc.id}>
          {loc.code} - {loc.name}
        </option>
      ))}
    </select>
  );
}
```

- [ ] **Step 3: Create SupplierSelect component**

```tsx
import { useQuery } from "@tanstack/react-query";
import { warehouseSupplierListOptions } from "../../lib/queries/warehouse";

interface SupplierSelectProps {
  value: number | null;
  onChange: (supplierId: number | null) => void;
}

export default function SupplierSelect({
  value,
  onChange,
}: SupplierSelectProps) {
  const { data } = useQuery(warehouseSupplierListOptions({ perPage: 100 }));

  const suppliers = data?.data ?? [];

  return (
    <select
      className="admin-form-select"
      value={value ?? ""}
      onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
    >
      <option value="">-- bez dodavatele --</option>
      {suppliers.map((s) => (
        <option key={s.id} value={s.id}>
          {s.name}
          {s.ico ? ` (${s.ico})` : ""}
        </option>
      ))}
    </select>
  );
}
```

- [ ] **Step 4: Create BatchPicker component**

```tsx
import { useQuery } from "@tanstack/react-query";
import { jsonQuery } from "../../lib/apiAdapter";
import type { WarehouseItem } from "../../lib/queries/warehouse";

interface Batch {
  id: number;
  quantity: number;
  unit_price: number;
  received_at: string;
  is_consumed: boolean;
}

interface BatchPickerProps {
  itemId: number | null;
  value: number | null;
  onChange: (batchId: number, unitPrice: number) => void;
}

export default function BatchPicker({
  itemId,
  value,
  onChange,
}: BatchPickerProps) {
  const { data: batches } = useQuery({
    queryKey: ["warehouse", "batches", itemId],
    queryFn: () =>
      jsonQuery<Batch[]>(`/api/admin/warehouse/items/${itemId}/batches`),
    enabled: !!itemId,
  });

  return (
    <select
      className="admin-form-select"
      value={value ?? ""}
      onChange={(e) => {
        const batchId = Number(e.target.value);
        const batch = batches?.find((b) => b.id === batchId);
        if (batch) onChange(batch.id, Number(batch.unit_price));
      }}
    >
      <option value="">-- auto FIFO --</option>
      {batches?.map((b) => (
        <option key={b.id} value={b.id}>
          {new Date(b.received_at).toLocaleDateString("cs-CZ")} | {b.quantity}{" "}
          ks | {Number(b.unit_price).toFixed(2)} Kč
        </option>
      ))}
    </select>
  );
}
```

- [ ] **Step 5: Create WarehouseMovementTable component**

This is the multi-line editor used in both receipt and issue forms:

```tsx
import { useState } from "react";
import type {
  WarehouseItem,
  WarehouseLocation,
} from "../../lib/queries/warehouse";
import ItemPicker from "./ItemPicker";
import LocationSelect from "./LocationSelect";
import BatchPicker from "./BatchPicker";

export interface MovementLine {
  key: string;
  item_id: number | null;
  item_name?: string;
  quantity: number;
  unit_price: number;
  location_id: number | null;
  batch_id: number | null;
  reservation_id: number | null;
  notes: string | null;
}

interface WarehouseMovementTableProps {
  lines: MovementLine[];
  onChange: (lines: MovementLine[]) => void;
  mode: "receipt" | "issue";
  locations: WarehouseLocation[];
}

let lineCounter = 0;

export default function WarehouseMovementTable({
  lines,
  onChange,
  mode,
  locations,
}: WarehouseMovementTableProps) {
  const addLine = () => {
    onChange([
      ...lines,
      {
        key: `line-${++lineCounter}`,
        item_id: null,
        quantity: 0,
        unit_price: 0,
        location_id: null,
        batch_id: null,
        reservation_id: null,
        notes: null,
      },
    ]);
  };

  const removeLine = (index: number) => {
    onChange(lines.filter((_, i) => i !== index));
  };

  const updateLine = (index: number, field: string, value: unknown) => {
    const updated = [...lines];
    updated[index] = { ...updated[index], [field]: value };
    onChange(updated);
  };

  return (
    <div className="admin-warehouse-movement-table">
      <table className="admin-table">
        <thead>
          <tr>
            <th>Položka</th>
            {mode === "issue" && <th>Šarže</th>}
            <th>Množství</th>
            <th>Cena/ks</th>
            <th>Lokace</th>
            <th>Poznámka</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          {lines.map((line, index) => (
            <tr key={line.key}>
              <td>
                <ItemPicker
                  value={line.item_id}
                  onChange={(id) => updateLine(index, "item_id", id)}
                />
              </td>
              {mode === "issue" && (
                <td>
                  <BatchPicker
                    itemId={line.item_id}
                    value={line.batch_id}
                    onChange={(batchId, unitPrice) => {
                      updateLine(index, "batch_id", batchId);
                      updateLine(index, "unit_price", unitPrice);
                    }}
                  />
                </td>
              )}
              <td>
                <input
                  type="number"
                  className="admin-form-input"
                  value={line.quantity || ""}
                  onChange={(e) =>
                    updateLine(index, "quantity", Number(e.target.value))
                  }
                  min="0"
                  step="0.001"
                />
              </td>
              <td>
                <input
                  type="number"
                  className="admin-form-input"
                  value={line.unit_price || ""}
                  onChange={(e) =>
                    updateLine(index, "unit_price", Number(e.target.value))
                  }
                  min="0"
                  step="0.01"
                  disabled={mode === "issue"}
                />
              </td>
              <td>
                <LocationSelect
                  value={line.location_id}
                  onChange={(id) => updateLine(index, "location_id", id)}
                />
              </td>
              <td>
                <input
                  type="text"
                  className="admin-form-input"
                  value={line.notes ?? ""}
                  onChange={(e) => updateLine(index, "notes", e.target.value)}
                />
              </td>
              <td>
                <button
                  type="button"
                  className="admin-btn-danger-sm"
                  onClick={() => removeLine(index)}
                >
                  ✕
                </button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
      <button type="button" className="admin-btn-secondary" onClick={addLine}>
        + Přidat řádek
      </button>
    </div>
  );
}
```

- [ ] **Step 6: Commit**

```bash
git add src/admin/components/warehouse/
git commit -m "feat(warehouse): add shared warehouse components"
```

---

### Task 10: Sidebar & Routes

**Files:**

- Modify: `src/admin/components/Sidebar.tsx`
- Modify: `src/admin/AdminApp.tsx`

- [ ] **Step 1: Add warehouse entry to Sidebar.tsx**

In `src/admin/components/Sidebar.tsx`, add a new item to the "Administrativa" menu section:

```ts
{
  path: "/warehouse",
  label: "Sklad",
  permission: "warehouse.view",
  matchPrefix: "/warehouse",
  icon: (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
      <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
      <polyline points="3.27 6.96 12 12.01 20.73 6.96" />
      <line x1="12" y1="22.08" x2="12" y2="12" />
    </svg>
  ),
},
```

- [ ] **Step 2: Add lazy imports and routes to AdminApp.tsx**

In `src/admin/AdminApp.tsx`, add lazy imports:

```tsx
const Warehouse = lazy(() => import("./pages/Warehouse"));
const WarehouseItems = lazy(() => import("./pages/WarehouseItems"));
const WarehouseItemDetail = lazy(() => import("./pages/WarehouseItemDetail"));
const WarehouseReceipts = lazy(() => import("./pages/WarehouseReceipts"));
const WarehouseReceiptDetail = lazy(
  () => import("./pages/WarehouseReceiptDetail"),
);
const WarehouseReceiptForm = lazy(() => import("./pages/WarehouseReceiptForm"));
const WarehouseIssues = lazy(() => import("./pages/WarehouseIssues"));
const WarehouseIssueDetail = lazy(() => import("./pages/WarehouseIssueDetail"));
const WarehouseIssueForm = lazy(() => import("./pages/WarehouseIssueForm"));
const WarehouseReservations = lazy(
  () => import("./pages/WarehouseReservations"),
);
const WarehouseInventory = lazy(() => import("./pages/WarehouseInventory"));
const WarehouseInventoryDetail = lazy(
  () => import("./pages/WarehouseInventoryDetail"),
);
const WarehouseInventoryForm = lazy(
  () => import("./pages/WarehouseInventoryForm"),
);
const WarehouseReports = lazy(() => import("./pages/WarehouseReports"));
const WarehouseSuppliers = lazy(() => import("./pages/WarehouseSuppliers"));
const WarehouseLocations = lazy(() => import("./pages/WarehouseLocations"));
const WarehouseCategories = lazy(() => import("./pages/WarehouseCategories"));
```

Add routes inside the `<Route element={<AdminLayout />}>` section:

```tsx
<Route path="warehouse" element={<Warehouse />} />
<Route path="warehouse/items" element={<WarehouseItems />} />
<Route path="warehouse/items/:id" element={<WarehouseItemDetail />} />
<Route path="warehouse/receipts" element={<WarehouseReceipts />} />
<Route path="warehouse/receipts/new" element={<WarehouseReceiptForm />} />
<Route path="warehouse/receipts/:id" element={<WarehouseReceiptDetail />} />
<Route path="warehouse/receipts/:id/edit" element={<WarehouseReceiptForm />} />
<Route path="warehouse/issues" element={<WarehouseIssues />} />
<Route path="warehouse/issues/new" element={<WarehouseIssueForm />} />
<Route path="warehouse/issues/:id" element={<WarehouseIssueDetail />} />
<Route path="warehouse/issues/:id/edit" element={<WarehouseIssueForm />} />
<Route path="warehouse/reservations" element={<WarehouseReservations />} />
<Route path="warehouse/inventory" element={<WarehouseInventory />} />
<Route path="warehouse/inventory/new" element={<WarehouseInventoryForm />} />
<Route path="warehouse/inventory/:id" element={<WarehouseInventoryDetail />} />
<Route path="warehouse/reports" element={<WarehouseReports />} />
<Route path="warehouse/suppliers" element={<WarehouseSuppliers />} />
<Route path="warehouse/locations" element={<WarehouseLocations />} />
<Route path="warehouse/categories" element={<WarehouseCategories />} />
```

- [ ] **Step 3: Commit**

```bash
git add src/admin/components/Sidebar.tsx src/admin/AdminApp.tsx
git commit -m "feat(warehouse): add sidebar entry and routes"
```

---

## Phase 5: Frontend - Pages

### Task 11: Master Data Pages (Categories, Suppliers, Locations)

**Files:**

- Create: `src/admin/pages/WarehouseCategories.tsx`
- Create: `src/admin/pages/WarehouseSuppliers.tsx`
- Create: `src/admin/pages/WarehouseLocations.tsx`

These three pages follow the same CRUD pattern as existing pages (e.g., Vehicles). Each page:

- Checks permission at top
- Uses `useQuery` for listing
- Has a modal for create/edit
- Uses `useAlert` for feedback
- Invalidates `["warehouse"]` on mutation

- [ ] **Step 1: Create WarehouseCategories.tsx**

Standard CRUD page: list categories in a table, modal for create/edit, delete with confirmation. Permission: `warehouse.manage`. Uses `warehouseCategoryListOptions` for data. Mutations: POST/PUT/DELETE `/api/admin/warehouse/categories`. Invalidates `["warehouse"]` on success.

- [ ] **Step 2: Create WarehouseSuppliers.tsx**

Standard CRUD page with paginated list. Fields: name, ico, dic, contact_person, email, phone, address, notes. Uses `warehouseSupplierListOptions`. Permission: `warehouse.manage`.

- [ ] **Step 3: Create WarehouseLocations.tsx**

Standard CRUD page. Fields: code (unique), name, description. Uses `warehouseLocationListOptions`. Permission: `warehouse.manage`.

- [ ] **Step 4: Commit**

```bash
git add src/admin/pages/WarehouseCategories.tsx src/admin/pages/WarehouseSuppliers.tsx src/admin/pages/WarehouseLocations.tsx
git commit -m "feat(warehouse): add categories, suppliers, locations pages"
```

---

### Task 12: Items Pages (List + Detail)

**Files:**

- Create: `src/admin/pages/WarehouseItems.tsx`
- Create: `src/admin/pages/WarehouseItemDetail.tsx`

- [ ] **Step 1: Create WarehouseItems.tsx**

Paginated item catalog with search, category filter, sorting. Columns: item_number, name, category, unit, total_quantity, available_quantity, stock_value, below_minimum (red badge). Click row → navigate to detail. Permission: `warehouse.view`. Uses `warehouseItemListOptions`.

- [ ] **Step 2: Create WarehouseItemDetail.tsx**

Shows item details, batch list (FIFO queue), location breakdown, and active reservations. Has edit button (opens inline edit or modal). Permission: `warehouse.view`. Uses `warehouseItemDetailOptions`.

- [ ] **Step 3: Commit**

```bash
git add src/admin/pages/WarehouseItems.tsx src/admin/pages/WarehouseItemDetail.tsx
git commit -m "feat(warehouse): add items list and detail pages"
```

---

### Task 13: Receipt Pages (List, Detail, Form)

**Files:**

- Create: `src/admin/pages/WarehouseReceipts.tsx`
- Create: `src/admin/pages/WarehouseReceiptDetail.tsx`
- Create: `src/admin/pages/WarehouseReceiptForm.tsx`

- [ ] **Step 1: Create WarehouseReceipts.tsx**

Paginated receipt list with filters (status, supplier, date range). Columns: receipt_number, supplier, status (colored badge), delivery_note_number, created_at, line count. Click row → navigate to detail. Permission: `warehouse.view`. Uses `warehouseReceiptListOptions`.

- [ ] **Step 2: Create WarehouseReceiptDetail.tsx**

Receipt detail with:

- Header: receipt_number, supplier, delivery note info, status badge
- Lines table: item, quantity, unit_price, location, notes
- Attachments section: list of uploaded files with download/delete buttons
- Actions: Confirm button (if DRAFT), Cancel button (if DRAFT or CONFIRMED), Edit button (if DRAFT)
- Permission: `warehouse.view` for viewing, `warehouse.operate` for actions

- [ ] **Step 3: Create WarehouseReceiptForm.tsx**

Create/edit form using `WarehouseMovementTable` component. Header fields: supplier (SupplierSelect), delivery note number, delivery note date, notes. Uses file input for attachment upload. Draft/Confirm submit buttons. Route: `/warehouse/receipts/new` (create) and `/warehouse/receipts/:id/edit` (edit, pre-fills from existing). Permission: `warehouse.operate`.

- [ ] **Step 4: Commit**

```bash
git add src/admin/pages/WarehouseReceipts.tsx src/admin/pages/WarehouseReceiptDetail.tsx src/admin/pages/WarehouseReceiptForm.tsx
git commit -m "feat(warehouse): add receipt pages"
```

---

### Task 14: Issue Pages (List, Detail, Form)

**Files:**

- Create: `src/admin/pages/WarehouseIssues.tsx`
- Create: `src/admin/pages/WarehouseIssueDetail.tsx`
- Create: `src/admin/pages/WarehouseIssueForm.tsx`

- [ ] **Step 1: Create WarehouseIssues.tsx**

Paginated issue list with filters (status, project, date range). Columns: issue_number, project, status (colored badge), created_at, line count. Click row → navigate to detail. Permission: `warehouse.view`. Uses `warehouseIssueListOptions`.

- [ ] **Step 2: Create WarehouseIssueDetail.tsx**

Issue detail with:

- Header: issue_number, project name, status badge, issued_by
- Lines table: item, batch (received date + price), quantity, location, reservation link, notes
- Actions: Confirm button (if DRAFT), Cancel button (if DRAFT or CONFIRMED), Edit button (if DRAFT)
- Permission: `warehouse.view` for viewing, `warehouse.operate` for actions

- [ ] **Step 3: Create WarehouseIssueForm.tsx**

Create/edit form using `WarehouseMovementTable` component (mode="issue"). Header fields: project (mandatory select from projects list), notes. Each line has BatchPicker for batch selection (auto-FIFO if not selected). Draft/Confirm submit buttons. Permission: `warehouse.operate`.

- [ ] **Step 4: Commit**

```bash
git add src/admin/pages/WarehouseIssues.tsx src/admin/pages/WarehouseIssueDetail.tsx src/admin/pages/WarehouseIssueForm.tsx
git commit -m "feat(warehouse): add issue pages"
```

---

### Task 15: Reservations, Inventory, Reports, Dashboard Pages

**Files:**

- Create: `src/admin/pages/WarehouseReservations.tsx`
- Create: `src/admin/pages/WarehouseInventory.tsx`
- Create: `src/admin/pages/WarehouseInventoryDetail.tsx`
- Create: `src/admin/pages/WarehouseInventoryForm.tsx`
- Create: `src/admin/pages/WarehouseReports.tsx`
- Create: `src/admin/pages/Warehouse.tsx`

- [ ] **Step 1: Create WarehouseReservations.tsx**

List of active reservations with item, project, quantity, remaining_qty, status. Cancel button per row. Create reservation modal (item picker, project picker, quantity). Permission: `warehouse.view` + `warehouse.operate` for cancel/create. Uses `warehouseReservationListOptions`.

- [ ] **Step 2: Create WarehouseInventory.tsx**

List of inventory sessions. Create button → navigate to form. Columns: session_number, status, created_at, line count. Click row → navigate to detail. Permission: `warehouse.inventory`. Uses `warehouseInventoryListOptions`.

- [ ] **Step 3: Create WarehouseInventoryDetail.tsx**

Inventory session detail with lines table: item, location, system_qty, actual_qty, difference (colored red/green), notes. Confirm button (if DRAFT). Permission: `warehouse.inventory`.

- [ ] **Step 4: Create WarehouseInventoryForm.tsx**

Create/edit inventory form. Select items, enter actual quantities. System quantities auto-filled on creation. Permission: `warehouse.inventory`.

- [ ] **Step 5: Create WarehouseReports.tsx**

Tab-based report page with 4 tabs:

- **Stock Status**: table of all items with qty, min_qty, value, below-min flag (uses `warehouseStockStatusOptions`)
- **Project Consumption**: filter by project + date range, shows material consumed per project
- **Movement Log**: filter by date range + type (receipt/issue), shows all movements
- **Below Minimum**: items under min_quantity (uses `warehouseBelowMinimumOptions`)

Permission: `warehouse.view`.

- [ ] **Step 6: Create Warehouse.tsx (Dashboard)**

Dashboard with:

- Summary cards: total items, total stock value, below-minimum count, active reservations count
- Below-minimum alert section (highlighted items)
- Recent movements (last 10, fetched from movement-log report)
  Permission: `warehouse.view`.

- [ ] **Step 7: Commit**

```bash
git add src/admin/pages/WarehouseReservations.tsx src/admin/pages/WarehouseInventory.tsx src/admin/pages/WarehouseInventoryDetail.tsx src/admin/pages/WarehouseInventoryForm.tsx src/admin/pages/WarehouseReports.tsx src/admin/pages/Warehouse.tsx
git commit -m "feat(warehouse): add reservations, inventory, reports, and dashboard pages"
```

---

## Phase 6: Testing & Polish

### Task 16: Integration Tests

**Files:**

- Create: `src/__tests__/warehouse.test.ts`

- [ ] **Step 1: Write integration tests for warehouse API**

Tests using the `buildApp()` helper against the real test database. Cover:

- Category CRUD
- Supplier CRUD
- Location CRUD
- Item CRUD + available-qty calculation
- Receipt create (DRAFT) → confirm → verify batches created + item_locations updated
- Receipt cancel (CONFIRMED) → verify batches deleted + item_locations restored
- Issue create (DRAFT) → confirm → verify batches decremented + reservations fulfilled
- Issue cancel (CONFIRMED) → verify batches restored
- Reservation create → verify available_qty decreases
- Reservation cancel → verify available_qty increases
- Permission checks (unauthorized access returns 403)

- [ ] **Step 2: Run tests**

```bash
npm test -- --reporter=verbose src/__tests__/warehouse.test.ts
```

Expected: All tests pass.

- [ ] **Step 3: Commit**

```bash
git add src/__tests__/warehouse.test.ts
git commit -m "test(warehouse): add integration tests"
```

---

### Task 17: Build Verification

- [ ] **Step 1: Run full build**

```bash
npm run build
```

Expected: No errors, `dist/` and `dist-client/` generated successfully.

- [ ] **Step 2: Run full test suite**

```bash
npm test
```

Expected: All tests pass (existing + warehouse).

- [ ] **Step 3: Commit any remaining fixes**

---

## Self-Review Checklist

After writing the complete plan, I verified:

1. **Spec coverage**: Every requirement in the design spec has a corresponding task:
   - 13 Prisma models → Task 1
   - 5 enums → Task 1
   - Entity types → Task 2
   - 4 permissions → Task 2
   - 3 number sequence types → Task 2
   - Zod schemas → Task 3
   - Service (FIFO, confirm, cancel, inventory) → Task 4
   - All API endpoints → Tasks 5-6
   - Server registration → Task 7
   - Query hooks → Task 8
   - Shared components → Task 9
   - Sidebar + routes → Task 10
   - All 17 pages → Tasks 11-15
   - Tests → Task 16
   - Build verification → Task 17

2. **Placeholder scan**: No TBD, TODO, or placeholder patterns found. All tasks contain actual code or specific instructions.

3. **Type consistency**: Types defined in Task 8 (`WarehouseItem`, `WarehouseReceipt`, etc.) are used consistently across Tasks 9-15. Service function signatures in Task 4 match route handler usage in Tasks 5-6.
