---
date: 2026-05-21
type: impl-plan
epic_id: EPIC-004
repo: web-kingston
status: draft
---

# Implementation Plan — EPIC-004 / web-kingston

## Prerequisites
- API đã deploy và endpoint `/structures/:id/materials` hoạt động
- Hiểu `packages/main-app/src/clean-architecture/` — đây là root của toàn bộ code

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

```
1. Domain entity + repository interface
2. Application use cases
3. Infrastructure (response types + mapper + API repository)
4. DI wiring (types + container)
5. Form schema
6. Module hooks
7. Module components (Table + FormModal)
8. Tab page component
9. Layout + Route
```

---

## Step 1 — Domain: Entity & Repository Interface

**File:** `src/clean-architecture/domain/entities/StructureMaterial.ts`

```typescript
export interface StructureMaterialMaterialUnit {
  id: ID;
  name: string;
}

export interface StructureMaterialMaterial {
  id: ID;
  code: string;
  name: string;
  unit?: StructureMaterialMaterialUnit | null;
}

export interface StructureMaterial {
  id: ID;
  structureId: ID;
  materialId: ID;
  assemblyQty: number | null;
  packagingQty: number | null;
  material?: StructureMaterialMaterial;
  createdAt?: string;
  updatedAt?: string;
}

export interface CreateStructureMaterialInput {
  materialId: ID;
  assemblyQty?: number | null;
  packagingQty?: number | null;
}

export interface UpdateStructureMaterialInput {
  assemblyQty?: number | null;
  packagingQty?: number | null;
}

export interface IStructureMaterialRepository {
  getStructureMaterials(structureId: ID): Promise<StructureMaterial[]>;
  createStructureMaterial(structureId: ID, input: CreateStructureMaterialInput): Promise<StructureMaterial>;
  updateStructureMaterial(structureId: ID, id: ID, input: UpdateStructureMaterialInput): Promise<StructureMaterial>;
  deleteStructureMaterial(structureId: ID, id: ID): Promise<{ id: ID }>;
}
```

---

## Step 2 — Application: Use Cases

### 2a. GetStructureMaterials

**File:** `src/clean-architecture/application/useCases/structureMaterial/GetStructureMaterials.ts`

```typescript
import { inject, injectable } from "inversify";
import { TYPES } from "clean-architecture/di/types";
import type { IStructureMaterialRepository } from "clean-architecture/domain/entities/StructureMaterial";
import type { StructureMaterial } from "clean-architecture/domain/entities/StructureMaterial";

@injectable()
export class GetStructureMaterials {
  constructor(
    @inject(TYPES.StructureMaterialRepository)
    private readonly repository: IStructureMaterialRepository,
  ) {}

  async execute(structureId: ID): Promise<StructureMaterial[]> {
    return this.repository.getStructureMaterials(structureId);
  }
}
```

### 2b. CreateStructureMaterial

**File:** `src/clean-architecture/application/useCases/structureMaterial/CreateStructureMaterial.ts`

```typescript
import { inject, injectable } from "inversify";
import { TYPES } from "clean-architecture/di/types";
import type { IStructureMaterialRepository, CreateStructureMaterialInput, StructureMaterial } from "clean-architecture/domain/entities/StructureMaterial";

@injectable()
export class CreateStructureMaterial {
  constructor(
    @inject(TYPES.StructureMaterialRepository)
    private readonly repository: IStructureMaterialRepository,
  ) {}

  async execute(structureId: ID, input: CreateStructureMaterialInput): Promise<StructureMaterial> {
    return this.repository.createStructureMaterial(structureId, input);
  }
}
```

### 2c. UpdateStructureMaterial

**File:** `src/clean-architecture/application/useCases/structureMaterial/UpdateStructureMaterial.ts`

```typescript
import { inject, injectable } from "inversify";
import { TYPES } from "clean-architecture/di/types";
import type { IStructureMaterialRepository, UpdateStructureMaterialInput, StructureMaterial } from "clean-architecture/domain/entities/StructureMaterial";

@injectable()
export class UpdateStructureMaterial {
  constructor(
    @inject(TYPES.StructureMaterialRepository)
    private readonly repository: IStructureMaterialRepository,
  ) {}

  async execute(structureId: ID, id: ID, input: UpdateStructureMaterialInput): Promise<StructureMaterial> {
    return this.repository.updateStructureMaterial(structureId, id, input);
  }
}
```

### 2d. DeleteStructureMaterial

**File:** `src/clean-architecture/application/useCases/structureMaterial/DeleteStructureMaterial.ts`

```typescript
import { inject, injectable } from "inversify";
import { TYPES } from "clean-architecture/di/types";
import type { IStructureMaterialRepository } from "clean-architecture/domain/entities/StructureMaterial";

@injectable()
export class DeleteStructureMaterial {
  constructor(
    @inject(TYPES.StructureMaterialRepository)
    private readonly repository: IStructureMaterialRepository,
  ) {}

  async execute(structureId: ID, id: ID): Promise<{ id: ID }> {
    return this.repository.deleteStructureMaterial(structureId, id);
  }
}
```

---

## Step 3 — Infrastructure

### 3a. Response Types

**File:** `src/clean-architecture/infrastructure/api/types/StructureMaterialResponse.ts`

```typescript
export interface StructureMaterialUnitResponse {
  id: number;
  name: string;
}

export interface StructureMaterialMaterialResponse {
  id: number;
  code: string;
  name: string;
  unit?: StructureMaterialUnitResponse | null;
}

export interface StructureMaterialResponse {
  id: number;
  structureId: number;
  materialId: number;
  assemblyQty: number | null;
  packagingQty: number | null;
  material?: StructureMaterialMaterialResponse;
  createdAt: string;
  updatedAt: string;
}
```

### 3b. Mapper

**File:** `src/clean-architecture/infrastructure/api/mapper/StructureMaterialMapper.ts`

```typescript
import type { StructureMaterial } from "clean-architecture/domain/entities/StructureMaterial";
import type { StructureMaterialResponse } from "clean-architecture/infrastructure/api/types/StructureMaterialResponse";

export class StructureMaterialMapper {
  static toDomain(response: StructureMaterialResponse): StructureMaterial {
    return {
      id: response.id,
      structureId: response.structureId,
      materialId: response.materialId,
      assemblyQty: response.assemblyQty,
      packagingQty: response.packagingQty,
      material: response.material
        ? {
            id: response.material.id,
            code: response.material.code,
            name: response.material.name,
            unit: response.material.unit ?? null,
          }
        : undefined,
      createdAt: response.createdAt,
      updatedAt: response.updatedAt,
    };
  }

  static toDomainList(responses: StructureMaterialResponse[]): StructureMaterial[] {
    return responses.map(StructureMaterialMapper.toDomain);
  }
}
```

### 3c. API Repository

**File:** `src/clean-architecture/infrastructure/api/StructureMaterialApiRepository.ts`

```typescript
import { inject, injectable } from "inversify";
import { TYPES } from "clean-architecture/di/types";
import type { IStructureMaterialRepository, StructureMaterial, CreateStructureMaterialInput, UpdateStructureMaterialInput } from "clean-architecture/domain/entities/StructureMaterial";
import { StructureMaterialMapper } from "clean-architecture/infrastructure/api/mapper/StructureMaterialMapper";
import type { StructureMaterialResponse } from "clean-architecture/infrastructure/api/types/StructureMaterialResponse";

interface ApiService {
  get<T>(url: string, config?: unknown): Promise<T>;
  post<T>(url: string, body?: unknown): Promise<T>;
  put<T>(url: string, body?: unknown): Promise<T>;
  delete<T>(url: string): Promise<T>;
}

@injectable()
export class StructureMaterialApiRepository implements IStructureMaterialRepository {
  constructor(
    @inject(TYPES.AdminApiService)
    private readonly apiService: ApiService,
  ) {}

  private baseUrl(structureId: ID): string {
    return `/structures/${structureId}/materials`;
  }

  async getStructureMaterials(structureId: ID): Promise<StructureMaterial[]> {
    const resp = await this.apiService.get<StructureMaterialResponse[]>(this.baseUrl(structureId));
    return StructureMaterialMapper.toDomainList(resp);
  }

  async createStructureMaterial(structureId: ID, input: CreateStructureMaterialInput): Promise<StructureMaterial> {
    const resp = await this.apiService.post<StructureMaterialResponse>(this.baseUrl(structureId), input);
    return StructureMaterialMapper.toDomain(resp);
  }

  async updateStructureMaterial(structureId: ID, id: ID, input: UpdateStructureMaterialInput): Promise<StructureMaterial> {
    const resp = await this.apiService.put<StructureMaterialResponse>(`${this.baseUrl(structureId)}/${id}`, input);
    return StructureMaterialMapper.toDomain(resp);
  }

  async deleteStructureMaterial(structureId: ID, id: ID): Promise<{ id: ID }> {
    return this.apiService.delete<{ id: ID }>(`${this.baseUrl(structureId)}/${id}`);
  }
}
```

> **Lưu ý:** Xem `MaterialApiRepository.ts` hoặc `ProductStructureApiRepository.ts` để biết tên inject token cho `AdminApiService` (thường là `TYPES.AdminApiService` hoặc `TYPES.ApiService`). Align với pattern đang dùng.

---

## Step 4 — DI Wiring

### 4a. types.ts

**Sửa** `src/clean-architecture/di/types.ts` — thêm sau section `// Material`:

```typescript
// Structure Material
StructureMaterialRepository: Symbol.for("StructureMaterialRepository"),
GetStructureMaterials: Symbol.for("GetStructureMaterials"),
CreateStructureMaterial: Symbol.for("CreateStructureMaterial"),
UpdateStructureMaterial: Symbol.for("UpdateStructureMaterial"),
DeleteStructureMaterial: Symbol.for("DeleteStructureMaterial"),
```

### 4b. container.ts

**Sửa** `src/clean-architecture/di/container.ts` — thêm bindings:

```typescript
import { StructureMaterialApiRepository } from "clean-architecture/infrastructure/api/StructureMaterialApiRepository";
import { GetStructureMaterials } from "clean-architecture/application/useCases/structureMaterial/GetStructureMaterials";
import { CreateStructureMaterial } from "clean-architecture/application/useCases/structureMaterial/CreateStructureMaterial";
import { UpdateStructureMaterial } from "clean-architecture/application/useCases/structureMaterial/UpdateStructureMaterial";
import { DeleteStructureMaterial } from "clean-architecture/application/useCases/structureMaterial/DeleteStructureMaterial";

// ... thêm bindings:
container.bind<IStructureMaterialRepository>(TYPES.StructureMaterialRepository)
  .to(StructureMaterialApiRepository).inSingletonScope();
container.bind<GetStructureMaterials>(TYPES.GetStructureMaterials)
  .to(GetStructureMaterials).inSingletonScope();
container.bind<CreateStructureMaterial>(TYPES.CreateStructureMaterial)
  .to(CreateStructureMaterial).inSingletonScope();
container.bind<UpdateStructureMaterial>(TYPES.UpdateStructureMaterial)
  .to(UpdateStructureMaterial).inSingletonScope();
container.bind<DeleteStructureMaterial>(TYPES.DeleteStructureMaterial)
  .to(DeleteStructureMaterial).inSingletonScope();
```

---

## Step 5 — Form Schema

**File:** `src/clean-architecture/presentation/forms/structureMaterial/StructureMaterialFormValues.ts`

```typescript
import * as yup from "yup";

export interface StructureMaterialFormValues {
  materialId: number | null;
  assemblyQty: number | null;
  packagingQty: number | null;
}

export const structureMaterialSchema = yup.object({
  materialId: yup.number().required("Vui lòng chọn vật tư").nullable(),
  assemblyQty: yup
    .number()
    .nullable()
    .min(0.01, "Số lượng phải lớn hơn 0")
    .optional(),
  packagingQty: yup
    .number()
    .nullable()
    .min(0.01, "Số lượng phải lớn hơn 0")
    .optional(),
}).test(
  "at-least-one-qty",
  "Vui lòng nhập ít nhất một số lượng",
  (values) => values.assemblyQty != null || values.packagingQty != null,
);

export const structureMaterialUpdateSchema = yup.object({
  assemblyQty: yup.number().nullable().min(0.01, "Số lượng phải lớn hơn 0").optional(),
  packagingQty: yup.number().nullable().min(0.01, "Số lượng phải lớn hơn 0").optional(),
}).test(
  "at-least-one-qty",
  "Vui lòng nhập ít nhất một số lượng",
  (values) => values.assemblyQty != null || values.packagingQty != null,
);
```

---

## Step 6 — Module Hooks

### 6a. useGetStructureMaterials

**File:** `src/clean-architecture/presentation/modules/structureMaterial/hooks/useGetStructureMaterials.ts`

```typescript
import { useInjection } from "inversify-react";
import { useQuery } from "@tanstack/react-query";
import { TYPES } from "clean-architecture/di/types";
import type { GetStructureMaterials } from "clean-architecture/application/useCases/structureMaterial/GetStructureMaterials";

export const STRUCTURE_MATERIALS_QUERY_KEY = (structureId: ID) =>
  ["structure-materials", structureId] as const;

export const useGetStructureMaterials = (structureId: ID | undefined) => {
  const useCase = useInjection<GetStructureMaterials>(TYPES.GetStructureMaterials);

  const { data, isLoading, refetch } = useQuery({
    queryKey: STRUCTURE_MATERIALS_QUERY_KEY(structureId!),
    queryFn: () => useCase.execute(structureId!),
    enabled: !!structureId,
    staleTime: 5 * 60 * 1000,
  });

  return {
    structureMaterials: data ?? [],
    isStructureMaterialsLoading: isLoading,
    refetchStructureMaterials: refetch,
  };
};
```

### 6b. useStructureMaterialActions

**File:** `src/clean-architecture/presentation/modules/structureMaterial/hooks/useStructureMaterialActions.ts`

```typescript
import { useInjection } from "inversify-react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { TYPES } from "clean-architecture/di/types";
import type { CreateStructureMaterial } from "clean-architecture/application/useCases/structureMaterial/CreateStructureMaterial";
import type { UpdateStructureMaterial } from "clean-architecture/application/useCases/structureMaterial/UpdateStructureMaterial";
import type { DeleteStructureMaterial } from "clean-architecture/application/useCases/structureMaterial/DeleteStructureMaterial";
import { STRUCTURE_MATERIALS_QUERY_KEY } from "./useGetStructureMaterials";
import type { CreateStructureMaterialInput, UpdateStructureMaterialInput } from "clean-architecture/domain/entities/StructureMaterial";

export const useStructureMaterialActions = (structureId: ID) => {
  const queryClient = useQueryClient();
  const createUseCase = useInjection<CreateStructureMaterial>(TYPES.CreateStructureMaterial);
  const updateUseCase = useInjection<UpdateStructureMaterial>(TYPES.UpdateStructureMaterial);
  const deleteUseCase = useInjection<DeleteStructureMaterial>(TYPES.DeleteStructureMaterial);

  const invalidate = () =>
    queryClient.invalidateQueries({ queryKey: STRUCTURE_MATERIALS_QUERY_KEY(structureId) });

  const createMutation = useMutation({
    mutationFn: (input: CreateStructureMaterialInput) =>
      createUseCase.execute(structureId, input),
    onSuccess: invalidate,
  });

  const updateMutation = useMutation({
    mutationFn: ({ id, input }: { id: ID; input: UpdateStructureMaterialInput }) =>
      updateUseCase.execute(structureId, id, input),
    onSuccess: invalidate,
  });

  const deleteMutation = useMutation({
    mutationFn: (id: ID) => deleteUseCase.execute(structureId, id),
    onSuccess: invalidate,
  });

  return {
    handleCreate: createMutation.mutateAsync,
    handleUpdate: updateMutation.mutateAsync,
    handleDelete: deleteMutation.mutateAsync,
    isCreating: createMutation.isPending,
    isUpdating: updateMutation.isPending,
    isDeleting: deleteMutation.isPending,
  };
};
```

---

## Step 7 — Module Components

### 7a. StructureMaterialTable

**File:** `src/clean-architecture/presentation/modules/structureMaterial/components/StructureMaterialTable/StructureMaterialTable.tsx`

```typescript
import { DataTable } from "@kingstonvn/ui";
import type { StructureMaterial } from "clean-architecture/domain/entities/StructureMaterial";

interface Props {
  data: StructureMaterial[];
  isLoading: boolean;
  canEdit: boolean;
  onEdit: (item: StructureMaterial) => void;
  onDelete: (item: StructureMaterial) => void;
}

export const StructureMaterialTable: FC<Props> = ({
  data, isLoading, canEdit, onEdit, onDelete
}) => {
  const columns = [
    { key: "material.code", header: "Mã vật tư", render: (row) => row.material?.code ?? "—" },
    { key: "material.name", header: "Tên vật tư", render: (row) => row.material?.name ?? "—" },
    { key: "assemblyQty", header: "SL Lắp ráp", render: (row) => row.assemblyQty ?? "—" },
    { key: "packagingQty", header: "SL Đóng gói", render: (row) => row.packagingQty ?? "—" },
    { key: "unit", header: "Đơn vị", render: (row) => row.material?.unit?.name ?? "—" },
    ...(canEdit
      ? [{
          key: "actions",
          header: "",
          render: (row) => (
            <div className="flex gap-2">
              <button onClick={() => onEdit(row)}>✏️</button>
              <button onClick={() => onDelete(row)}>🗑️</button>
            </div>
          ),
        }]
      : []),
  ];

  return (
    <DataTable
      data={data}
      columns={columns}
      loading={isLoading}
      emptyText="Chưa có vật tư nào. Nhấn '+ Thêm vật tư' để bắt đầu."
    />
  );
};
```

**File:** `src/clean-architecture/presentation/modules/structureMaterial/components/StructureMaterialTable/index.ts`

```typescript
export * from "./StructureMaterialTable";
```

### 7b. StructureMaterialFormModal

**File:** `src/clean-architecture/presentation/modules/structureMaterial/components/StructureMaterialFormModal/StructureMaterialFormModal.tsx`

```typescript
import { Dialog } from "@kingstonvn/ui";
import { FormStateProvider, FormDialogActions } from "clean-architecture/presentation/components/organisms";
import { useFormStateContext } from "clean-architecture/presentation/hooks";
import { Controller, useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import { Input, ReactSelect } from "@kingstonvn/ui";
import {
  StructureMaterialFormValues,
  structureMaterialSchema,
  structureMaterialUpdateSchema,
} from "clean-architecture/presentation/forms/structureMaterial/StructureMaterialFormValues";
import type { StructureMaterial } from "clean-architecture/domain/entities/StructureMaterial";

interface Props {
  open: boolean;
  onToggle: () => void;
  onSubmit: (values: StructureMaterialFormValues) => Promise<void>;
  editingItem?: StructureMaterial | null;  // null = create mode
  isSubmitting?: boolean;
}

const FORM_ID = "structure-material-form";

export const StructureMaterialFormModal: FC<Props> = ({
  open, onToggle, onSubmit, editingItem, isSubmitting
}) => {
  const isEditMode = !!editingItem;
  const schema = isEditMode ? structureMaterialUpdateSchema : structureMaterialSchema;

  const { register, control, handleSubmit, reset, watch, formState: { errors } } = useForm<StructureMaterialFormValues>({
    resolver: yupResolver(schema as any),
    defaultValues: {
      materialId: editingItem?.materialId ?? null,
      assemblyQty: editingItem?.assemblyQty ?? null,
      packagingQty: editingItem?.packagingQty ?? null,
    },
  });

  // Khi chọn material, hiển thị unit (read-only)
  // Dùng MaterialSelect component đang có hoặc ReactSelect với search API

  return (
    <Dialog
      open={open}
      onOpenChange={(v) => { if (!v) onToggle(); }}
      title={isEditMode ? "Sửa vật tư" : "Thêm vật tư"}
    >
      <FormStateProvider isSubmitting={isSubmitting}>
        <form id={FORM_ID} onSubmit={handleSubmit(onSubmit)}>
          <div className="flex flex-col gap-4">
            {/* Material select — read-only khi edit */}
            {!isEditMode && (
              <Controller
                name="materialId"
                control={control}
                render={({ field }) => (
                  <ReactSelect
                    label="Vật tư *"
                    isInvalid={!!errors.materialId}
                    description={errors.materialId?.message}
                    {...field}
                    // loadOptions: GET /materials?search=...
                  />
                )}
              />
            )}
            {isEditMode && (
              <Input
                label="Vật tư"
                value={editingItem?.material?.name ?? ""}
                disabled
              />
            )}

            <div className="grid grid-cols-2 gap-4">
              <Input
                label="SL Lắp ráp"
                type="number"
                step="0.01"
                isInvalid={!!errors.assemblyQty}
                description={errors.assemblyQty?.message}
                {...register("assemblyQty", { valueAsNumber: true })}
              />
              <Input
                label="SL Đóng gói"
                type="number"
                step="0.01"
                isInvalid={!!errors.packagingQty}
                description={errors.packagingQty?.message}
                {...register("packagingQty", { valueAsNumber: true })}
              />
            </div>

            {/* Đơn vị read-only — hiển thị từ material đã chọn */}
            <Input
              label="Đơn vị"
              value={editingItem?.material?.unit?.name ?? "—"}
              disabled
            />
          </div>
        </form>
        <Dialog.Footer>
          <FormDialogActions formId={FORM_ID} onCancel={onToggle} />
        </Dialog.Footer>
      </FormStateProvider>
    </Dialog>
  );
};
```

**File:** `src/clean-architecture/presentation/modules/structureMaterial/components/StructureMaterialFormModal/index.ts`

```typescript
export * from "./StructureMaterialFormModal";
```

---

## Step 8 — Tab Page Component

**File:** `src/clean-architecture/presentation/components/pages/structure/DetailProductStructureMaterials/DetailProductStructureMaterials.tsx`

```typescript
import { useConfirm } from "clean-architecture/presentation/components/molecules/ConfirmationProvider/useConfirm";
import { useAppTranslation } from "clean-architecture/presentation/hooks";
import { useGetStructureMaterials } from "clean-architecture/presentation/modules/structureMaterial/hooks/useGetStructureMaterials";
import { useStructureMaterialActions } from "clean-architecture/presentation/modules/structureMaterial/hooks/useStructureMaterialActions";
import { StructureMaterialTable } from "clean-architecture/presentation/modules/structureMaterial/components/StructureMaterialTable";
import { StructureMaterialFormModal } from "clean-architecture/presentation/modules/structureMaterial/components/StructureMaterialFormModal";
import { useParams } from "react-router-dom";
import { useState } from "react";
import type { StructureMaterial } from "clean-architecture/domain/entities/StructureMaterial";
import { useHasPermission } from "clean-architecture/presentation/hooks";
import { Permissions } from "clean-architecture/domain/constants/permissions";

const DetailProductStructureMaterials = () => {
  const { id: structureId } = useParams<{ id: string }>();
  const canEdit = useHasPermission(Permissions.STRUCTURE_UPDATE);
  const { confirm } = useConfirm();
  const { confirmText } = useAppTranslation();

  const { structureMaterials, isStructureMaterialsLoading } =
    useGetStructureMaterials(structureId);

  const { handleCreate, handleUpdate, handleDelete, isCreating, isUpdating } =
    useStructureMaterialActions(structureId as ID);

  const [isAddOpen, setIsAddOpen] = useState(false);
  const [editingItem, setEditingItem] = useState<StructureMaterial | null>(null);

  const handleDeleteConfirm = (item: StructureMaterial) => {
    confirm({
      title: `Xóa "${item.material?.name}" khỏi kết cấu?`,
      description: "Thao tác này không thể hoàn tác.",
      onConfirm: () => handleDelete(item.id),
    });
  };

  return (
    <div className="flex flex-col gap-4">
      {canEdit && (
        <div className="flex justify-end">
          <button
            className="btn btn-primary"
            onClick={() => setIsAddOpen(true)}
          >
            + Thêm vật tư
          </button>
        </div>
      )}

      <StructureMaterialTable
        data={structureMaterials}
        isLoading={isStructureMaterialsLoading}
        canEdit={canEdit}
        onEdit={setEditingItem}
        onDelete={handleDeleteConfirm}
      />

      {/* Add modal */}
      <StructureMaterialFormModal
        open={isAddOpen}
        onToggle={() => setIsAddOpen(false)}
        onSubmit={async (values) => {
          await handleCreate({
            materialId: values.materialId!,
            assemblyQty: values.assemblyQty,
            packagingQty: values.packagingQty,
          });
          // Không đóng modal sau khi thêm — cho phép thêm tiếp (AC01)
        }}
        isSubmitting={isCreating}
      />

      {/* Edit modal */}
      <StructureMaterialFormModal
        open={editingItem !== null}
        onToggle={() => setEditingItem(null)}
        editingItem={editingItem}
        onSubmit={async (values) => {
          if (!editingItem) return;
          await handleUpdate({
            id: editingItem.id,
            input: {
              assemblyQty: values.assemblyQty,
              packagingQty: values.packagingQty,
            },
          });
          setEditingItem(null);
        }}
        isSubmitting={isUpdating}
      />
    </div>
  );
};

export default DetailProductStructureMaterials;
```

**File:** `src/clean-architecture/presentation/components/pages/structure/DetailProductStructureMaterials/index.ts`

```typescript
import DetailProductStructureMaterials from "./DetailProductStructureMaterials";
export default DetailProductStructureMaterials;
```

---

## Step 9 — Layout Update & Route

### 9a. DetailProductStructureLayout — thêm tab "Vật tư"

**Sửa** `DetailProductStructureLayout.tsx`:

```typescript
// Thêm vào enum:
export enum DetailProductStructureTabEnum {
  General = "/structures/:id/general",
  Structure = "/structures/:id/structure",
  Bom = "/structures/:id/bom",
  Materials = "/structures/:id/materials",   // ← thêm
}

// Sửa useRouteMatch — thêm Materials:
const routeMatch = useRouteMatch([
  DetailProductStructureTabEnum.Structure,
  DetailProductStructureTabEnum.Bom,
  DetailProductStructureTabEnum.Materials,  // ← thêm
]);

// Thêm vào tabs array:
{
  label: "Vật tư",
  value: DetailProductStructureTabEnum.Materials,
  click: () => navigate(`/structures/${id}/materials`, { replace: true }),
},
```

### 9b. allRoutes.tsx — thêm route

**Sửa** `allRoutes.tsx` — trong children của `/structures/:id`:

```typescript
import DetailProductStructureMaterials from "clean-architecture/presentation/components/pages/structure/DetailProductStructureMaterials";

// Thêm vào children array của route /structures/:id:
{
  path: "materials",
  component: <DetailProductStructureMaterials />,
},
```

---

## Checklist hoàn thành

- [ ] Step 1: StructureMaterial.ts (entity + repository interface)
- [ ] Step 2a: GetStructureMaterials.ts
- [ ] Step 2b: CreateStructureMaterial.ts
- [ ] Step 2c: UpdateStructureMaterial.ts
- [ ] Step 2d: DeleteStructureMaterial.ts
- [ ] Step 3a: StructureMaterialResponse.ts
- [ ] Step 3b: StructureMaterialMapper.ts
- [ ] Step 3c: StructureMaterialApiRepository.ts (verify ApiService token name)
- [ ] Step 4a: di/types.ts — 5 tokens
- [ ] Step 4b: di/container.ts — 5 bindings
- [ ] Step 5: StructureMaterialFormValues.ts
- [ ] Step 6a: useGetStructureMaterials.ts
- [ ] Step 6b: useStructureMaterialActions.ts
- [ ] Step 7a: StructureMaterialTable.tsx + index.ts
- [ ] Step 7b: StructureMaterialFormModal.tsx + index.ts
- [ ] Step 8: DetailProductStructureMaterials.tsx + index.ts
- [ ] Step 9a: DetailProductStructureLayout.tsx — thêm tab
- [ ] Step 9b: allRoutes.tsx — thêm route
- [ ] `bun type-check` — no errors
- [ ] Smoke test: mở tab Vật tư → thêm vật tư → xem bảng → edit → delete
