Files
sam-react-prod/src/components/items/DynamicItemForm/hooks/useFormStructure.ts
byeongcheolryu 6ed5d4ffb3 feat: 품목관리 동적 렌더링 시스템 구현
- DynamicItemForm 컴포넌트 구조 생성
  - DynamicField: 필드 타입별 렌더링
  - DynamicSection: 섹션 단위 렌더링
  - DynamicFormRenderer: 페이지 전체 렌더링
- 필드 타입별 컴포넌트 (TextField, NumberField, DropdownField, CheckboxField, DateField, FileField, CustomField)
- 커스텀 훅 (useDynamicFormState, useFormStructure, useConditionalFields)
- DataTable 공통 컴포넌트 (테이블, 페이지네이션, 검색, 탭필터, 통계카드)
- ItemFormWrapper: Feature Flag 기반 폼 선택
- 타입 정의 및 문서화

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-28 20:14:43 +09:00

995 lines
25 KiB
TypeScript

/**
* useFormStructure Hook
*
* API에서 품목 유형별 폼 구조를 로드하는 훅
* - 캐싱 지원 (5분 TTL)
* - 에러 처리 및 재시도
* - Mock 데이터 폴백 (API 미구현 시)
*/
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import type { ItemType, PartType } from '@/types/item';
import type {
FormStructure,
FormStructureResponse,
UseFormStructureReturn,
DynamicSection,
DynamicField,
ConditionalSection,
} from '../types';
// ===== 캐시 설정 =====
const CACHE_TTL = 5 * 60 * 1000; // 5분
const formStructureCache = new Map<string, { data: FormStructure; timestamp: number }>();
/**
* 캐시 키 생성
*/
function getCacheKey(itemType: ItemType, partType?: PartType): string {
return partType ? `${itemType}_${partType}` : itemType;
}
/**
* 캐시에서 데이터 가져오기
*/
function getFromCache(key: string): FormStructure | null {
const cached = formStructureCache.get(key);
if (!cached) return null;
const isExpired = Date.now() - cached.timestamp > CACHE_TTL;
if (isExpired) {
formStructureCache.delete(key);
return null;
}
return cached.data;
}
/**
* 캐시에 데이터 저장
*/
function setToCache(key: string, data: FormStructure): void {
formStructureCache.set(key, { data, timestamp: Date.now() });
}
// ===== Mock 데이터 (API 미구현 시 사용) =====
/**
* 제품(FG) Mock 폼 구조
*/
function getMockFGFormStructure(): FormStructure {
return {
page: {
id: 1,
page_name: '제품 등록',
item_type: 'FG',
is_active: true,
},
sections: [
{
id: 101,
title: '기본 정보',
section_type: 'BASIC',
order_no: 1,
is_collapsible: false,
is_default_open: true,
fields: [
{
id: 1001,
field_name: '품목코드',
field_key: 'item_code',
field_type: 'textbox',
order_no: 1,
is_required: true,
is_readonly: true,
placeholder: '자동 생성',
grid_row: 1,
grid_col: 1,
grid_span: 1,
},
{
id: 1002,
field_name: '품목명',
field_key: 'item_name',
field_type: 'textbox',
order_no: 2,
is_required: true,
placeholder: '품목명을 입력하세요',
validation_rules: { maxLength: 100 },
grid_row: 1,
grid_col: 2,
grid_span: 2,
},
{
id: 1003,
field_name: '제품 카테고리',
field_key: 'product_category',
field_type: 'dropdown',
order_no: 3,
is_required: true,
dropdown_config: {
options: [
{ value: 'SCREEN', label: '스크린' },
{ value: 'STEEL', label: '철재' },
],
placeholder: '카테고리 선택',
},
grid_row: 1,
grid_col: 4,
grid_span: 1,
},
{
id: 1004,
field_name: '단위',
field_key: 'unit',
field_type: 'dropdown',
order_no: 4,
is_required: true,
dropdown_config: {
options: [
{ value: 'EA', label: 'EA (개)' },
{ value: 'SET', label: 'SET (세트)' },
],
placeholder: '단위 선택',
},
grid_row: 2,
grid_col: 1,
grid_span: 1,
},
{
id: 1005,
field_name: '규격',
field_key: 'specification',
field_type: 'textbox',
order_no: 5,
is_required: false,
placeholder: '규격을 입력하세요',
grid_row: 2,
grid_col: 2,
grid_span: 2,
},
{
id: 1006,
field_name: '활성 상태',
field_key: 'is_active',
field_type: 'switch',
order_no: 6,
is_required: false,
default_value: true,
grid_row: 2,
grid_col: 4,
grid_span: 1,
},
],
},
{
id: 102,
title: '가격 정보',
section_type: 'DETAIL',
order_no: 2,
is_collapsible: true,
is_default_open: true,
fields: [
{
id: 1010,
field_name: '판매 단가',
field_key: 'sales_price',
field_type: 'currency',
order_no: 1,
is_required: false,
placeholder: '0',
grid_row: 1,
grid_col: 1,
grid_span: 1,
},
{
id: 1011,
field_name: '구매 단가',
field_key: 'purchase_price',
field_type: 'currency',
order_no: 2,
is_required: false,
placeholder: '0',
grid_row: 1,
grid_col: 2,
grid_span: 1,
},
{
id: 1012,
field_name: '마진율 (%)',
field_key: 'margin_rate',
field_type: 'number',
order_no: 3,
is_required: false,
placeholder: '0',
validation_rules: { min: 0, max: 100 },
grid_row: 1,
grid_col: 3,
grid_span: 1,
},
],
},
{
id: 103,
title: '부품 구성 (BOM)',
section_type: 'BOM',
order_no: 3,
is_collapsible: true,
is_default_open: true,
fields: [],
bom_config: {
columns: [
{ key: 'child_item_code', label: '품목코드', width: 150 },
{ key: 'child_item_name', label: '품목명', width: 200 },
{ key: 'specification', label: '규격', width: 150 },
{ key: 'quantity', label: '수량', width: 80, type: 'number', editable: true },
{ key: 'unit', label: '단위', width: 80 },
{ key: 'note', label: '비고', width: 150, type: 'text', editable: true },
],
allow_search: true,
search_endpoint: '/api/proxy/items/search',
allow_add: true,
allow_delete: true,
allow_reorder: true,
},
},
{
id: 104,
title: '인정 정보',
section_type: 'CERTIFICATION',
order_no: 4,
is_collapsible: true,
is_default_open: false,
fields: [
{
id: 1020,
field_name: '인정번호',
field_key: 'certification_number',
field_type: 'textbox',
order_no: 1,
is_required: false,
placeholder: '인정번호를 입력하세요',
grid_row: 1,
grid_col: 1,
grid_span: 2,
},
{
id: 1021,
field_name: '인정 시작일',
field_key: 'certification_start_date',
field_type: 'date',
order_no: 2,
is_required: false,
grid_row: 1,
grid_col: 3,
grid_span: 1,
},
{
id: 1022,
field_name: '인정 종료일',
field_key: 'certification_end_date',
field_type: 'date',
order_no: 3,
is_required: false,
grid_row: 1,
grid_col: 4,
grid_span: 1,
},
{
id: 1023,
field_name: '시방서',
field_key: 'specification_file',
field_type: 'file',
order_no: 4,
is_required: false,
file_config: {
accept: '.pdf,.doc,.docx',
max_size: 10 * 1024 * 1024, // 10MB
},
grid_row: 2,
grid_col: 1,
grid_span: 2,
},
{
id: 1024,
field_name: '인정서',
field_key: 'certification_file',
field_type: 'file',
order_no: 5,
is_required: false,
file_config: {
accept: '.pdf,.doc,.docx',
max_size: 10 * 1024 * 1024,
},
grid_row: 2,
grid_col: 3,
grid_span: 2,
},
],
},
],
conditionalSections: [],
conditionalFields: [],
};
}
/**
* 부품(PT) Mock 폼 구조
*/
function getMockPTFormStructure(partType?: PartType): FormStructure {
const baseFields: DynamicField[] = [
{
id: 2001,
field_name: '품목코드',
field_key: 'item_code',
field_type: 'textbox',
order_no: 1,
is_required: true,
is_readonly: true,
placeholder: '자동 생성',
grid_row: 1,
grid_col: 1,
grid_span: 1,
},
{
id: 2002,
field_name: '품목명',
field_key: 'item_name',
field_type: 'textbox',
order_no: 2,
is_required: true,
placeholder: '품목명을 입력하세요',
grid_row: 1,
grid_col: 2,
grid_span: 2,
},
{
id: 2003,
field_name: '부품 유형',
field_key: 'part_type',
field_type: 'dropdown',
order_no: 3,
is_required: true,
dropdown_config: {
options: [
{ value: 'ASSEMBLY', label: '조립 부품' },
{ value: 'BENDING', label: '절곡 부품' },
{ value: 'PURCHASED', label: '구매 부품' },
],
placeholder: '부품 유형 선택',
},
grid_row: 1,
grid_col: 4,
grid_span: 1,
},
{
id: 2004,
field_name: '단위',
field_key: 'unit',
field_type: 'dropdown',
order_no: 4,
is_required: true,
dropdown_config: {
options: [
{ value: 'EA', label: 'EA (개)' },
{ value: 'SET', label: 'SET (세트)' },
{ value: 'M', label: 'M (미터)' },
],
},
grid_row: 2,
grid_col: 1,
grid_span: 1,
},
];
const sections: DynamicSection[] = [
{
id: 201,
title: '기본 정보',
section_type: 'BASIC',
order_no: 1,
is_collapsible: false,
is_default_open: true,
fields: baseFields,
},
];
// 조립 부품 전용 섹션
const assemblySection: DynamicSection = {
id: 202,
title: '조립 부품 상세',
section_type: 'DETAIL',
order_no: 2,
is_collapsible: true,
is_default_open: true,
display_condition: {
field_key: 'part_type',
operator: 'equals',
value: 'ASSEMBLY',
},
fields: [
{
id: 2010,
field_name: '설치 유형',
field_key: 'installation_type',
field_type: 'dropdown',
order_no: 1,
is_required: false,
dropdown_config: {
options: [
{ value: 'WALL', label: '벽면형' },
{ value: 'SIDE', label: '측면형' },
],
},
grid_row: 1,
grid_col: 1,
grid_span: 1,
},
{
id: 2011,
field_name: '조립 종류',
field_key: 'assembly_type',
field_type: 'dropdown',
order_no: 2,
is_required: false,
dropdown_config: {
options: [
{ value: 'M', label: 'M형' },
{ value: 'T', label: 'T형' },
{ value: 'C', label: 'C형' },
{ value: 'D', label: 'D형' },
{ value: 'S', label: 'S형' },
{ value: 'U', label: 'U형' },
],
},
grid_row: 1,
grid_col: 2,
grid_span: 1,
},
{
id: 2012,
field_name: '길이 (mm)',
field_key: 'assembly_length',
field_type: 'dropdown',
order_no: 3,
is_required: false,
dropdown_config: {
options: [
{ value: '2438', label: '2438' },
{ value: '3000', label: '3000' },
{ value: '3500', label: '3500' },
{ value: '4000', label: '4000' },
{ value: '4300', label: '4300' },
],
},
grid_row: 1,
grid_col: 3,
grid_span: 1,
},
],
};
// 절곡 부품 전용 섹션
const bendingSection: DynamicSection = {
id: 203,
title: '절곡 정보',
section_type: 'BENDING',
order_no: 2,
is_collapsible: true,
is_default_open: true,
display_condition: {
field_key: 'part_type',
operator: 'equals',
value: 'BENDING',
},
fields: [
{
id: 2020,
field_name: '재질',
field_key: 'material',
field_type: 'dropdown',
order_no: 1,
is_required: true,
dropdown_config: {
options: [
{ value: 'EGI_1.55T', label: 'EGI 1.55T' },
{ value: 'SUS_1.2T', label: 'SUS 1.2T' },
{ value: 'SUS_1.5T', label: 'SUS 1.5T' },
],
},
grid_row: 1,
grid_col: 1,
grid_span: 1,
},
{
id: 2021,
field_name: '길이/목함 (mm)',
field_key: 'bending_length',
field_type: 'number',
order_no: 2,
is_required: false,
placeholder: '길이 입력',
grid_row: 1,
grid_col: 2,
grid_span: 1,
},
{
id: 2022,
field_name: '전개도',
field_key: 'bending_diagram',
field_type: 'custom:drawing-canvas',
order_no: 3,
is_required: false,
grid_row: 2,
grid_col: 1,
grid_span: 4,
},
{
id: 2023,
field_name: '전개도 상세',
field_key: 'bending_details',
field_type: 'custom:bending-detail-table',
order_no: 4,
is_required: false,
grid_row: 3,
grid_col: 1,
grid_span: 4,
},
],
};
// 구매 부품 전용 섹션
const purchasedSection: DynamicSection = {
id: 204,
title: '구매 부품 상세',
section_type: 'DETAIL',
order_no: 2,
is_collapsible: true,
is_default_open: true,
display_condition: {
field_key: 'part_type',
operator: 'equals',
value: 'PURCHASED',
},
fields: [
{
id: 2030,
field_name: '구매처',
field_key: 'supplier',
field_type: 'textbox',
order_no: 1,
is_required: false,
placeholder: '구매처를 입력하세요',
grid_row: 1,
grid_col: 1,
grid_span: 2,
},
{
id: 2031,
field_name: '구매 단가',
field_key: 'purchase_price',
field_type: 'currency',
order_no: 2,
is_required: false,
grid_row: 1,
grid_col: 3,
grid_span: 1,
},
{
id: 2032,
field_name: '리드타임 (일)',
field_key: 'lead_time',
field_type: 'number',
order_no: 3,
is_required: false,
placeholder: '0',
grid_row: 1,
grid_col: 4,
grid_span: 1,
},
],
};
sections.push(assemblySection, bendingSection, purchasedSection);
// BOM 섹션 (조립/절곡 부품만)
const bomSection: DynamicSection = {
id: 205,
title: '부품 구성 (BOM)',
section_type: 'BOM',
order_no: 3,
is_collapsible: true,
is_default_open: true,
display_condition: {
field_key: 'part_type',
operator: 'in',
value: ['ASSEMBLY', 'BENDING'],
},
fields: [],
bom_config: {
columns: [
{ key: 'child_item_code', label: '품목코드', width: 150 },
{ key: 'child_item_name', label: '품목명', width: 200 },
{ key: 'quantity', label: '수량', width: 80, type: 'number', editable: true },
{ key: 'unit', label: '단위', width: 80 },
],
allow_search: true,
search_endpoint: '/api/proxy/items/search',
allow_add: true,
allow_delete: true,
},
};
sections.push(bomSection);
return {
page: {
id: 2,
page_name: '부품 등록',
item_type: 'PT',
part_type: partType,
is_active: true,
},
sections,
conditionalSections: [],
conditionalFields: [],
};
}
/**
* 자재(RM/SM/CS) Mock 폼 구조
*/
function getMockMaterialFormStructure(itemType: ItemType): FormStructure {
const typeLabels: Record<string, string> = {
RM: '원자재',
SM: '부자재',
CS: '소모품',
};
return {
page: {
id: itemType === 'RM' ? 3 : itemType === 'SM' ? 4 : 5,
page_name: `${typeLabels[itemType]} 등록`,
item_type: itemType,
is_active: true,
},
sections: [
{
id: 301,
title: '기본 정보',
section_type: 'BASIC',
order_no: 1,
is_collapsible: false,
is_default_open: true,
fields: [
{
id: 3001,
field_name: '품목코드',
field_key: 'item_code',
field_type: 'textbox',
order_no: 1,
is_required: true,
is_readonly: true,
placeholder: '자동 생성',
grid_row: 1,
grid_col: 1,
grid_span: 1,
},
{
id: 3002,
field_name: '품목명',
field_key: 'item_name',
field_type: 'textbox',
order_no: 2,
is_required: true,
placeholder: '품목명을 입력하세요',
grid_row: 1,
grid_col: 2,
grid_span: 2,
},
{
id: 3003,
field_name: '단위',
field_key: 'unit',
field_type: 'dropdown',
order_no: 3,
is_required: true,
dropdown_config: {
options: [
{ value: 'EA', label: 'EA (개)' },
{ value: 'KG', label: 'KG (킬로그램)' },
{ value: 'M', label: 'M (미터)' },
{ value: 'L', label: 'L (리터)' },
{ value: 'BOX', label: 'BOX (박스)' },
],
},
grid_row: 1,
grid_col: 4,
grid_span: 1,
},
{
id: 3004,
field_name: '규격',
field_key: 'specification',
field_type: 'textbox',
order_no: 4,
is_required: false,
placeholder: '규격을 입력하세요',
grid_row: 2,
grid_col: 1,
grid_span: 2,
},
{
id: 3005,
field_name: '구매 단가',
field_key: 'purchase_price',
field_type: 'currency',
order_no: 5,
is_required: false,
grid_row: 2,
grid_col: 3,
grid_span: 1,
},
{
id: 3006,
field_name: '안전재고',
field_key: 'safety_stock',
field_type: 'number',
order_no: 6,
is_required: false,
placeholder: '0',
grid_row: 2,
grid_col: 4,
grid_span: 1,
},
],
},
{
id: 302,
title: '구매 정보',
section_type: 'DETAIL',
order_no: 2,
is_collapsible: true,
is_default_open: false,
fields: [
{
id: 3010,
field_name: '구매처',
field_key: 'supplier',
field_type: 'textbox',
order_no: 1,
is_required: false,
placeholder: '구매처를 입력하세요',
grid_row: 1,
grid_col: 1,
grid_span: 2,
},
{
id: 3011,
field_name: '리드타임 (일)',
field_key: 'lead_time',
field_type: 'number',
order_no: 2,
is_required: false,
placeholder: '0',
grid_row: 1,
grid_col: 3,
grid_span: 1,
},
{
id: 3012,
field_name: '비고',
field_key: 'note',
field_type: 'textarea',
order_no: 3,
is_required: false,
placeholder: '비고를 입력하세요',
grid_row: 2,
grid_col: 1,
grid_span: 4,
},
],
},
],
conditionalSections: [],
conditionalFields: [],
};
}
/**
* Mock 데이터 가져오기
*/
function getMockFormStructure(itemType: ItemType, partType?: PartType): FormStructure {
switch (itemType) {
case 'FG':
return getMockFGFormStructure();
case 'PT':
return getMockPTFormStructure(partType);
case 'RM':
case 'SM':
case 'CS':
return getMockMaterialFormStructure(itemType);
default:
return getMockFGFormStructure();
}
}
// ===== API 호출 =====
/**
* 폼 구조 API 호출
*/
async function fetchFormStructure(
itemType: ItemType,
partType?: PartType
): Promise<FormStructure> {
const endpoint = partType
? `/api/proxy/item-master/form-structure/${itemType}?part_type=${partType}`
: `/api/proxy/item-master/form-structure/${itemType}`;
try {
const response = await fetch(endpoint);
if (!response.ok) {
// API가 404면 Mock 데이터 사용
if (response.status === 404) {
console.warn(`[useFormStructure] API not found, using mock data for ${itemType}`);
return getMockFormStructure(itemType, partType);
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result: FormStructureResponse = await response.json();
if (!result.success) {
throw new Error(result.message || 'API returned unsuccessful response');
}
// API 응답을 FormStructure 형식으로 변환
return {
page: result.data.page,
sections: result.data.sections,
conditionalSections: result.data.conditional_sections || [],
conditionalFields: result.data.conditional_fields || [],
};
} catch (error) {
console.warn(`[useFormStructure] API call failed, using mock data:`, error);
// API 실패 시 Mock 데이터 폴백
return getMockFormStructure(itemType, partType);
}
}
// ===== 훅 구현 =====
interface UseFormStructureOptions {
itemType: ItemType;
partType?: PartType;
enabled?: boolean;
useMock?: boolean; // 강제로 Mock 데이터 사용
}
/**
* useFormStructure Hook
*
* @param options - 훅 옵션
* @returns 폼 구조 데이터 및 상태
*
* @example
* const { formStructure, isLoading, error, refetch } = useFormStructure({
* itemType: 'FG',
* });
*
* @example
* const { formStructure } = useFormStructure({
* itemType: 'PT',
* partType: 'BENDING',
* });
*/
export function useFormStructure(options: UseFormStructureOptions): UseFormStructureReturn {
const { itemType, partType, enabled = true, useMock = false } = options;
const [formStructure, setFormStructure] = useState<FormStructure | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// 이전 요청 취소용
const abortControllerRef = useRef<AbortController | null>(null);
const cacheKey = getCacheKey(itemType, partType);
/**
* 폼 구조 로드
*/
const loadFormStructure = useCallback(async () => {
// 이전 요청 취소
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
// 캐시 확인
const cached = getFromCache(cacheKey);
if (cached) {
setFormStructure(cached);
setIsLoading(false);
setError(null);
return;
}
setIsLoading(true);
setError(null);
try {
let data: FormStructure;
if (useMock) {
// 강제 Mock 모드
data = getMockFormStructure(itemType, partType);
} else {
// API 호출 (실패 시 자동으로 Mock 폴백)
data = await fetchFormStructure(itemType, partType);
}
// 캐시에 저장
setToCache(cacheKey, data);
setFormStructure(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error'));
} finally {
setIsLoading(false);
}
}, [itemType, partType, cacheKey, useMock]);
/**
* 강제 새로고침
*/
const refetch = useCallback(async () => {
// 캐시 무효화
formStructureCache.delete(cacheKey);
await loadFormStructure();
}, [cacheKey, loadFormStructure]);
// 마운트 시 및 의존성 변경 시 로드
useEffect(() => {
if (enabled) {
loadFormStructure();
}
return () => {
// 언마운트 시 요청 취소
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, [enabled, loadFormStructure]);
return {
formStructure,
isLoading,
error,
refetch,
};
}
// ===== 캐시 유틸리티 =====
/**
* 폼 구조 캐시 초기화
*/
export function clearFormStructureCache(): void {
formStructureCache.clear();
}
/**
* 특정 품목 유형의 캐시 무효화
*/
export function invalidateFormStructureCache(itemType: ItemType, partType?: PartType): void {
const key = getCacheKey(itemType, partType);
formStructureCache.delete(key);
}
export default useFormStructure;