Files
sam-docs/history/2025-11/item-master-archived/[DESIGN-2025-11-24] item-management-dynamic-frontend.md
hskwon ceae830e41 docs: 2025-11 문서 아카이브 이동
- history/2025-11/front-requests/ 프론트 요청 문서 이동
- history/2025-11/item-master-archived/ Item Master 구버전 문서 이동
2025-12-09 20:30:39 +09:00

29 KiB

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

작성일: 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:

// 메타데이터 구조
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 설정

  1. ItemMetadataContext.tsx 생성
  2. app/[locale]/(protected)/layout.tsx에 Provider 추가:
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 컴포넌트로 교체:
// 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 등)
✓ 테이블 목록 표시
✓ 검색 기능
✓ 필터 기능
✓ 정렬 기능
✓ 페이지네이션
✓ 생성/수정/삭제 기능

관련 문서


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