---
date: 2026-05-26
type: impl-plan
epic_id: EPIC-006
repo: web-kingston
title: Product Variant — Web Implementation Plan
---

# IMPL-PLAN — EPIC-006 (web-kingston)

> **Mục đích:** Specification cho `/implement-feature` skill. KHÔNG viết full code — chỉ mô tả input/output/rules đủ để generate code đúng pattern.
>
> **Base path:** `packages/main-app/src/clean-architecture/`
> **Follow EPIC-005 (OrderPackagingMaterial) pattern** cho infrastructure/DI steps.

---

## Step 1 — Domain: Entities

**File mới:** `domain/entities/ComponentType.ts`
**Follow pattern:** `domain/entities/ProductStructure.ts` (hoặc tương tự)

- Interface `ComponentTypeAttributeConfig`: `{ attributeTypeId: ID, name: string, inputType: 'SELECT' | 'TEXT', isRequired: boolean, options: string[] }`
- Interface `ComponentType`: `{ id: ID, name: string, attributeTypes: ComponentTypeAttributeConfig[] }`
- Class `ComponentTypeEntity` implement `ComponentType`
- Builder `ComponentTypeBuilder` với `withId`, `withName`, `withAttributeTypes`, `build()`

**File mới:** `domain/entities/AttributeType.ts`

- Interface `AttributeType`: `{ id: ID, name: string, inputType: 'SELECT' | 'TEXT', options: string[] }`
- Class `AttributeTypeEntity`

**File mới:** `domain/entities/Variant.ts`

- Interface `VariantAttribute`: `{ attributeTypeId: ID, name: string, inputType: 'SELECT' | 'TEXT', value: string }`
- Interface `VariantComponentGroup`: `{ componentId: ID, componentName: string, attributes: VariantAttribute[] }`
- Interface `Variant`: `{ id: ID, name: string, productId: ID, product?: { id: ID, nameVn: string }, productStructureId: ID, productStructure?: { id: ID, name: string }, components: VariantComponentGroup[], createdAt: string, createdBy?: ID }`
- Class `VariantEntity` implement `Variant`
- Builder `VariantBuilder`

---

## Step 2 — Domain: Repository Interfaces

**File mới:** `domain/repositories/ComponentTypeRepository.ts`

```typescript
export interface ComponentTypeRepository {
  getAll(params: GetComponentTypesParams): Promise<ComponentType[]>;
  getById(id: ID): Promise<ComponentType>;
  create(input: CreateComponentTypeInput): Promise<ComponentType>;
  update(id: ID, input: UpdateComponentTypeInput): Promise<ComponentType>;
  delete(id: ID): Promise<void>;
}

// Types:
// GetComponentTypesParams: { name?: string, page?: number, limit?: number }
// CreateComponentTypeInput: { name: string, attributeTypes: [{ attributeTypeId: ID, isRequired: boolean }] }
// UpdateComponentTypeInput: Partial<CreateComponentTypeInput>
```

**File mới:** `domain/repositories/AttributeTypeRepository.ts`

```typescript
export interface AttributeTypeRepository {
  getAll(): Promise<AttributeType[]>;
}
```

**File mới:** `domain/repositories/VariantRepository.ts`

```typescript
export interface VariantRepository {
  getAll(params: GetVariantsParams): Promise<PaginationResult<Variant>>;
  getById(id: ID): Promise<Variant>;
  getByStructureId(structureId: ID): Promise<Variant[]>;
  create(input: CreateVariantInput): Promise<Variant>;
  update(id: ID, input: UpdateVariantInput): Promise<Variant>;
  delete(id: ID): Promise<void>;
}

// GetVariantsParams: { name?: string, page?: number, limit?: number }
// CreateVariantInput: { name, productId, productStructureId, attributes: [{ componentId, attributeTypeId, value }] }
// UpdateVariantInput: Partial<CreateVariantInput>
```

---

## Step 3 — Application: Use Cases

### ComponentType

**File mới:** `application/useCases/componentType/GetComponentTypesUseCase.ts`
**Follow pattern:** `application/useCases/orderPackagingMaterial/GetOrderPackagingMaterialsUseCase.ts`

- `execute(args: { name?, page?, limit? })`: inject `ComponentTypeRepository`, call `getAll()`

**File mới:** `application/useCases/componentType/CreateComponentTypeUseCase.ts`

- `execute(args: CreateComponentTypeInput)`: inject `ComponentTypeRepository`, call `create()`

**File mới:** `application/useCases/componentType/UpdateComponentTypeUseCase.ts`

- `execute(args: { id: ID } & UpdateComponentTypeInput)`: call `update(id, input)`

**File mới:** `application/useCases/componentType/DeleteComponentTypeUseCase.ts`

- `execute(args: { id: ID })`: call `delete(id)`

**File mới:** `application/useCases/componentType/GetAttributeTypesUseCase.ts`

- `execute()`: inject `AttributeTypeRepository`, call `getAll()`

### Variant

**File mới:** `application/useCases/variant/GetVariantsUseCase.ts`
**File mới:** `application/useCases/variant/GetVariantUseCase.ts`
**File mới:** `application/useCases/variant/CreateVariantUseCase.ts`
**File mới:** `application/useCases/variant/UpdateVariantUseCase.ts`
**File mới:** `application/useCases/variant/DeleteVariantUseCase.ts`

Pattern cho tất cả: inject repository interface qua DI, delegate to repo method.

---

## Step 4 — Infrastructure: API Repositories

### 4a. ComponentTypeApiRepository

**File mới:** `infrastructure/api/ComponentTypeApiRepository.ts`
**Follow pattern:** `infrastructure/api/OrderPackagingMaterialApiRepository.ts`

- Implements `ComponentTypeRepository`
- Inject `AdminApiService` via `@inject(TYPES.AdminApiService)`
- Base URL: `/component-types`
- Methods:
  - `getAll(params)` → GET `/component-types?name=...&page=...` → `ComponentTypeMapper.toDomainList(resp)`
  - `getById(id)` → GET `/component-types/:id` → `ComponentTypeMapper.toDomain(resp)`
  - `create(input)` → POST `/component-types` → `ComponentTypeMapper.toDomain(resp)`
  - `update(id, input)` → PUT `/component-types/:id` → `ComponentTypeMapper.toDomain(resp)`
  - `delete(id)` → DELETE `/component-types/:id`

### 4b. AttributeTypeApiRepository

**File mới:** `infrastructure/api/AttributeTypeApiRepository.ts`

- `getAll()` → GET `/attribute-types` → `AttributeTypeMapper.toDomainList(resp)`

### 4c. VariantApiRepository

**File mới:** `infrastructure/api/VariantApiRepository.ts`

- `getAll(params)` → GET `/variants?name=...&page=...`
- `getById(id)` → GET `/variants/:id`
- `getByStructureId(id)` → GET `/variants?structureId=:id&limit=100` (cho dropdown)
- `create(input)` → POST `/variants`
- `update(id, input)` → PUT `/variants/:id`
- `delete(id)` → DELETE `/variants/:id`

---

## Step 5 — Infrastructure: Mappers

**File mới:** `infrastructure/api/mapper/ComponentTypeMapper.ts`
**Follow pattern:** `infrastructure/api/mapper/ProductStructureMapper.ts`

```typescript
class ComponentTypeMapper {
  static toDomain(resp: ComponentTypeResponse): ComponentType {
    return new ComponentTypeBuilder()
      .withId(resp.id.toString())
      .withName(resp.name)
      .withAttributeTypes(resp.attributeTypes?.map(a => ({
        attributeTypeId: a.attributeTypeId.toString(),
        name: a.name,
        inputType: a.inputType as 'SELECT' | 'TEXT',
        isRequired: a.isRequired,
        options: a.options ?? [],
      })) ?? [])
      .build()
  }
  static toDomainList(resp: ComponentTypeResponse[]): ComponentType[] { ... }
}
```

**File mới:** `infrastructure/api/mapper/AttributeTypeMapper.ts`

- Static `toDomain(resp)`: map id→string, options array

**File mới:** `infrastructure/api/mapper/VariantMapper.ts`

- Static `toDomain(resp)`: map Variant response → VariantEntity
- Map `resp.components` → `VariantComponentGroup[]`
- `resp.components[].componentId` → `.toString()` (ID is string)

---

## Step 6 — Infrastructure: Error Messages

**File mới:** `infrastructure/api/message/ComponentTypeMessage.ts`

```typescript
export enum ComponentTypeMessageKey {
  NAME_DUPLICATE = "COMPONENT_TYPE_NAME_DUPLICATE",
  REQUIRES_ATTRIBUTE = "COMPONENT_TYPE_REQUIRES_ATTRIBUTE",
  IN_USE = "COMPONENT_TYPE_IN_USE",
  NOT_FOUND = "COMPONENT_TYPE_NOT_FOUND",
}
```

**File mới:** `infrastructure/api/message/VariantMessage.ts`

```typescript
export enum VariantMessageKey {
  NOT_FOUND = "VARIANT_NOT_FOUND",
  REQUIRED_ATTRIBUTE_MISSING = "VARIANT_REQUIRED_ATTRIBUTE_MISSING",
}
```

Thêm cả hai vào `ErrorService.ts` error message map.

---

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

**File sửa:** `di/types.ts`

Thêm symbols:

```typescript
// ComponentType
ComponentTypeRepository: Symbol.for("ComponentTypeRepository"),
GetComponentTypes: Symbol.for("GetComponentTypes"),
CreateComponentType: Symbol.for("CreateComponentType"),
UpdateComponentType: Symbol.for("UpdateComponentType"),
DeleteComponentType: Symbol.for("DeleteComponentType"),

// AttributeType
AttributeTypeRepository: Symbol.for("AttributeTypeRepository"),
GetAttributeTypes: Symbol.for("GetAttributeTypes"),

// Variant
VariantRepository: Symbol.for("VariantRepository"),
GetVariants: Symbol.for("GetVariants"),
GetVariant: Symbol.for("GetVariant"),
CreateVariant: Symbol.for("CreateVariant"),
UpdateVariant: Symbol.for("UpdateVariant"),
DeleteVariant: Symbol.for("DeleteVariant"),
```

**File sửa:** `di/container.ts`

Thêm bindings (singleton scope cho tất cả):

```typescript
container.bind<type ComponentTypeRepository>(TYPES.ComponentTypeRepository)
  .to(ComponentTypeApiRepository).inSingletonScope()
container.bind<type AttributeTypeRepository>(TYPES.AttributeTypeRepository)
  .to(AttributeTypeApiRepository).inSingletonScope()
container.bind<type VariantRepository>(TYPES.VariantRepository)
  .to(VariantApiRepository).inSingletonScope()

container.bind(TYPES.GetComponentTypes).to(GetComponentTypesUseCase).inSingletonScope()
container.bind(TYPES.CreateComponentType).to(CreateComponentTypeUseCase).inSingletonScope()
container.bind(TYPES.UpdateComponentType).to(UpdateComponentTypeUseCase).inSingletonScope()
container.bind(TYPES.DeleteComponentType).to(DeleteComponentTypeUseCase).inSingletonScope()
container.bind(TYPES.GetAttributeTypes).to(GetAttributeTypesUseCase).inSingletonScope()
container.bind(TYPES.GetVariants).to(GetVariantsUseCase).inSingletonScope()
container.bind(TYPES.GetVariant).to(GetVariantUseCase).inSingletonScope()
container.bind(TYPES.CreateVariant).to(CreateVariantUseCase).inSingletonScope()
container.bind(TYPES.UpdateVariant).to(UpdateVariantUseCase).inSingletonScope()
container.bind(TYPES.DeleteVariant).to(DeleteVariantUseCase).inSingletonScope()
```

---

## Step 8 — Presentation: ComponentType Module

Path: `presentation/modules/componentType/`

### 8a. Hooks

**File mới:** `hooks/useGetComponentTypes.ts`
**Follow pattern:** `hooks/useGetOrderPackagingMaterials.ts`

- Query key: `['component-types', params]`
- `enabled: authService.isAuthenticated()`
- Accepts `opts?: UseQueryOptionsCustom<ComponentType[]>`
- Returns `{ data, isLoading, error }`

**File mới:** `hooks/useCreateComponentType.ts`

- Mutation: POST `/component-types`
- `onSuccess`: invalidate `['component-types']`

**File mới:** `hooks/useUpdateComponentType.ts`

- Mutation: PUT `/component-types/:id`

**File mới:** `hooks/useDeleteComponentType.ts`

- Mutation: DELETE `/component-types/:id`
- Guard: nếu response là 422 (`COMPONENT_TYPE_IN_USE`) → toast error thân thiện "Loại component đang được sử dụng, không thể xóa"

**File mới:** `hooks/useGetAttributeTypes.ts`

- Query key: `['attribute-types']`
- Dùng để load danh sách AttributeType trong form tạo ComponentType

**File mới:** `hooks/index.ts` — barrel export tất cả hooks

### 8b. Mappers

**File mới:** `mappers/componentTypeFormMapper.ts`

- `toFormValues(componentType: ComponentType)`: map → form values cho Edit form
- `toCreateInput(values: ComponentTypeFormValues)`: map form → `CreateComponentTypeInput`

### 8c. ComponentType List Page

**File:** `presentation/components/pages/componentType/ComponentTypeListPage/ComponentTypeListPage.tsx`
**Follow pattern:** danh sách trang khác (VD: order list page)

Layout:

- Header: "Cấu hình loại component" + nút "Tạo loại mới"
- `<DataTable>` với columns: Tên | Số thuộc tính | Actions (Edit, Delete)
- Column definitions trong hook `useGetComponentTypeColumns.tsx`
- Empty state: "Chưa có loại component nào"

### 8d. ComponentType Form (Dialog)

**File:** `presentation/modules/componentType/ComponentTypeFormDialog.tsx`
**Follow CLAUDE.md Dialog pattern:**

Form fields:

1. Tên ComponentType: `<Input>` với validation `required, maxLength(255)`
2. Danh sách AttributeType để chọn: multi-select hoặc checkbox list
   - Load từ `useGetAttributeTypes()`
   - Mỗi item: AttributeType name + toggle "Bắt buộc" (checkbox)
   - Validation: ít nhất 1 item phải được chọn
3. Submit → `useCreateComponentType` hoặc `useUpdateComponentType`

Yup schema:

```typescript
yup.object({
  name: yup.string().required("Vui lòng nhập tên loại").max(255),
  attributeTypes: yup
    .array()
    .min(1, "Loại component phải có ít nhất 1 thuộc tính"),
});
```

---

## Step 9 — Presentation: Variant Module

Path: `presentation/modules/variant/`

### 9a. Hooks

**File mới:** `hooks/useGetVariants.ts`

- Query key: `['variants', params]`
- Params: `{ name?, page, limit }`

**File mới:** `hooks/useGetVariant.ts`

- Query key: `['variants', id]`

**File mới:** `hooks/useGetVariantsByStructure.ts`

- Query key: `['variants', 'by-structure', structureId]`
- Dùng cho Variant selector trong OrderItem form
- `enabled: !!structureId && authService.isAuthenticated()`

**File mới:** `hooks/useCreateVariant.ts`

**File mới:** `hooks/useUpdateVariant.ts`

**File mới:** `hooks/useDeleteVariant.ts`

**File mới:** `hooks/index.ts`

### 9b. Mappers

**File mới:** `mappers/variantFormMapper.ts`

- `toFormValues(variant: Variant)`: map Variant → form values (flat structure cho step 2)
- `toCreateInput(step1: Step1Values, step2: Step2Values)`: merge → `CreateVariantInput`
- `toUpdateInput(step2: Step2Values)`: map attributes update

### 9c. Variant List Page

**File:** `presentation/components/pages/variant/VariantListPage/VariantListPage.tsx`

Layout:

- Header: "Variant Management" + nút "Tạo Variant mới"
- Search input (debounce 300ms)
- `<DataTable>` — columns: Tên Variant | Sản phẩm | Kết cấu | Ngày tạo | Người tạo | Actions
- Column hook: `useGetVariantColumns.tsx`
- Empty state: "Chưa có Variant nào. Nhấn 'Tạo Variant mới' để bắt đầu."

### 9d. Variant Create/Edit — Stepper (2 bước)

**File:** `presentation/modules/variant/VariantStepperForm.tsx`

**Bước 1: Thông tin cơ bản**

Fields:

- Tên Variant: `<Input>` required, max 255
- Sản phẩm: `<ReactSelect>` — load từ existing product API
- Kết cấu (ProductStructure): `<ReactSelect>` — load từ existing productStructure API, filter theo productId đã chọn
  - Reset khi productId thay đổi
- Nút "Tiếp tục": disabled cho đến khi cả 3 field valid

Yup bước 1:

```typescript
yup.object({
  name: yup.string().required("Vui lòng nhập tên Variant").max(255),
  product: yup.object().nullable().required("Vui lòng chọn sản phẩm"),
  productStructure: yup.object().nullable().required("Vui lòng chọn kết cấu"),
});
```

**Bước 2: Thuộc tính theo component**

Sau khi bước 1 submit, load:

- `GET /product-structures/:id` để lấy components (with componentTypeId + variantType attributes)
- Hoặc `GET /component-types` để load type config

Render:

- Mỗi component = 1 Card section:
  - Header: tên component
  - Nếu component có `componentTypeId`: render fields theo `attributeTypes` của ComponentType
    - SELECT field → `<ReactSelect>` với `options` từ attributeType
    - TEXT field → `<Input>` max 500 ký tự
    - Required field → dấu (\*) + inline error khi submit thiếu
  - Nếu không có componentTypeId → Badge "Chưa cấu hình loại" (gray, no action)

Nút "Quay lại" → confirm dialog nếu đã có giá trị: "Quay lại sẽ xóa dữ liệu đã nhập. Tiếp tục?"

Yup bước 2: Dynamic schema — với mỗi required attribute → `yup.string().required("Trường này là bắt buộc").max(500)`

### 9e. Variant Detail Page

**File:** `presentation/components/pages/variant/VariantDetailPage/VariantDetailPage.tsx`

Layout:

- Header: tên Variant + nút "Chỉnh sửa"
- Info: Product, ProductStructure, ngày tạo, người tạo
- Accordion/cards: mỗi component = 1 card, attributes dạng key-value pairs (read-only)

---

## Step 10 — Presentation: OrderItem Extensions

### 10a. Variant Selector trong OrderItem Form

**File sửa:** OrderItem form component (locate file theo existing code)

Thêm field:

- Tên field: "Variant" (optional)
- Xuất hiện khi: `productStructureId` đã được chọn
- Component: `<ReactSelect>` combobox với search
  - Load từ `useGetVariantsByStructure(productStructureId)`
  - Option display: `${variant.name}` + preview attributes (tooltip hoặc sub-text)
  - Khi không có kết quả: option "Tạo Variant mới" → link mở tab mới `/variants/new?structureId=X`
- Clear button để unset variant (nullable)

Form value type: `OptionSelect<ID> | null` (theo CLAUDE.md convention)

### 10b. OrderItem Detail — Section "Thông số sản phẩm"

**File sửa:** OrderItem detail component

Thêm section sau các thông tin cơ bản:

```
Section header: "Thông số sản phẩm"

Nếu variantId có:
  - Tên Variant (link đến Variant detail)
  - Cards per component: {componentName} / {attribute: value} (read-only)

Nếu không có variantId:
  - Empty state: "Chưa có thông số sản phẩm"
```

Không cần API call riêng nếu OrderItem detail response đã include `variant` nested data.

---

## Step 11 — Router

**File sửa:** router config (app router file)

Thêm routes:

```
/component-types              → ComponentTypeListPage
/variants                     → VariantListPage
/variants/new                 → VariantCreatePage (VariantStepperForm in create mode)
/variants/:id                 → VariantDetailPage
/variants/:id/edit            → VariantEditPage (VariantStepperForm in edit mode)
```

---

## Checklist

- [ ] `bun type-check` pass (tsc --noEmit)
- [ ] `bun lint` pass
- [ ] `container.get<T>(TYPES.X)` — không dùng `useInjection`
- [ ] Query hooks có `enabled: authService.isAuthenticated()`
- [ ] Mutation hooks là separate files (không gộp)
- [ ] Form trong dialog dùng `useFormStateContext()` để sync isSubmitting lên modal buttons
- [ ] Dialog dùng `<Dialog>` từ `@kingstonvn/ui`, không dùng Reactstrap modal
- [ ] `<FormDialogActions formId={...} onCancel={...} />` truyền vào `actions` prop
- [ ] Select form values typed là `OptionSelect<ID> | null`
- [ ] Numeric IDs từ API response được `.toString()` trong mapper
- [ ] Column definitions trong `useGet*Columns.tsx` hook riêng
- [ ] `hooks/index.ts` barrel export đầy đủ
- [ ] Error message files được thêm vào ErrorService.ts
