- history/2025-11/front-requests/ 프론트 요청 문서 이동 - history/2025-11/item-master-archived/ Item Master 구버전 문서 이동
29 KiB
29 KiB
품목관리 동적 화면 생성 프론트엔드 설계
작성일: 2025-11-24 프로젝트: SAM MES System - Next.js 15 Frontend 관련 API 문서: [API-2025-11-24] item-management-dynamic-api-spec.md
목차
개요
목적
품목기준관리에서 정의한 메타데이터를 기반으로 품목관리 화면을 동적으로 생성
핵심 요구사항
- 메타데이터 기반 동적 폼 생성
- 메타데이터 기반 동적 테이블 생성
- 필드 타입별 적절한 입력 컴포넌트
- 페이지(탭) 구조 지원
- 섹션별 그룹핑
- 실시간 유효성 검증
기존 품목기준관리 구조 참고
ItemMasterContext:
// 메타데이터 구조
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
목적: 메타데이터를 전역에서 관리하고 캐싱
// 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 (메인 페이지)
// 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 (동적 테이블)
// 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 (동적 폼)
// 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 (필드 렌더러)
// 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 예시
// 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>
);
}
동적 렌더링 엔진
필드 검증 유틸리티
// 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 클라이언트
// 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 (복잡한 상태 관리 필요 시)
// 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 설정
ItemMetadataContext.tsx생성app/[locale]/(protected)/layout.tsx에 Provider 추가:
import { ItemMetadataProvider } from '@/contexts/ItemMetadataContext';
export default function ProtectedLayout({ children }) {
return (
<ItemMetadataProvider>
{children}
</ItemMetadataProvider>
);
}
Step 2: 기존 품목관리 페이지 대체
- 기존
/src/app/[locale]/(protected)/items/page.tsx백업 - 새 ItemManagement 컴포넌트로 교체:
// 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: 동적 컴포넌트 구현
DynamicTable.tsxDynamicForm.tsxDynamicFilter.tsxDynamicFieldRenderer.tsx- 각 필드 타입별 컴포넌트 (TextField, NumberField 등)
Step 4: API 클라이언트 구현
src/lib/api/items.ts생성- CRUD 메서드 구현
- 에러 핸들링 추가
Step 5: 유효성 검증 로직
src/lib/utils/fieldValidation.ts생성- 필드 타입별 검증 로직 구현
Step 6: 테스트
- 메타데이터 조회 동작 확인
- 테이블 렌더링 확인
- 필터 동작 확인
- CRUD 동작 확인
- 유효성 검증 확인
사용자 시나리오
시나리오 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 등)
✓ 테이블 목록 표시
✓ 검색 기능
✓ 필터 기능
✓ 정렬 기능
✓ 페이지네이션
✓ 생성/수정/삭제 기능
관련 문서
최종 업데이트: 2025-11-24