# 품목관리 동적 화면 생성 프론트엔드 설계

**작성일**: 2025-11-24
**프로젝트**: SAM MES System - Next.js 15 Frontend
**관련 API 문서**: [API-2025-11-24] item-management-dynamic-api-spec.md

---

## 목차

1. [개요](#개요)
2. [아키텍처 설계](#아키텍처-설계)
3. [컴포넌트 구조](#컴포넌트-구조)
4. [동적 렌더링 엔진](#동적-렌더링-엔진)
5. [상태 관리](#상태-관리)
6. [구현 가이드](#구현-가이드)
7. [사용자 시나리오](#사용자-시나리오)

---

## 개요

### 목적
품목기준관리에서 정의한 메타데이터를 기반으로 품목관리 화면을 동적으로 생성

### 핵심 요구사항
1. 메타데이터 기반 동적 폼 생성
2. 메타데이터 기반 동적 테이블 생성
3. 필드 타입별 적절한 입력 컴포넌트
4. 페이지(탭) 구조 지원
5. 섹션별 그룹핑
6. 실시간 유효성 검증

### 기존 품목기준관리 구조 참고

**ItemMasterContext**:
```typescript
// 메타데이터 구조
interface ItemPage {
  id: string;
  page_name: string;
  display_order: number;
  sections: ItemSection[];
}

interface ItemSection {
  id: string;
  section_name: string;
  display_order: number;
  fields: ItemField[];
}

interface ItemField {
  id: string;
  field_name: string;
  field_label: string;
  field_type: 'text' | 'number' | 'date' | 'select' | 'textarea' | 'checkbox' | 'file';
  is_required: boolean;
  validation_rules?: Record<string, any>;
  default_value?: string;
  options?: string[];
  help_text?: string;
  placeholder?: string;
}
```

---

## 아키텍처 설계

### 전체 흐름

```
사용자가 품목관리 페이지 접속
    ↓
1. API 호출: GET /api/item-master/config
   → 메타데이터 조회
    ↓
2. ItemMetadataContext에 저장
   → 전역 상태 관리
    ↓
3. 메타데이터 파싱
   - 페이지(탭) 구조 분석
   - 섹션 구조 분석
   - 필드 구조 분석
    ↓
4. 동적 컴포넌트 생성
   - DynamicTable (목록)
   - DynamicForm (생성/수정)
   - DynamicFilter (필터)
    ↓
5. 사용자 CRUD 작업
   → API 호출
```

### 디렉토리 구조

```
src/
├── components/
│   └── items/
│       ├── ItemManagement.tsx                    # 메인 페이지 컴포넌트
│       └── ItemManagement/
│           ├── DynamicTable.tsx                  # 동적 테이블
│           ├── DynamicForm.tsx                   # 동적 폼
│           ├── DynamicFilter.tsx                 # 동적 필터
│           ├── fields/
│           │   ├── DynamicFieldRenderer.tsx      # 필드 타입별 렌더러
│           │   ├── TextField.tsx                 # 텍스트 입력
│           │   ├── NumberField.tsx               # 숫자 입력
│           │   ├── DateField.tsx                 # 날짜 선택
│           │   ├── SelectField.tsx               # 드롭다운
│           │   ├── TextareaField.tsx             # 텍스트 영역
│           │   ├── CheckboxField.tsx             # 체크박스
│           │   └── FileField.tsx                 # 파일 업로드
│           ├── ItemDialog.tsx                    # 생성/수정 다이얼로그
│           └── ItemTableRow.tsx                  # 테이블 행 컴포넌트
├── contexts/
│   └── ItemMetadataContext.tsx                   # 메타데이터 전역 상태
├── lib/
│   ├── api/
│   │   └── items.ts                              # 품목 API 클라이언트
│   └── utils/
│       ├── fieldValidation.ts                    # 필드 검증 유틸
│       └── fieldFormatters.ts                    # 필드 포맷터
└── types/
    └── item.ts                                    # 품목 관련 타입 정의
```

---

## 컴포넌트 구조

### 1. ItemMetadataContext

**목적**: 메타데이터를 전역에서 관리하고 캐싱

```typescript
// src/contexts/ItemMetadataContext.tsx
'use client';

import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { itemsApi } from '@/lib/api/items';
import type { ItemMetadata, ItemPage } from '@/types/item';

interface ItemMetadataContextType {
  metadata: ItemMetadata | null;
  isLoading: boolean;
  error: string | null;
  refresh: () => Promise<void>;
}

const ItemMetadataContext = createContext<ItemMetadataContextType | undefined>(undefined);

export function ItemMetadataProvider({ children }: { children: ReactNode }) {
  const [metadata, setMetadata] = useState<ItemMetadata | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const loadMetadata = async () => {
    try {
      setIsLoading(true);
      setError(null);
      const data = await itemsApi.getMetadata();
      setMetadata(data);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to load metadata');
    } finally {
      setIsLoading(false);
    }
  };

  useEffect(() => {
    loadMetadata();
  }, []);

  return (
    <ItemMetadataContext.Provider value={{
      metadata,
      isLoading,
      error,
      refresh: loadMetadata
    }}>
      {children}
    </ItemMetadataContext.Provider>
  );
}

export function useItemMetadata() {
  const context = useContext(ItemMetadataContext);
  if (!context) {
    throw new Error('useItemMetadata must be used within ItemMetadataProvider');
  }
  return context;
}
```

### 2. ItemManagement (메인 페이지)

```typescript
// src/components/items/ItemManagement.tsx
'use client';

import { useState } from 'react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Button } from '@/components/ui/button';
import { Plus } from 'lucide-react';
import { useItemMetadata } from '@/contexts/ItemMetadataContext';
import { DynamicTable } from './ItemManagement/DynamicTable';
import { DynamicFilter } from './ItemManagement/DynamicFilter';
import { ItemDialog } from './ItemManagement/ItemDialog';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { ErrorMessage } from '@/components/ui/error-message';

export function ItemManagement() {
  const { metadata, isLoading, error } = useItemMetadata();
  const [isDialogOpen, setIsDialogOpen] = useState(false);
  const [editingItem, setEditingItem] = useState<any>(null);
  const [filters, setFilters] = useState<Record<string, any>>({});

  if (isLoading) {
    return <LoadingSpinner message="메타데이터 로딩 중..." />;
  }

  if (error || !metadata) {
    return <ErrorMessage message={error || "메타데이터를 불러올 수 없습니다."} />;
  }

  const handleCreate = () => {
    setEditingItem(null);
    setIsDialogOpen(true);
  };

  const handleEdit = (item: any) => {
    setEditingItem(item);
    setIsDialogOpen(true);
  };

  return (
    <PageLayout>
      <PageHeader
        title="품목 관리"
        description="품목 정보를 관리합니다"
        actions={
          <Button onClick={handleCreate}>
            <Plus className="w-4 h-4 mr-2" />
            품목 추가
          </Button>
        }
      />

      <div className="space-y-4">
        {/* 동적 필터 */}
        <DynamicFilter
          metadata={metadata}
          filters={filters}
          onFilterChange={setFilters}
        />

        {/* 동적 테이블 */}
        <DynamicTable
          metadata={metadata}
          filters={filters}
          onEdit={handleEdit}
        />
      </div>

      {/* 생성/수정 다이얼로그 */}
      <ItemDialog
        open={isDialogOpen}
        onClose={() => setIsDialogOpen(false)}
        metadata={metadata}
        item={editingItem}
      />
    </PageLayout>
  );
}
```

### 3. DynamicTable (동적 테이블)

```typescript
// src/components/items/ItemManagement/DynamicTable.tsx
'use client';

import { useState, useEffect } from 'react';
import { itemsApi } from '@/lib/api/items';
import type { ItemMetadata, Item } from '@/types/item';
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Edit, Trash2 } from 'lucide-react';
import { Pagination } from '@/components/ui/pagination';

interface DynamicTableProps {
  metadata: ItemMetadata;
  filters: Record<string, any>;
  onEdit: (item: Item) => void;
}

export function DynamicTable({ metadata, filters, onEdit }: DynamicTableProps) {
  const [items, setItems] = useState<Item[]>([]);
  const [pagination, setPagination] = useState({
    total: 0,
    current_page: 1,
    per_page: 20,
    last_page: 1,
  });
  const [isLoading, setIsLoading] = useState(false);

  // 목록에 표시할 컬럼 추출 (show_in_list=true)
  const columns = metadata.pages
    .flatMap(page => page.sections)
    .flatMap(section => section.fields)
    .filter(field => field.show_in_list)
    .sort((a, b) => (a.list_order ?? 999) - (b.list_order ?? 999));

  useEffect(() => {
    loadItems();
  }, [filters, pagination.current_page]);

  const loadItems = async () => {
    try {
      setIsLoading(true);
      const response = await itemsApi.getList({
        page: pagination.current_page,
        per_page: pagination.per_page,
        ...filters,
      });
      setItems(response.data);
      setPagination(response.pagination);
    } catch (error) {
      console.error('Failed to load items:', error);
    } finally {
      setIsLoading(false);
    }
  };

  const handleDelete = async (id: number) => {
    if (!confirm('정말 삭제하시겠습니까?')) return;

    try {
      await itemsApi.delete(id);
      loadItems();
    } catch (error) {
      console.error('Failed to delete item:', error);
      alert('삭제에 실패했습니다.');
    }
  };

  return (
    <div className="space-y-4">
      <Table>
        <TableHeader>
          <TableRow>
            {columns.map(column => (
              <TableHead key={column.id} style={{ width: column.column_width }}>
                {column.field_label}
              </TableHead>
            ))}
            <TableHead className="w-[100px]">작업</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {isLoading ? (
            <TableRow>
              <TableCell colSpan={columns.length + 1} className="text-center">
                로딩 중...
              </TableCell>
            </TableRow>
          ) : items.length === 0 ? (
            <TableRow>
              <TableCell colSpan={columns.length + 1} className="text-center">
                데이터가 없습니다.
              </TableCell>
            </TableRow>
          ) : (
            items.map(item => (
              <TableRow key={item.id}>
                {columns.map(column => (
                  <TableCell key={column.id}>
                    {formatCellValue(item[column.field_name], column)}
                  </TableCell>
                ))}
                <TableCell>
                  <div className="flex gap-2">
                    <Button
                      size="sm"
                      variant="ghost"
                      onClick={() => onEdit(item)}
                    >
                      <Edit className="w-4 h-4" />
                    </Button>
                    <Button
                      size="sm"
                      variant="ghost"
                      onClick={() => handleDelete(item.id)}
                    >
                      <Trash2 className="w-4 h-4" />
                    </Button>
                  </div>
                </TableCell>
              </TableRow>
            ))
          )}
        </TableBody>
      </Table>

      <Pagination
        currentPage={pagination.current_page}
        totalPages={pagination.last_page}
        onPageChange={(page) => setPagination(prev => ({ ...prev, current_page: page }))}
      />
    </div>
  );
}

function formatCellValue(value: any, column: any): string {
  if (value === null || value === undefined) return '-';

  switch (column.field_type) {
    case 'number':
      return typeof value === 'number' ? value.toLocaleString() : value;
    case 'date':
      return value ? new Date(value).toLocaleDateString('ko-KR') : '-';
    case 'checkbox':
      return value ? '예' : '아니오';
    default:
      return String(value);
  }
}
```

### 4. DynamicForm (동적 폼)

```typescript
// src/components/items/ItemManagement/DynamicForm.tsx
'use client';

import { useState, useEffect } from 'react';
import type { ItemMetadata, Item, ItemField } from '@/types/item';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { DynamicFieldRenderer } from './fields/DynamicFieldRenderer';
import { validateField } from '@/lib/utils/fieldValidation';

interface DynamicFormProps {
  metadata: ItemMetadata;
  initialValues?: Partial<Item>;
  onSubmit: (values: Record<string, any>) => void;
}

export function DynamicForm({ metadata, initialValues, onSubmit }: DynamicFormProps) {
  const [values, setValues] = useState<Record<string, any>>({});
  const [errors, setErrors] = useState<Record<string, string>>({});

  // 초기값 설정
  useEffect(() => {
    const defaultValues: Record<string, any> = {};

    // 메타데이터에서 기본값 추출
    metadata.pages.forEach(page => {
      page.sections.forEach(section => {
        section.fields.forEach(field => {
          if (field.default_value) {
            defaultValues[field.field_name] = field.default_value;
          }
        });
      });
    });

    // 초기값 병합
    setValues({ ...defaultValues, ...initialValues });
  }, [metadata, initialValues]);

  const handleFieldChange = (fieldName: string, value: any, field: ItemField) => {
    setValues(prev => ({ ...prev, [fieldName]: value }));

    // 실시간 유효성 검증
    const error = validateField(value, field);
    setErrors(prev => {
      const newErrors = { ...prev };
      if (error) {
        newErrors[fieldName] = error;
      } else {
        delete newErrors[fieldName];
      }
      return newErrors;
    });
  };

  const handleSubmit = () => {
    // 전체 유효성 검증
    const newErrors: Record<string, string> = {};

    metadata.pages.forEach(page => {
      page.sections.forEach(section => {
        section.fields.forEach(field => {
          const value = values[field.field_name];
          const error = validateField(value, field);
          if (error) {
            newErrors[field.field_name] = error;
          }
        });
      });
    });

    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      return;
    }

    onSubmit(values);
  };

  return (
    <div className="space-y-4">
      <Tabs defaultValue={metadata.pages[0]?.id} className="w-full">
        <TabsList>
          {metadata.pages.map(page => (
            <TabsTrigger key={page.id} value={page.id}>
              {page.page_name}
            </TabsTrigger>
          ))}
        </TabsList>

        {metadata.pages.map(page => (
          <TabsContent key={page.id} value={page.id} className="space-y-4">
            {page.sections.map(section => (
              <Card key={section.id}>
                <CardHeader>
                  <CardTitle>{section.section_name}</CardTitle>
                </CardHeader>
                <CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
                  {section.fields.map(field => (
                    <DynamicFieldRenderer
                      key={field.id}
                      field={field}
                      value={values[field.field_name]}
                      error={errors[field.field_name]}
                      onChange={(value) => handleFieldChange(field.field_name, value, field)}
                    />
                  ))}
                </CardContent>
              </Card>
            ))}
          </TabsContent>
        ))}
      </Tabs>

      <div className="flex justify-end gap-2">
        <Button variant="outline" onClick={() => {}}>
          취소
        </Button>
        <Button onClick={handleSubmit}>
          저장
        </Button>
      </div>
    </div>
  );
}
```

### 5. DynamicFieldRenderer (필드 렌더러)

```typescript
// src/components/items/ItemManagement/fields/DynamicFieldRenderer.tsx
'use client';

import type { ItemField } from '@/types/item';
import { TextField } from './TextField';
import { NumberField } from './NumberField';
import { DateField } from './DateField';
import { SelectField } from './SelectField';
import { TextareaField } from './TextareaField';
import { CheckboxField } from './CheckboxField';
import { FileField } from './FileField';

interface DynamicFieldRendererProps {
  field: ItemField;
  value: any;
  error?: string;
  onChange: (value: any) => void;
}

export function DynamicFieldRenderer({ field, value, error, onChange }: DynamicFieldRendererProps) {
  const commonProps = {
    label: field.field_label,
    value,
    error,
    onChange,
    required: field.is_required,
    helpText: field.help_text,
    placeholder: field.placeholder,
  };

  switch (field.field_type) {
    case 'text':
      return <TextField {...commonProps} validation={field.validation_rules} />;

    case 'number':
      return <NumberField {...commonProps} validation={field.validation_rules} />;

    case 'date':
      return <DateField {...commonProps} validation={field.validation_rules} />;

    case 'select':
      return <SelectField {...commonProps} options={field.options || []} />;

    case 'textarea':
      return <TextareaField {...commonProps} validation={field.validation_rules} />;

    case 'checkbox':
      return <CheckboxField {...commonProps} />;

    case 'file':
      return <FileField {...commonProps} validation={field.validation_rules} />;

    default:
      console.warn(`Unknown field type: ${field.field_type}`);
      return null;
  }
}
```

### 6. TextField 예시

```typescript
// src/components/items/ItemManagement/fields/TextField.tsx
'use client';

import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

interface TextFieldProps {
  label: string;
  value: string;
  error?: string;
  onChange: (value: string) => void;
  required?: boolean;
  helpText?: string;
  placeholder?: string;
  validation?: Record<string, any>;
}

export function TextField({
  label,
  value,
  error,
  onChange,
  required,
  helpText,
  placeholder,
  validation,
}: TextFieldProps) {
  return (
    <div className="space-y-2">
      <Label>
        {label}
        {required && <span className="text-red-500 ml-1">*</span>}
      </Label>
      <Input
        value={value || ''}
        onChange={(e) => onChange(e.target.value)}
        placeholder={placeholder}
        maxLength={validation?.max_length}
        className={error ? 'border-red-500' : ''}
      />
      {helpText && (
        <p className="text-sm text-muted-foreground">{helpText}</p>
      )}
      {error && (
        <p className="text-sm text-red-500">{error}</p>
      )}
    </div>
  );
}
```

---

## 동적 렌더링 엔진

### 필드 검증 유틸리티

```typescript
// src/lib/utils/fieldValidation.ts
import type { ItemField } from '@/types/item';

export function validateField(value: any, field: ItemField): string | null {
  // 필수 필드 체크
  if (field.is_required && !value) {
    return `${field.field_label}은(는) 필수 입력 항목입니다.`;
  }

  if (!value) return null;

  const rules = field.validation_rules || {};

  switch (field.field_type) {
    case 'text':
    case 'textarea':
      if (rules.min_length && value.length < rules.min_length) {
        return `${field.field_label}은(는) 최소 ${rules.min_length}자 이상이어야 합니다.`;
      }
      if (rules.max_length && value.length > rules.max_length) {
        return `${field.field_label}은(는) 최대 ${rules.max_length}자 이하여야 합니다.`;
      }
      if (rules.pattern && !new RegExp(rules.pattern).test(value)) {
        return rules.error_message || `${field.field_label}의 형식이 올바르지 않습니다.`;
      }
      break;

    case 'number':
      const numValue = Number(value);
      if (isNaN(numValue)) {
        return `${field.field_label}은(는) 숫자여야 합니다.`;
      }
      if (rules.min !== undefined && numValue < rules.min) {
        return `${field.field_label}은(는) ${rules.min} 이상이어야 합니다.`;
      }
      if (rules.max !== undefined && numValue > rules.max) {
        return `${field.field_label}은(는) ${rules.max} 이하여야 합니다.`;
      }
      break;

    case 'date':
      const dateValue = new Date(value);
      if (isNaN(dateValue.getTime())) {
        return `${field.field_label}의 날짜 형식이 올바르지 않습니다.`;
      }
      if (rules.min_date) {
        const minDate = rules.min_date === 'today' ? new Date() : new Date(rules.min_date);
        if (dateValue < minDate) {
          return `${field.field_label}은(는) ${minDate.toLocaleDateString()} 이후여야 합니다.`;
        }
      }
      if (rules.max_date) {
        const maxDate = new Date(rules.max_date);
        if (dateValue > maxDate) {
          return `${field.field_label}은(는) ${maxDate.toLocaleDateString()} 이전이어야 합니다.`;
        }
      }
      break;
  }

  return null;
}
```

### API 클라이언트

```typescript
// src/lib/api/items.ts
import { getAuthHeaders } from './auth-headers';
import type { ItemMetadata, Item } from '@/types/item';

const BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'https://api.codebridge-x.com';

export const itemsApi = {
  /**
   * 메타데이터 조회
   */
  async getMetadata(): Promise<ItemMetadata> {
    const headers = getAuthHeaders();
    const response = await fetch(`${BASE_URL}/item-master/config`, {
      method: 'GET',
      headers,
    });

    if (!response.ok) {
      throw new Error('Failed to fetch metadata');
    }

    const data = await response.json();
    return data.data;
  },

  /**
   * 품목 목록 조회
   */
  async getList(params: {
    page?: number;
    per_page?: number;
    sort_by?: string;
    sort_order?: 'asc' | 'desc';
    search?: string;
    [key: string]: any;
  }): Promise<{
    data: Item[];
    pagination: {
      total: number;
      current_page: number;
      per_page: number;
      last_page: number;
    };
  }> {
    const headers = getAuthHeaders();
    const queryString = new URLSearchParams(params as any).toString();
    const response = await fetch(`${BASE_URL}/items?${queryString}`, {
      method: 'GET',
      headers,
    });

    if (!response.ok) {
      throw new Error('Failed to fetch items');
    }

    const data = await response.json();
    return data.data;
  },

  /**
   * 품목 상세 조회
   */
  async getById(id: number): Promise<Item> {
    const headers = getAuthHeaders();
    const response = await fetch(`${BASE_URL}/items/${id}`, {
      method: 'GET',
      headers,
    });

    if (!response.ok) {
      throw new Error('Failed to fetch item');
    }

    const data = await response.json();
    return data.data;
  },

  /**
   * 품목 생성
   */
  async create(item: Record<string, any>): Promise<Item> {
    const headers = getAuthHeaders();
    const response = await fetch(`${BASE_URL}/items`, {
      method: 'POST',
      headers,
      body: JSON.stringify(item),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Failed to create item');
    }

    const data = await response.json();
    return data.data;
  },

  /**
   * 품목 수정
   */
  async update(id: number, item: Record<string, any>): Promise<Item> {
    const headers = getAuthHeaders();
    const response = await fetch(`${BASE_URL}/items/${id}`, {
      method: 'PUT',
      headers,
      body: JSON.stringify(item),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Failed to update item');
    }

    const data = await response.json();
    return data.data;
  },

  /**
   * 품목 삭제
   */
  async delete(id: number): Promise<void> {
    const headers = getAuthHeaders();
    const response = await fetch(`${BASE_URL}/items/${id}`, {
      method: 'DELETE',
      headers,
    });

    if (!response.ok) {
      throw new Error('Failed to delete item');
    }
  },
};
```

---

## 상태 관리

### Context vs Zustand

**권장**: ItemMetadataContext (React Context API 사용)

**이유**:
- 메타데이터는 자주 변경되지 않음
- 전체 앱에서 공유 필요
- 간단한 구조로 충분

**선택사항**: Zustand (복잡한 상태 관리 필요 시)

```typescript
// src/stores/itemMetadataStore.ts (선택사항)
import { create } from 'zustand';
import { itemsApi } from '@/lib/api/items';
import type { ItemMetadata } from '@/types/item';

interface ItemMetadataState {
  metadata: ItemMetadata | null;
  isLoading: boolean;
  error: string | null;
  loadMetadata: () => Promise<void>;
}

export const useItemMetadataStore = create<ItemMetadataState>((set) => ({
  metadata: null,
  isLoading: false,
  error: null,
  loadMetadata: async () => {
    set({ isLoading: true, error: null });
    try {
      const data = await itemsApi.getMetadata();
      set({ metadata: data });
    } catch (error) {
      set({ error: error instanceof Error ? error.message : 'Failed to load' });
    } finally {
      set({ isLoading: false });
    }
  },
}));
```

---

## 구현 가이드

### Step 1: Context 및 Provider 설정

1. `ItemMetadataContext.tsx` 생성
2. `app/[locale]/(protected)/layout.tsx`에 Provider 추가:

```typescript
import { ItemMetadataProvider } from '@/contexts/ItemMetadataContext';

export default function ProtectedLayout({ children }) {
  return (
    <ItemMetadataProvider>
      {children}
    </ItemMetadataProvider>
  );
}
```

### Step 2: 기존 품목관리 페이지 대체

1. 기존 `/src/app/[locale]/(protected)/items/page.tsx` 백업
2. 새 ItemManagement 컴포넌트로 교체:

```typescript
// src/app/[locale]/(protected)/items/page.tsx
import { ItemManagement } from '@/components/items/ItemManagement';
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: '품목 관리',
  description: '품목 정보를 관리합니다',
};

export default function ItemsPage() {
  return <ItemManagement />;
}
```

### Step 3: 동적 컴포넌트 구현

1. `DynamicTable.tsx`
2. `DynamicForm.tsx`
3. `DynamicFilter.tsx`
4. `DynamicFieldRenderer.tsx`
5. 각 필드 타입별 컴포넌트 (TextField, NumberField 등)

### Step 4: API 클라이언트 구현

1. `src/lib/api/items.ts` 생성
2. CRUD 메서드 구현
3. 에러 핸들링 추가

### Step 5: 유효성 검증 로직

1. `src/lib/utils/fieldValidation.ts` 생성
2. 필드 타입별 검증 로직 구현

### Step 6: 테스트

1. 메타데이터 조회 동작 확인
2. 테이블 렌더링 확인
3. 필터 동작 확인
4. CRUD 동작 확인
5. 유효성 검증 확인

---

## 사용자 시나리오

### 시나리오 1: 신규 품목 등록

```
사용자 → "품목 추가" 버튼 클릭
    ↓
ItemDialog 열림
    ↓
DynamicForm 렌더링 (메타데이터 기반)
  - 페이지 1: 기본정보 (품목코드, 품목명, 단위 등)
  - 페이지 2: 추가정보 (규격, 재질, 색상 등)
    ↓
사용자 → 필드 입력
    ↓
실시간 유효성 검증
  - 품목코드: 5-20자, 패턴 검증
  - 단위: 필수 선택
    ↓
"저장" 버튼 클릭
    ↓
전체 유효성 검증
    ↓
API 호출: POST /api/v1/items
    ↓
성공 → 테이블 새로고침 + 토스트 메시지
```

### 시나리오 2: 품목 검색 및 필터링

```
사용자 → 검색어 입력 ("ITEM-")
    ↓
DynamicFilter에서 입력 감지
    ↓
debounce 후 API 호출
  → GET /api/v1/items?search=ITEM-
    ↓
테이블 업데이트
    ↓
사용자 → 필터 적용 (단위: EA)
    ↓
API 호출: GET /api/v1/items?search=ITEM-&unit=EA
    ↓
테이블 업데이트
```

### 시나리오 3: 품목기준관리 변경 반영

```
관리자 → 품목기준관리에서 필드 추가
  예: "중량" 필드 추가 (number, 필수)
    ↓
백엔드 → 메타데이터 업데이트
    ↓
백엔드 → 캐시 클리어
    ↓
사용자 → 품목관리 페이지 새로고침
    ↓
메타데이터 재조회
    ↓
DynamicForm에 "중량" 필드 자동 추가
    ↓
DynamicTable에 "중량" 컬럼 자동 추가
    ↓
기존 품목 → 중량 필드 없음 (NULL)
신규 품목 → 중량 필드 필수 입력
```

---

## 체크리스트

### 프론트엔드 구현

```
✓ ItemMetadataContext 및 Provider 설정
✓ ItemManagement 메인 컴포넌트
✓ DynamicTable 구현
✓ DynamicForm 구현
✓ DynamicFilter 구현
✓ DynamicFieldRenderer 구현
✓ 필드 타입별 컴포넌트 (7개)
✓ API 클라이언트 구현
✓ 유효성 검증 유틸리티
✓ 에러 핸들링
✓ 로딩 상태 관리
✓ 반응형 디자인
```

### 기능 구현

```
✓ 메타데이터 조회 및 캐싱
✓ 페이지(탭) 구조 렌더링
✓ 섹션별 그룹핑
✓ 동적 필드 렌더링
✓ 필수 필드 검증
✓ 필드 타입별 검증 (패턴, min/max 등)
✓ 테이블 목록 표시
✓ 검색 기능
✓ 필터 기능
✓ 정렬 기능
✓ 페이지네이션
✓ 생성/수정/삭제 기능
```

---

## 관련 문서

- [백엔드 API 명세서](./[API-2025-11-24]%20item-management-dynamic-api-spec.md)
- [품목기준관리 컴포넌트](/src/components/items/ItemMasterDataManagement.tsx)
- [품목기준관리 Context](/src/contexts/ItemMasterContext.tsx)

---

**최종 업데이트**: 2025-11-24