---
date: 2026-05-21
type: impl-plan
epic_id: EPIC-005
repo: web-kingston
title: Implementation Plan — web-kingston
---

# IMPL-PLAN-WEB — EPIC-005: Add Packaging Material

## Thứ tự thực hiện

1. Domain: Entity
2. Domain: Repository Interface
3. Application: Use Cases (4 files)
4. Infrastructure: Response Types
5. Infrastructure: Mapper
6. Infrastructure: ApiRepository
7. Infrastructure: Error Messages + ErrorService
8. DI: types.ts + container.ts
9. Presentation: Query Hook
10. Presentation: Mutation Hooks (3 files)
11. Presentation: Section Component
12. Integration: Mount vào Order Detail Page

---

## Step 1 — Domain: Entity

**File mới:** `src/clean-architecture/domain/entities/OrderPackagingMaterial.ts`
**Follow pattern:** `src/clean-architecture/domain/entities/Order.ts`

- Interface `IOrderPackagingMaterial`:
  - `id: ID`, `orderUUID: UUID`, `materialId: ID`, `quantity: number`, `note: string | null`
  - `material: { id: ID; code: string; name: string; unit: { id: ID; name: string } } | null`
- Class `OrderPackagingMaterial implements IOrderPackagingMaterial` — constructor assigns all fields
- Builder `OrderPackagingMaterialBuilder extends Builder<OrderPackagingMaterial>` — `build()` returns new instance
- **Không có** state machine, status, hay enum — entity này là dữ liệu thuần túy

---

## Step 2 — Domain: Repository Interface

**File mới:** `src/clean-architecture/domain/repositories/OrderPackagingMaterialRepository.ts`
**Follow pattern:** `src/clean-architecture/domain/repositories/OrderRepository.ts`

- `GetOrderPackagingMaterialsArgs`: `{ orderUUID: UUID }`
- `CreateOrderPackagingMaterialArgs`: `{ orderUUID: UUID; materialId: ID; quantity: number; note?: string }`
- `UpdateOrderPackagingMaterialArgs`: `{ orderUUID: UUID; id: ID; quantity?: number; note?: string }`
- `DeleteOrderPackagingMaterialArgs`: `{ orderUUID: UUID; id: ID }`
- Interface `OrderPackagingMaterialRepository`:
  - `getOrderPackagingMaterials(args: GetOrderPackagingMaterialsArgs): Promise<OrderPackagingMaterial[]>`
  - `createOrderPackagingMaterial(args: CreateOrderPackagingMaterialArgs): Promise<OrderPackagingMaterial>`
  - `updateOrderPackagingMaterial(args: UpdateOrderPackagingMaterialArgs): Promise<OrderPackagingMaterial>`
  - `deleteOrderPackagingMaterial(args: DeleteOrderPackagingMaterialArgs): Promise<{ id: ID }>`

---

## Step 3 — Application: Use Cases

**Follow pattern:** `src/clean-architecture/application/useCases/order/CreateOrder.ts`

Pattern chung: `@injectable()` class với constructor `@inject(TYPES.OrderPackagingMaterialRepository) private repo: OrderPackagingMaterialRepository`, method `execute(args)` gọi repo method.

### 3a. GetOrderPackagingMaterials

**File mới:** `src/clean-architecture/application/useCases/order-packaging-material/GetOrderPackagingMaterials.ts`

- `execute(args: GetOrderPackagingMaterialsArgs): Promise<OrderPackagingMaterial[]>`
- Gọi `this.repo.getOrderPackagingMaterials(args)`

### 3b. CreateOrderPackagingMaterial

**File mới:** `src/clean-architecture/application/useCases/order-packaging-material/CreateOrderPackagingMaterial.ts`

- `execute(args: CreateOrderPackagingMaterialArgs): Promise<OrderPackagingMaterial>`
- Gọi `this.repo.createOrderPackagingMaterial(args)`

### 3c. UpdateOrderPackagingMaterial

**File mới:** `src/clean-architecture/application/useCases/order-packaging-material/UpdateOrderPackagingMaterial.ts`

- `execute(args: UpdateOrderPackagingMaterialArgs): Promise<OrderPackagingMaterial>`
- Gọi `this.repo.updateOrderPackagingMaterial(args)`

### 3d. DeleteOrderPackagingMaterial

**File mới:** `src/clean-architecture/application/useCases/order-packaging-material/DeleteOrderPackagingMaterial.ts`

- `execute(args: DeleteOrderPackagingMaterialArgs): Promise<{ id: ID }>`
- Gọi `this.repo.deleteOrderPackagingMaterial(args)`

---

## Step 4 — Infrastructure: Response Types

**File mới:** `src/clean-architecture/infrastructure/api/types/OrderPackagingMaterialResponse.ts`
**Follow pattern:** `src/clean-architecture/infrastructure/api/types/OrderResponse.ts`

```ts
export interface OrderPackagingMaterialResponse {
  id: number;
  orderUUID: string;
  materialId: number;
  quantity: string; // Decimal từ API trả về dạng string
  note: string | null;
  createdAt: string;
  updatedAt: string;
  material: {
    id: number;
    code: string;
    name: string;
    unit: { id: number; name: string } | null;
  } | null;
}
```

---

## Step 5 — Infrastructure: Mapper

**File mới:** `src/clean-architecture/infrastructure/api/mapper/OrderPackagingMaterialMapper.ts`
**Follow pattern:** `src/clean-architecture/infrastructure/api/mapper/OrderMapper.ts`

- Static class `OrderPackagingMaterialMapper`
- `static toDomain(resp: OrderPackagingMaterialResponse): OrderPackagingMaterial`:
  - `id: resp.id.toString()` — numeric ID → string
  - `orderUUID: resp.orderUUID` — UUID string trực tiếp
  - `materialId: resp.materialId.toString()`
  - `quantity: parseFloat(resp.quantity)` — string Decimal → number
  - `note: resp.note ?? null`
  - `material`: nếu `resp.material` tồn tại: map `id.toString()`, `code`, `name`, `unit` (nếu có: `id.toString()`, `name`)
- `static toDomainList(resp: OrderPackagingMaterialResponse[]): OrderPackagingMaterial[]`
  - `resp.map(r => OrderPackagingMaterialMapper.toDomain(r))`

---

## Step 6 — Infrastructure: ApiRepository

**File mới:** `src/clean-architecture/infrastructure/api/OrderPackagingMaterialApiRepository.ts`
**Follow pattern:** `src/clean-architecture/infrastructure/api/OrderApiRepository.ts`

- `@injectable()` class implements `OrderPackagingMaterialRepository`
- Constructor: `@inject(TYPES.ApiService) private api: ApiService`
- Base URL helper: `private baseUrl(orderUUID: UUID) { return \`${BASE_ORDER_API}/${orderUUID}/packaging-materials\` }`
  - Dùng lại `BASE_ORDER_API = "/api/admin/orders"` import từ `OrderApiRepository`
- Methods:
  - `getOrderPackagingMaterials(args)` → `GET baseUrl(args.orderUUID)` → `OrderPackagingMaterialMapper.toDomainList(data)`
  - `createOrderPackagingMaterial(args)` → `POST baseUrl(args.orderUUID)` với body `{ materialId, quantity, note }` → `OrderPackagingMaterialMapper.toDomain(data)`
  - `updateOrderPackagingMaterial(args)` → `PUT ${baseUrl(args.orderUUID)}/${args.id}` với body `{ quantity, note }` → `OrderPackagingMaterialMapper.toDomain(data)`
  - `deleteOrderPackagingMaterial(args)` → `DELETE ${baseUrl(args.orderUUID)}/${args.id}` → return data as `{ id: ID }`

---

## Step 7 — Infrastructure: Error Messages + ErrorService

**File mới:** `src/clean-architecture/infrastructure/api/message/OrderPackagingMaterialMessage.ts`
**Follow pattern:** `src/clean-architecture/infrastructure/api/message/OrderMessage.ts`

```ts
import { MessageHelper } from "./MessageHelper";

export enum OrderPackagingMaterialMessageKey {
  NotFound = "Order Packaging Material Not Found",
  AlreadyExists = "Order Packaging Material Already Exists",
  InvalidQuantity = "Order Packaging Material Invalid Quantity",
  OrderNotEditable = "Order Is Not Editable",
}

export const OrderPackagingMaterialMessages: Record<OrderPackagingMaterialMessageKey, string> = {
  [OrderPackagingMaterialMessageKey.NotFound]: MessageHelper.notFound("Vật tư đóng gói"),
  [OrderPackagingMaterialMessageKey.AlreadyExists]: "Vật tư này đã có trong danh sách bao bì của đơn hàng",
  [OrderPackagingMaterialMessageKey.InvalidQuantity]: "Số lượng phải lớn hơn 0",
  [OrderPackagingMaterialMessageKey.OrderNotEditable]: "Đơn hàng không ở trạng thái có thể chỉnh sửa bao bì",
};
```

**Modified:** `src/clean-architecture/infrastructure/services/ErrorService.ts`

1. Import `OrderPackagingMaterialMessages` từ message file mới
2. Thêm `"orderPackagingMaterial"` vào type `Domain`
3. Thêm `OrderPackagingMaterialMessages` vào map messages (theo pattern hiện có của các domain khác)

---

## Step 8 — DI: types.ts + container.ts

**Modified:** `src/clean-architecture/di/types.ts`

```ts
// OrderPackagingMaterial
OrderPackagingMaterialRepository: Symbol.for("OrderPackagingMaterialRepository"),
GetOrderPackagingMaterials: Symbol.for("GetOrderPackagingMaterials"),
CreateOrderPackagingMaterial: Symbol.for("CreateOrderPackagingMaterial"),
UpdateOrderPackagingMaterial: Symbol.for("UpdateOrderPackagingMaterial"),
DeleteOrderPackagingMaterial: Symbol.for("DeleteOrderPackagingMaterial"),
```

Thêm vào cuối object `TYPES`, sau section `// Material`.

**Modified:** `src/clean-architecture/di/container.ts`

```ts
import { OrderPackagingMaterialApiRepository } from "../infrastructure/api/OrderPackagingMaterialApiRepository";
import { GetOrderPackagingMaterials } from "../application/useCases/order-packaging-material/GetOrderPackagingMaterials";
import { CreateOrderPackagingMaterial } from "../application/useCases/order-packaging-material/CreateOrderPackagingMaterial";
import { UpdateOrderPackagingMaterial } from "../application/useCases/order-packaging-material/UpdateOrderPackagingMaterial";
import { DeleteOrderPackagingMaterial } from "../application/useCases/order-packaging-material/DeleteOrderPackagingMaterial";
import type { OrderPackagingMaterialRepository } from "../domain/repositories/OrderPackagingMaterialRepository";

// Bindings:
container
  .bind<OrderPackagingMaterialRepository>(TYPES.OrderPackagingMaterialRepository)
  .to(OrderPackagingMaterialApiRepository)
  .inSingletonScope();
container
  .bind<GetOrderPackagingMaterials>(TYPES.GetOrderPackagingMaterials)
  .to(GetOrderPackagingMaterials)
  .inSingletonScope();
container
  .bind<CreateOrderPackagingMaterial>(TYPES.CreateOrderPackagingMaterial)
  .to(CreateOrderPackagingMaterial)
  .inSingletonScope();
container
  .bind<UpdateOrderPackagingMaterial>(TYPES.UpdateOrderPackagingMaterial)
  .to(UpdateOrderPackagingMaterial)
  .inSingletonScope();
container
  .bind<DeleteOrderPackagingMaterial>(TYPES.DeleteOrderPackagingMaterial)
  .to(DeleteOrderPackagingMaterial)
  .inSingletonScope();
```

---

## Step 9 — Presentation: Query Hook

**File mới:** `src/clean-architecture/presentation/modules/order/hooks/useGetOrderPackagingMaterials.tsx`
**Follow pattern:** `src/clean-architecture/presentation/modules/order/hooks/useGetOrderById.tsx`

- `useGetOrderPackagingMaterials(orderUUID: UUID, opts?: UseQueryOptionsCustom<OrderPackagingMaterial[]>)`
- `container.get<GetOrderPackagingMaterials>(TYPES.GetOrderPackagingMaterials)`
- `container.get<AuthService>(TYPES.AuthService)`
- `queryKey: ["order-packaging-materials", orderUUID]`
- `queryFn: () => useCase.execute({ orderUUID })`
- `enabled: authService.isAuthenticated() && !!orderUUID && opts?.enabled !== false`
- Return: `{ packagingMaterials: data ?? [], isPackagingMaterialsLoading: isLoading, error, refetch }`

---

## Step 10 — Presentation: Mutation Hooks

### 10a. useCreateOrderPackagingMaterial

**File mới:** `src/clean-architecture/presentation/modules/order/hooks/useCreateOrderPackagingMaterial.tsx`
**Follow pattern:** `src/clean-architecture/presentation/modules/order/hooks/useCreateOrder.tsx`

- `useCreateOrderPackagingMaterial(opts?: UseMutationOptionsCustom<OrderPackagingMaterial, CreateOrderPackagingMaterialArgs>)`
- `container.get<CreateOrderPackagingMaterial>(TYPES.CreateOrderPackagingMaterial)`
- `mutationKey: ["create-order-packaging-material"]`
- `wrapMutationFn((args) => createUseCase.execute(args), "orderPackagingMaterial")`
- Return: `{ createOrderPackagingMaterial: mutation.mutateAsync, isCreating: mutation.isPending }`

### 10b. useUpdateOrderPackagingMaterial

**File mới:** `src/clean-architecture/presentation/modules/order/hooks/useUpdateOrderPackagingMaterial.tsx`
**Follow pattern:** `useCreateOrderPackagingMaterial`

- `mutationKey: ["update-order-packaging-material"]`
- Return: `{ updateOrderPackagingMaterial, isUpdating }`

### 10c. useDeleteOrderPackagingMaterial

**File mới:** `src/clean-architecture/presentation/modules/order/hooks/useDeleteOrderPackagingMaterial.tsx`
**Follow pattern:** `useCreateOrderPackagingMaterial`

- `mutationKey: ["delete-order-packaging-material"]`
- Return: `{ deleteOrderPackagingMaterial, isDeleting }`

### 10d. Hooks Barrel

**Modified:** `src/clean-architecture/presentation/modules/order/hooks/index.ts`

Thêm exports:
```ts
export * from "./useGetOrderPackagingMaterials";
export * from "./useCreateOrderPackagingMaterial";
export * from "./useUpdateOrderPackagingMaterial";
export * from "./useDeleteOrderPackagingMaterial";
```

---

## Step 11 — Presentation: Section Component

**File mới:** `src/clean-architecture/presentation/modules/order/components/OrderPackagingMaterialSection/OrderPackagingMaterialSection.tsx`
**File mới:** `src/clean-architecture/presentation/modules/order/components/OrderPackagingMaterialSection/index.ts`

### Props

```ts
interface OrderPackagingMaterialSectionProps {
  orderUUID: UUID;
  orderStatus: OrderStatus;
}
```

### Layout (xem PRD wireframe)

```
┌─────────────────────────────────────────────────────────┐
│ Vật tư đóng gói                    [+ Thêm vật tư]      │
│                                    (ẩn nếu không phải   │
│                                     WAIT_FOR_APPROVE)   │
├────────────────────────────────────────────────────────┤
│ Mã vật tư │ Tên vật tư │ Số lượng │ Đơn vị │ Ghi chú │ │
│ BB-001    │ Thùng 5 lớp│ 2        │ thùng  │ ...     │ │
│ [Edit] [Delete]                                         │
│ (ẩn nếu không phải WAIT_FOR_APPROVE)                    │
└─────────────────────────────────────────────────────────┘
```

### Behavior

- Dùng `useGetOrderPackagingMaterials(orderUUID)` để fetch danh sách
- `isWritable = orderStatus === OrderStatus.WAIT_FOR_APPROVE`
- Khi `isWritable = false`: ẩn nút "+ Thêm", ẩn icon Edit/Delete — read-only mode
- Empty state: hiển thị text "Đơn hàng này chưa có vật tư bao bì" + nút action (chỉ khi `isWritable`)
- State quản lý modal: `isCreateModalOpen`, `editingItem: OrderPackagingMaterial | null`

### Create Modal

- Dùng `<Dialog>` từ `@kingstonvn/ui`, wrap trong `<FormStateProvider>`
- Form fields:
  - `material`: `<MaterialSelect>` hoặc `<ReactSelect>` search từ `GET /materials?search=...` — không filter typeId
    - Form value type: `OptionSelect<ID> | null` (theo CLAUDE.md: "Select Form Values phải dùng `OptionSelect<ID> | null`")
  - `quantity`: `<Input type="number">` — validate > 0
  - `note`: `<Input>` (optional)
- Submit: gọi `createOrderPackagingMaterial({ orderUUID, materialId: material.value, quantity, note })`
- `onSuccess`: invalidate query `["order-packaging-materials", orderUUID]`, đóng modal
- Validation (Yup schema):
  - `material`: `yup.object().nullable().required("Vui lòng chọn vật tư bao bì")`
  - `quantity`: `yup.number().positive("Số lượng phải lớn hơn 0").required()`
  - `note`: `yup.string().optional()`

### Edit Modal

- Giống Create Modal nhưng `materialId` là read-only (hiển thị tên, không cho đổi)
- Pre-fill values từ `editingItem`
- Submit: gọi `updateOrderPackagingMaterial({ orderUUID, id: editingItem.id, quantity, note })`

### Delete Confirmation

- Inline confirm (dùng pattern dialog confirm hiện có trong codebase) hoặc `window.confirm`
- Submit: gọi `deleteOrderPackagingMaterial({ orderUUID, id: item.id })`
- `onSuccess`: invalidate query

### Table Columns

- Column definitions PHẢI được extract ra hook `useGetOrderPackagingMaterialColumns.tsx` (theo CLAUDE.md: "Table Columns MUST be extracted into a custom hook")
- Columns: Mã vật tư, Tên vật tư, Số lượng + Đơn vị (kết hợp hoặc 2 cột), Ghi chú, Actions (khi `isWritable`)

---

## Step 12 — Integration: Mount vào Order Detail Page

**Modified:** Order detail page component (tìm file trong `src/clean-architecture/presentation/modules/order/` hoặc `presentation/components/pages/order/`)

- Import `OrderPackagingMaterialSection` từ `../components/OrderPackagingMaterialSection`
- Mount bên dưới order items section:
  ```tsx
  <OrderPackagingMaterialSection
    orderUUID={order.uuid}
    orderStatus={order.status}
  />
  ```
- Đảm bảo `order.uuid` và `order.status` đã available tại điểm mount (từ `useGetOrderById` hook)

---

## Checklist hoàn thành

- [ ] Step 1: Entity + Builder
- [ ] Step 2: Repository interface + Args types
- [ ] Step 3: 4 use case files
- [ ] Step 4: Response type (chú ý `quantity` là string từ API)
- [ ] Step 5: Mapper (chú ý `id.toString()`, `parseFloat(quantity)`)
- [ ] Step 6: ApiRepository (`BASE_ORDER_API` reuse, `orderUUID` trong URL path)
- [ ] Step 7: Message file + ErrorService `Domain` type + messages map
- [ ] Step 8: types.ts + container.ts (5 bindings)
- [ ] Step 9: Query hook (enabled guard + `?? []` default)
- [ ] Step 10: 3 mutation hooks + barrel update
- [ ] Step 11: Section component với columns hook + Create/Edit modal + Delete confirm
- [ ] Step 12: Mount vào Order detail page
- [ ] Type-check: `cd packages/main-app && bun type-check`
- [ ] Smoke test: mở màn hình Order detail, section "Vật tư đóng gói" hiển thị, thêm/sửa/xóa hoạt động khi status = WAIT_FOR_APPROVE
