---
date: 2026-05-12
type: impl-plan
epic_id: EPIC-001
repo: web-kingston
---

# IMPL-PLAN — EPIC-001 — web-kingston

Kế hoạch implement từng bước cho `web-kingston`. Đọc TECH-DESIGN.md và đảm bảo api-kingston đã deploy endpoints mới trước khi bắt đầu tích hợp.

Tất cả paths trong `packages/main-app/src/clean-architecture/`.

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

```
Step 1: Domain Layer (Entity + Repository interface)
Step 2: Application Layer (Use Cases)
Step 3: Infrastructure Layer (ApiRepository + React Query hooks)
Step 4: DI Wiring
Step 5: Presentation Layer (UI Components)
Step 6: Integrate vào Product Detail Page
```

---

## Step 1: Domain Layer

### 1.1 Entity

**File:** `domain/entities/ProductStructure.ts`

```typescript
export enum EProductStructureStatus {
  ACTIVE = 'ACTIVE',
  INACTIVE = 'INACTIVE',
}

export interface IProductStructure {
  id: number;
  productId: number;
  name: string;
  description?: string;
  status: EProductStructureStatus;
  componentCount?: number;
  createdAt: string;
  updatedAt: string;
}

export interface IStructureComponentTree {
  id: number;
  code: string;
  name: string;
  typeId?: number;
  quantity: number;
  description?: string;
  lengthOfBoxCm?: number;
  widthOfBoxCm?: number;
  lengthOfDetailCm?: number;
  thicknessCm?: number;
  weightKg?: number;
  areaM2?: number;
  children: IStructureComponentTree[];
}

export interface IStructureTree extends IProductStructure {
  componentTrees: IStructureComponentTree[];
}
```

### 1.2 Repository Interface

**File:** `domain/repositories/ProductStructureRepository.ts`

```typescript
import type { IProductStructure, IStructureTree } from '../entities/ProductStructure';

export interface IGetStructuresParams {
  productId: number;
  status?: 'ACTIVE' | 'INACTIVE';
}

export interface ICreateStructureParams {
  productId: number;
  name: string;
  description?: string;
}

export interface IUpdateStructureParams {
  productId: number;
  id: number;
  name?: string;
  description?: string;
}

export interface IAddComponentParams {
  productId: number;
  structureId: number;
  parentCode?: string;
  name: string;
  typeId?: number;
  quantity: number;
  description?: string;
  lengthOfBoxCm?: number;
  widthOfBoxCm?: number;
  lengthOfDetailCm?: number;
  thicknessCm?: number;
}

export interface ProductStructureRepository {
  getList(params: IGetStructuresParams): Promise<IProductStructure[]>;
  create(params: ICreateStructureParams): Promise<IProductStructure>;
  update(params: IUpdateStructureParams): Promise<IProductStructure>;
  deactivate(productId: number, id: number): Promise<IProductStructure>;
  getTree(productId: number, id: number): Promise<IStructureTree>;
  addComponent(params: IAddComponentParams): Promise<void>;
  removeComponent(productId: number, structureId: number, code: string): Promise<{ removedCodes: string[] }>;
}
```

---

## Step 2: Application Layer (Use Cases)

Tất cả files trong `application/useCases/structure/`

### 2.1 `GetStructuresUseCase.ts`

```typescript
import { injectable, inject } from 'inversify';
import { TYPES } from '../../di/types';
import type { ProductStructureRepository } from '../../domain/repositories/ProductStructureRepository';

@injectable()
export class GetStructuresUseCase {
  constructor(
    @inject(TYPES.ProductStructureRepository)
    private readonly repo: ProductStructureRepository,
  ) {}

  async execute(productId: number, status?: 'ACTIVE' | 'INACTIVE') {
    return this.repo.getList({ productId, status });
  }
}
```

### 2.2 `CreateStructureUseCase.ts`

```typescript
@injectable()
export class CreateStructureUseCase {
  constructor(
    @inject(TYPES.ProductStructureRepository)
    private readonly repo: ProductStructureRepository,
  ) {}

  async execute(params: ICreateStructureParams) {
    return this.repo.create(params);
  }
}
```

### 2.3 `DeactivateStructureUseCase.ts`

```typescript
@injectable()
export class DeactivateStructureUseCase {
  async execute(productId: number, id: number) {
    return this.repo.deactivate(productId, id);
  }
}
```

### 2.4 `GetStructureTreeUseCase.ts`

```typescript
@injectable()
export class GetStructureTreeUseCase {
  async execute(productId: number, structureId: number) {
    return this.repo.getTree(productId, structureId);
  }
}
```

---

## Step 3: Infrastructure Layer

### 3.1 `ProductStructureApiRepository.ts`

**File:** `infrastructure/api/ProductStructureApiRepository.ts`

```typescript
import { injectable, inject } from 'inversify';
import { TYPES } from '../../di/types';
import type { IHttpClient } from '../http/IHttpClient';
import type { ProductStructureRepository } from '../../domain/repositories/ProductStructureRepository';
import type { IProductStructure, IStructureTree } from '../../domain/entities/ProductStructure';

@injectable()
export class ProductStructureApiRepository implements ProductStructureRepository {
  constructor(
    @inject(TYPES.HttpClient)
    private readonly http: IHttpClient,
  ) {}

  async getList({ productId, status }: IGetStructuresParams): Promise<IProductStructure[]> {
    const params = status ? `?status=${status}` : '';
    const res = await this.http.get<{ result: IProductStructure[] }>(
      `/products/${productId}/structures${params}`
    );
    return res.result;
  }

  async create(params: ICreateStructureParams): Promise<IProductStructure> {
    return this.http.post<IProductStructure>(
      `/products/${params.productId}/structures`,
      { name: params.name, description: params.description }
    );
  }

  async update({ productId, id, ...body }: IUpdateStructureParams): Promise<IProductStructure> {
    return this.http.patch<IProductStructure>(
      `/products/${productId}/structures/${id}`,
      body
    );
  }

  async deactivate(productId: number, id: number): Promise<IProductStructure> {
    return this.http.patch<IProductStructure>(
      `/products/${productId}/structures/${id}/deactivate`,
      {}
    );
  }

  async getTree(productId: number, id: number): Promise<IStructureTree> {
    return this.http.get<IStructureTree>(
      `/products/${productId}/structures/${id}/tree`
    );
  }

  async addComponent(params: IAddComponentParams): Promise<void> {
    const { productId, structureId, ...body } = params;
    await this.http.post(
      `/products/${productId}/structures/${structureId}/components`,
      body
    );
  }

  async removeComponent(productId: number, structureId: number, code: string): Promise<{ removedCodes: string[] }> {
    return this.http.delete(
      `/products/${productId}/structures/${structureId}/components/${code}`
    );
  }
}
```

### 3.2 React Query Hooks

**File:** `infrastructure/react-query/useGetStructures.ts`
```typescript
import { useQuery } from '@tanstack/react-query';
import { useInjection } from 'inversify-react';
import { TYPES } from '../../di/types';
import type { ProductStructureRepository } from '../../domain/repositories/ProductStructureRepository';

export const structureKeys = {
  all: (productId: number) => ['structures', productId] as const,
  list: (productId: number, status?: string) => [...structureKeys.all(productId), status] as const,
  tree: (productId: number, id: number) => [...structureKeys.all(productId), 'tree', id] as const,
};

export const useGetStructures = (productId: number, status?: 'ACTIVE' | 'INACTIVE') => {
  const repo = useInjection<ProductStructureRepository>(TYPES.ProductStructureRepository);
  return useQuery({
    queryKey: structureKeys.list(productId, status),
    queryFn: () => repo.getList({ productId, status }),
    enabled: !!productId,
    staleTime: 30_000,
  });
};
```

**File:** `infrastructure/react-query/useCreateStructure.ts`
```typescript
export const useCreateStructure = (productId: number) => {
  const repo = useInjection<ProductStructureRepository>(TYPES.ProductStructureRepository);
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (params: Omit<ICreateStructureParams, 'productId'>) =>
      repo.create({ productId, ...params }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: structureKeys.all(productId) });
    },
  });
};
```

**File:** `infrastructure/react-query/useDeactivateStructure.ts`
```typescript
export const useDeactivateStructure = (productId: number) => {
  const repo = useInjection<ProductStructureRepository>(TYPES.ProductStructureRepository);
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (id: number) => repo.deactivate(productId, id),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: structureKeys.all(productId) });
    },
  });
};
```

**File:** `infrastructure/react-query/useGetStructureTree.ts`
```typescript
export const useGetStructureTree = (productId: number, structureId: number | null) => {
  const repo = useInjection<ProductStructureRepository>(TYPES.ProductStructureRepository);
  return useQuery({
    queryKey: structureKeys.tree(productId, structureId!),
    queryFn: () => repo.getTree(productId, structureId!),
    enabled: !!productId && !!structureId,
    staleTime: 30_000,
  });
};
```

**File:** `infrastructure/react-query/useAddStructureComponent.ts`
```typescript
export const useAddStructureComponent = (productId: number, structureId: number) => {
  const repo = useInjection<ProductStructureRepository>(TYPES.ProductStructureRepository);
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (params: Omit<IAddComponentParams, 'productId' | 'structureId'>) =>
      repo.addComponent({ productId, structureId, ...params }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: structureKeys.tree(productId, structureId) });
      queryClient.invalidateQueries({ queryKey: structureKeys.list(productId) }); // componentCount thay đổi
    },
  });
};
```

**File:** `infrastructure/react-query/useRemoveStructureComponent.ts`
```typescript
export const useRemoveStructureComponent = (productId: number, structureId: number) => {
  const repo = useInjection<ProductStructureRepository>(TYPES.ProductStructureRepository);
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (code: string) => repo.removeComponent(productId, structureId, code),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: structureKeys.tree(productId, structureId) });
      queryClient.invalidateQueries({ queryKey: structureKeys.list(productId) });
    },
  });
};
```

---

## Step 4: DI Wiring

### 4.1 `di/types.ts` — thêm token

```typescript
export const TYPES = {
  // ... existing ...
  ProductStructureRepository: Symbol.for('ProductStructureRepository'),
};
```

### 4.2 `di/container.ts` — thêm binding

```typescript
import { ProductStructureApiRepository } from '../infrastructure/api/ProductStructureApiRepository';

// trong configure():
container
  .bind<ProductStructureRepository>(TYPES.ProductStructureRepository)
  .to(ProductStructureApiRepository)
  .inSingletonScope();
```

---

## Step 5: Presentation Layer

### 5.1 Form tạo/sửa Structure

**File:** `presentation/forms/product/CreateStructureForm.tsx`

```tsx
import { useForm, Controller } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import { Input, Textarea } from '@kingstonvn/ui';
import { useFormStateContext } from '../../components/organisms';

const schema = yup.object({
  name: yup.string().required('Tên kết cấu là bắt buộc').max(100, 'Tối đa 100 ký tự'),
  description: yup.string().optional(),
});

type FormValues = yup.InferType<typeof schema>;

interface Props {
  formId: string;
  defaultValues?: Partial<FormValues>;
  onSubmit: (values: FormValues) => Promise<void>;
}

export const CreateStructureForm = ({ formId, defaultValues, onSubmit }: Props) => {
  const { setIsSubmitting } = useFormStateContext();
  const { control, handleSubmit, formState: { errors } } = useForm<FormValues>({
    resolver: yupResolver(schema),
    defaultValues: defaultValues ?? { name: '', description: '' },
  });

  const handleFormSubmit = async (values: FormValues) => {
    setIsSubmitting(true);
    try {
      await onSubmit(values);
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form id={formId} onSubmit={handleSubmit(handleFormSubmit)} className="flex flex-col gap-4">
      <Controller
        name="name"
        control={control}
        render={({ field }) => (
          <Input
            {...field}
            label="Tên kết cấu"
            isRequired
            maxLength={100}
            isInvalid={!!errors.name}
            description={errors.name?.message}
          />
        )}
      />
      <Controller
        name="description"
        control={control}
        render={({ field }) => (
          <Textarea
            {...field}
            label="Mô tả"
            isInvalid={!!errors.description}
            description={errors.description?.message}
          />
        )}
      />
    </form>
  );
};
```

### 5.2 StructureList component

**File:** `presentation/modules/product/components/StructureList.tsx`

```tsx
import { DataTable } from '@kingstonvn/ui';
import { IProductStructure, EProductStructureStatus } from '../../../../domain/entities/ProductStructure';
import { useGetStructures } from '../../../../infrastructure/react-query/useGetStructures';
import { useDeactivateStructure } from '../../../../infrastructure/react-query/useDeactivateStructure';

interface Props {
  productId: number;
  canEdit: boolean;
  onSelectStructure: (structure: IProductStructure) => void;
  onCreateClick: () => void;
}

export const StructureList = ({ productId, canEdit, onSelectStructure, onCreateClick }: Props) => {
  const [showInactive, setShowInactive] = useState(false);
  const { data: structures = [], isLoading } = useGetStructures(
    productId,
    showInactive ? undefined : 'ACTIVE'
  );
  const deactivate = useDeactivateStructure(productId);

  const handleDeactivate = async (structure: IProductStructure) => {
    if (!confirm(`Bạn có chắc muốn tắt kết cấu "${structure.name}"?`)) return;
    await deactivate.mutateAsync(structure.id);
  };

  // Columns definition
  const columns = [
    { key: 'name', label: 'Tên kết cấu' },
    { key: 'description', label: 'Mô tả' },
    { key: 'componentCount', label: 'Số cụm/chi tiết' },
    {
      key: 'status',
      label: 'Trạng thái',
      render: (row: IProductStructure) => (
        <span className={row.status === EProductStructureStatus.ACTIVE ? 'text-green-600' : 'text-gray-400'}>
          {row.status === EProductStructureStatus.ACTIVE ? 'Hoạt động' : 'Đã tắt'}
        </span>
      ),
    },
    // Actions column (canEdit only)
  ];

  return (
    <div className="flex flex-col gap-4">
      <div className="flex items-center justify-between">
        {canEdit && (
          <button onClick={onCreateClick} className="btn btn-primary">
            + Thêm kết cấu
          </button>
        )}
        <label className="flex items-center gap-2 text-sm">
          <input type="checkbox" checked={showInactive} onChange={e => setShowInactive(e.target.checked)} />
          Xem cả INACTIVE
        </label>
      </div>

      {isLoading ? (
        <div>Đang tải...</div>
      ) : structures.length === 0 ? (
        <div className="text-center py-8 text-gray-500">
          {canEdit
            ? <><p>Sản phẩm này chưa có kết cấu nào.</p><button onClick={onCreateClick}>Thêm kết cấu</button></>
            : <p>Sản phẩm này chưa có kết cấu nào. Liên hệ kỹ thuật viên để tạo kết cấu.</p>
          }
        </div>
      ) : (
        <DataTable
          rows={structures}
          columns={columns}
          onRowClick={onSelectStructure}
        />
      )}
    </div>
  );
};
```

### 5.3 StructureTreePanel component

**File:** `presentation/modules/product/components/StructureTreePanel.tsx`

```tsx
import { IStructureTree, IStructureComponentTree } from '../../../../domain/entities/ProductStructure';
import { useGetStructureTree } from '../../../../infrastructure/react-query/useGetStructureTree';
import { useAddStructureComponent } from '../../../../infrastructure/react-query/useAddStructureComponent';
import { useRemoveStructureComponent } from '../../../../infrastructure/react-query/useRemoveStructureComponent';

interface Props {
  productId: number;
  structureId: number;
  canEdit: boolean;
  onClose: () => void;
}

export const StructureTreePanel = ({ productId, structureId, canEdit, onClose }: Props) => {
  const { data: tree, isLoading } = useGetStructureTree(productId, structureId);
  const addComponent = useAddStructureComponent(productId, structureId);
  const removeComponent = useRemoveStructureComponent(productId, structureId);

  const handleRemove = async (node: IStructureComponentTree) => {
    const childCount = countChildren(node);
    const confirm = childCount > 0
      ? window.confirm(`Xóa node này sẽ xóa ${childCount} node con. Bạn có chắc không?`)
      : window.confirm('Bạn có chắc muốn xóa node này?');
    if (!confirm) return;
    await removeComponent.mutateAsync(node.code);
  };

  if (isLoading) return <div>Đang tải cây kết cấu...</div>;
  if (!tree) return null;

  return (
    <div className="border rounded-lg p-4">
      <div className="flex items-center justify-between mb-4">
        <h3 className="font-semibold">{tree.name}</h3>
        <button onClick={onClose}>✕</button>
      </div>

      {canEdit && (
        <div className="flex gap-2 mb-4">
          <button onClick={() => addComponent.mutate({ name: 'Cụm mới', quantity: 1 })}>
            + Thêm cụm
          </button>
          <button onClick={() => addComponent.mutate({ name: 'Chi tiết mới', quantity: 1 })}>
            + Thêm chi tiết
          </button>
        </div>
      )}

      <ComponentTreeView
        nodes={tree.componentTrees}
        canEdit={canEdit}
        onRemove={handleRemove}
        onAddChild={(parentCode) => addComponent.mutate({ parentCode, name: 'Node mới', quantity: 1 })}
      />
    </div>
  );
};

const ComponentTreeView = ({ nodes, canEdit, onRemove, onAddChild, depth = 0 }: TreeViewProps) => (
  <ul className={`ml-${depth * 4}`}>
    {nodes.map(node => (
      <li key={node.code} className="py-1">
        <div className="flex items-center gap-2 group">
          <span className="text-gray-400 text-sm">{node.code}</span>
          <span>{node.name}</span>
          {canEdit && (
            <div className="hidden group-hover:flex gap-1">
              <button onClick={() => onAddChild(node.code)} title="Thêm con">+</button>
              <button onClick={() => onRemove(node)} title="Xóa">✕</button>
            </div>
          )}
        </div>
        {node.children.length > 0 && (
          <ComponentTreeView nodes={node.children} canEdit={canEdit} onRemove={onRemove} onAddChild={onAddChild} depth={depth + 1} />
        )}
      </li>
    ))}
  </ul>
);

function countChildren(node: IStructureComponentTree): number {
  return node.children.reduce((sum, c) => sum + 1 + countChildren(c), 0);
}
```

### 5.4 StructureTab container

**File:** `presentation/modules/product/components/StructureTab.tsx`

```tsx
import { useState } from 'react';
import { StructureList } from './StructureList';
import { StructureTreePanel } from './StructureTreePanel';
import { Dialog, FormStateProvider, FormDialogActions } from '@kingstonvn/ui';
import { CreateStructureForm } from '../../../forms/product/CreateStructureForm';
import { useCreateStructure } from '../../../../infrastructure/react-query/useCreateStructure';
import type { IProductStructure } from '../../../../domain/entities/ProductStructure';

interface Props {
  productId: number;
  canEdit: boolean; // true nếu user có role KTV/Admin
}

export const StructureTab = ({ productId, canEdit }: Props) => {
  const [selectedStructure, setSelectedStructure] = useState<IProductStructure | null>(null);
  const [showCreateModal, setShowCreateModal] = useState(false);
  const createStructure = useCreateStructure(productId);

  const handleCreate = async (values: { name: string; description?: string }) => {
    await createStructure.mutateAsync(values);
    setShowCreateModal(false);
  };

  return (
    <div className="flex gap-4">
      <div className="flex-1">
        <StructureList
          productId={productId}
          canEdit={canEdit}
          onSelectStructure={setSelectedStructure}
          onCreateClick={() => setShowCreateModal(true)}
        />
      </div>

      {selectedStructure && (
        <div className="w-96">
          <StructureTreePanel
            productId={productId}
            structureId={selectedStructure.id}
            canEdit={canEdit}
            onClose={() => setSelectedStructure(null)}
          />
        </div>
      )}

      <Dialog
        isOpen={showCreateModal}
        onClose={() => setShowCreateModal(false)}
        title="Thêm kết cấu mới"
        actions={
          <FormStateProvider>
            <FormDialogActions formId="create-structure-form" onCancel={() => setShowCreateModal(false)} />
          </FormStateProvider>
        }
      >
        <FormStateProvider>
          <CreateStructureForm
            formId="create-structure-form"
            onSubmit={handleCreate}
          />
        </FormStateProvider>
      </Dialog>
    </div>
  );
};
```

---

## Step 6: Integrate vào Product Detail Page

### 6.1 Tìm Product Detail Page

Tìm file component product detail hiện tại:
```bash
find packages/main-app/src -name "*.tsx" | xargs grep -l "ProductDetail\|product-detail\|tab.*bom" | head -5
```

### 6.2 Thêm tab "Kết cấu"

Trong Product Detail page, thêm tab mới vào tab bar:
```tsx
import { StructureTab } from '../modules/product/components/StructureTab';

// Trong tabs definition:
const tabs = [
  { key: 'info', label: 'Thông tin', content: <ProductInfoTab ... /> },
  { key: 'bom', label: 'BOM', content: <BomTab ... /> },
  { key: 'structure', label: 'Kết cấu', content: (
    <StructureTab
      productId={product.id}
      canEdit={currentUser.hasRole(['KTV', 'ADMIN'])}
    />
  )},
];
```

---

## Checklist trước khi PR

- [ ] `bun type-check` không có lỗi TypeScript
- [ ] `bun lint` sạch
- [ ] Tab "Kết cấu" hiển thị đúng trong Product Detail
- [ ] Empty state đúng theo role (KTV vs QLSX/KD)
- [ ] Tạo Structure → xuất hiện ngay trong list
- [ ] Deactivate Structure duy nhất → hiển thị error message đúng
- [ ] Cây component render đúng hierarchy
- [ ] Thêm node vào cây → tree cập nhật
- [ ] Xóa node có con → cảnh báo; xóa node lá → không cảnh báo
- [ ] Keyboard navigation: Tab qua các nút, Enter kích hoạt, Escape đóng modal
- [ ] User QLSX/KD không thấy nút tạo/sửa/xóa
