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>
This commit is contained in:
7
src/components/items/DynamicItemForm/hooks/index.ts
Normal file
7
src/components/items/DynamicItemForm/hooks/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 동적 폼 훅 인덱스
|
||||
*/
|
||||
|
||||
export { useFormStructure, clearFormStructureCache, invalidateFormStructureCache } from './useFormStructure';
|
||||
export { useConditionalFields } from './useConditionalFields';
|
||||
export { useDynamicFormState } from './useDynamicFormState';
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* useConditionalFields Hook
|
||||
*
|
||||
* 조건부 섹션/필드 표시 로직을 처리하는 훅
|
||||
* - 필드 값에 따른 섹션 표시/숨김
|
||||
* - 필드 값에 따른 필드 표시/숨김
|
||||
* - 조건 평가 (equals, in, not_equals 등)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type {
|
||||
ConditionalSection,
|
||||
ConditionalField,
|
||||
DynamicSection,
|
||||
Condition,
|
||||
FormData,
|
||||
UseConditionalFieldsReturn,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* 단일 조건 평가
|
||||
*/
|
||||
function evaluateCondition(condition: Condition, values: FormData): boolean {
|
||||
const fieldValue = values[condition.field_key];
|
||||
|
||||
switch (condition.operator) {
|
||||
case 'equals':
|
||||
return fieldValue === condition.value;
|
||||
|
||||
case 'not_equals':
|
||||
return fieldValue !== condition.value;
|
||||
|
||||
case 'in':
|
||||
if (Array.isArray(condition.value)) {
|
||||
return condition.value.includes(fieldValue as string);
|
||||
}
|
||||
return false;
|
||||
|
||||
case 'not_in':
|
||||
if (Array.isArray(condition.value)) {
|
||||
return !condition.value.includes(fieldValue as string);
|
||||
}
|
||||
return true;
|
||||
|
||||
case 'contains':
|
||||
if (typeof fieldValue === 'string' && typeof condition.value === 'string') {
|
||||
return fieldValue.includes(condition.value);
|
||||
}
|
||||
return false;
|
||||
|
||||
case 'greater_than':
|
||||
if (typeof fieldValue === 'number' && typeof condition.value === 'number') {
|
||||
return fieldValue > condition.value;
|
||||
}
|
||||
return false;
|
||||
|
||||
case 'less_than':
|
||||
if (typeof fieldValue === 'number' && typeof condition.value === 'number') {
|
||||
return fieldValue < condition.value;
|
||||
}
|
||||
return false;
|
||||
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 섹션의 display_condition 평가
|
||||
*/
|
||||
function isSectionConditionMet(section: DynamicSection, values: FormData): boolean {
|
||||
// display_condition이 없으면 항상 표시
|
||||
if (!section.display_condition) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return evaluateCondition(section.display_condition, values);
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 섹션 규칙에 따른 표시 여부 결정
|
||||
*/
|
||||
function evaluateConditionalSections(
|
||||
sections: DynamicSection[],
|
||||
conditionalSections: ConditionalSection[],
|
||||
values: FormData
|
||||
): Set<number> {
|
||||
const visibleSectionIds = new Set<number>();
|
||||
|
||||
// 1. 기본적으로 모든 섹션의 display_condition 평가
|
||||
for (const section of sections) {
|
||||
if (isSectionConditionMet(section, values)) {
|
||||
visibleSectionIds.add(section.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. conditionalSections 규칙 적용
|
||||
for (const rule of conditionalSections) {
|
||||
const conditionMet = evaluateCondition(rule.condition, values);
|
||||
|
||||
if (conditionMet) {
|
||||
// 조건 충족 시 show_sections 표시
|
||||
for (const sectionId of rule.show_sections) {
|
||||
visibleSectionIds.add(sectionId);
|
||||
}
|
||||
// hide_sections가 있으면 숨김
|
||||
if (rule.hide_sections) {
|
||||
for (const sectionId of rule.hide_sections) {
|
||||
visibleSectionIds.delete(sectionId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 조건 미충족 시 show_sections 숨김
|
||||
for (const sectionId of rule.show_sections) {
|
||||
visibleSectionIds.delete(sectionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return visibleSectionIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 필드 규칙에 따른 표시 여부 결정
|
||||
*/
|
||||
function evaluateConditionalFields(
|
||||
sections: DynamicSection[],
|
||||
conditionalFields: ConditionalField[],
|
||||
values: FormData
|
||||
): Map<number, Set<number>> {
|
||||
const visibleFieldsMap = new Map<number, Set<number>>();
|
||||
|
||||
// 1. 기본적으로 모든 필드 표시 (각 섹션별로)
|
||||
for (const section of sections) {
|
||||
const fieldIds = new Set<number>();
|
||||
|
||||
for (const field of section.fields) {
|
||||
// 필드의 display_condition 평가
|
||||
if (field.display_condition) {
|
||||
if (evaluateCondition(field.display_condition, values)) {
|
||||
fieldIds.add(field.id);
|
||||
}
|
||||
} else {
|
||||
fieldIds.add(field.id);
|
||||
}
|
||||
}
|
||||
|
||||
visibleFieldsMap.set(section.id, fieldIds);
|
||||
}
|
||||
|
||||
// 2. conditionalFields 규칙 적용
|
||||
for (const rule of conditionalFields) {
|
||||
const conditionMet = evaluateCondition(rule.condition, values);
|
||||
|
||||
// 모든 섹션에서 해당 필드 ID 찾기
|
||||
for (const [sectionId, fieldIds] of visibleFieldsMap) {
|
||||
if (conditionMet) {
|
||||
// 조건 충족 시 show_fields 표시
|
||||
for (const fieldId of rule.show_fields) {
|
||||
fieldIds.add(fieldId);
|
||||
}
|
||||
// hide_fields가 있으면 숨김
|
||||
if (rule.hide_fields) {
|
||||
for (const fieldId of rule.hide_fields) {
|
||||
fieldIds.delete(fieldId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 조건 미충족 시 show_fields 숨김
|
||||
for (const fieldId of rule.show_fields) {
|
||||
fieldIds.delete(fieldId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return visibleFieldsMap;
|
||||
}
|
||||
|
||||
interface UseConditionalFieldsOptions {
|
||||
sections: DynamicSection[];
|
||||
conditionalSections: ConditionalSection[];
|
||||
conditionalFields: ConditionalField[];
|
||||
values: FormData;
|
||||
}
|
||||
|
||||
/**
|
||||
* useConditionalFields Hook
|
||||
*
|
||||
* @param options - 훅 옵션
|
||||
* @returns 조건부 표시 상태 및 헬퍼 함수
|
||||
*
|
||||
* @example
|
||||
* const { visibleSections, isFieldVisible, isSectionVisible } = useConditionalFields({
|
||||
* sections: formStructure.sections,
|
||||
* conditionalSections: formStructure.conditionalSections,
|
||||
* conditionalFields: formStructure.conditionalFields,
|
||||
* values: formValues,
|
||||
* });
|
||||
*/
|
||||
export function useConditionalFields(
|
||||
options: UseConditionalFieldsOptions
|
||||
): UseConditionalFieldsReturn {
|
||||
const { sections, conditionalSections, conditionalFields, values } = options;
|
||||
|
||||
// 표시할 섹션 ID 목록
|
||||
const visibleSectionIds = useMemo(() => {
|
||||
return evaluateConditionalSections(sections, conditionalSections, values);
|
||||
}, [sections, conditionalSections, values]);
|
||||
|
||||
// 섹션별 표시할 필드 ID 맵
|
||||
const visibleFieldsMap = useMemo(() => {
|
||||
return evaluateConditionalFields(sections, conditionalFields, values);
|
||||
}, [sections, conditionalFields, values]);
|
||||
|
||||
// 배열로 변환
|
||||
const visibleSections = useMemo(() => {
|
||||
return Array.from(visibleSectionIds);
|
||||
}, [visibleSectionIds]);
|
||||
|
||||
// Map을 Record로 변환
|
||||
const visibleFields = useMemo(() => {
|
||||
const result: Record<number, number[]> = {};
|
||||
for (const [sectionId, fieldIds] of visibleFieldsMap) {
|
||||
result[sectionId] = Array.from(fieldIds);
|
||||
}
|
||||
return result;
|
||||
}, [visibleFieldsMap]);
|
||||
|
||||
/**
|
||||
* 특정 섹션이 표시되는지 확인
|
||||
*/
|
||||
const isSectionVisible = (sectionId: number): boolean => {
|
||||
return visibleSectionIds.has(sectionId);
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 필드가 표시되는지 확인
|
||||
*/
|
||||
const isFieldVisible = (sectionId: number, fieldId: number): boolean => {
|
||||
const fieldIds = visibleFieldsMap.get(sectionId);
|
||||
if (!fieldIds) return false;
|
||||
return fieldIds.has(fieldId);
|
||||
};
|
||||
|
||||
return {
|
||||
visibleSections,
|
||||
visibleFields,
|
||||
isSectionVisible,
|
||||
isFieldVisible,
|
||||
};
|
||||
}
|
||||
|
||||
export default useConditionalFields;
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* useDynamicFormState Hook
|
||||
*
|
||||
* 동적 폼의 상태 관리 훅
|
||||
* - 필드 값 관리
|
||||
* - 유효성 검증
|
||||
* - 에러 상태 관리
|
||||
* - 폼 제출 처리
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import type {
|
||||
FormState,
|
||||
FormData,
|
||||
FormValue,
|
||||
DynamicSection,
|
||||
DynamicField,
|
||||
UseDynamicFormStateReturn,
|
||||
ValidationRules,
|
||||
FIELD_TYPE_DEFAULTS,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* 폼 구조에서 초기 값 생성
|
||||
*/
|
||||
function buildInitialValues(
|
||||
sections: DynamicSection[],
|
||||
existingValues?: FormData
|
||||
): FormData {
|
||||
const values: FormData = {};
|
||||
|
||||
for (const section of sections) {
|
||||
for (const field of section.fields) {
|
||||
// 기존 값이 있으면 사용, 없으면 기본값
|
||||
if (existingValues && existingValues[field.field_key] !== undefined) {
|
||||
values[field.field_key] = existingValues[field.field_key];
|
||||
} else if (field.default_value !== undefined) {
|
||||
values[field.field_key] = field.default_value;
|
||||
} else {
|
||||
// 필드 타입에 따른 기본값
|
||||
values[field.field_key] = getDefaultValueForType(field.field_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* 필드 타입에 따른 기본값
|
||||
*/
|
||||
function getDefaultValueForType(fieldType: string): FormValue {
|
||||
switch (fieldType) {
|
||||
case 'textbox':
|
||||
case 'textarea':
|
||||
case 'dropdown':
|
||||
case 'searchable-dropdown':
|
||||
return '';
|
||||
case 'number':
|
||||
case 'currency':
|
||||
return 0;
|
||||
case 'checkbox':
|
||||
case 'switch':
|
||||
return false;
|
||||
case 'date':
|
||||
case 'date-range':
|
||||
case 'file':
|
||||
case 'custom:drawing-canvas':
|
||||
case 'custom:bending-detail-table':
|
||||
case 'custom:bom-table':
|
||||
return null;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 필드 유효성 검증
|
||||
*/
|
||||
function validateField(
|
||||
field: DynamicField,
|
||||
value: FormValue
|
||||
): string | undefined {
|
||||
// 필수 필드 검사
|
||||
if (field.is_required) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return `${field.field_name}은(는) 필수 입력 항목입니다.`;
|
||||
}
|
||||
}
|
||||
|
||||
// 값이 없으면 추가 검증 스킵
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rules = field.validation_rules;
|
||||
if (!rules) return undefined;
|
||||
|
||||
// 문자열 검증
|
||||
if (typeof value === 'string') {
|
||||
if (rules.minLength && value.length < rules.minLength) {
|
||||
return `최소 ${rules.minLength}자 이상 입력해주세요.`;
|
||||
}
|
||||
if (rules.maxLength && value.length > rules.maxLength) {
|
||||
return `최대 ${rules.maxLength}자까지 입력 가능합니다.`;
|
||||
}
|
||||
if (rules.pattern) {
|
||||
const regex = new RegExp(rules.pattern);
|
||||
if (!regex.test(value)) {
|
||||
return rules.patternMessage || '입력 형식이 올바르지 않습니다.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 숫자 검증
|
||||
if (typeof value === 'number') {
|
||||
if (rules.min !== undefined && value < rules.min) {
|
||||
return `최소 ${rules.min} 이상이어야 합니다.`;
|
||||
}
|
||||
if (rules.max !== undefined && value > rules.max) {
|
||||
return `최대 ${rules.max} 이하여야 합니다.`;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 폼 유효성 검증
|
||||
*/
|
||||
function validateForm(
|
||||
sections: DynamicSection[],
|
||||
values: FormData
|
||||
): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
for (const section of sections) {
|
||||
for (const field of section.fields) {
|
||||
const error = validateField(field, values[field.field_key]);
|
||||
if (error) {
|
||||
errors[field.field_key] = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
interface UseDynamicFormStateOptions {
|
||||
sections: DynamicSection[];
|
||||
initialValues?: FormData;
|
||||
onSubmit?: (data: FormData) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* useDynamicFormState Hook
|
||||
*
|
||||
* @param options - 훅 옵션
|
||||
* @returns 폼 상태 및 조작 함수
|
||||
*
|
||||
* @example
|
||||
* const { state, setValue, handleSubmit, validate } = useDynamicFormState({
|
||||
* sections: formStructure.sections,
|
||||
* initialValues: existingItem,
|
||||
* onSubmit: async (data) => {
|
||||
* await saveItem(data);
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useDynamicFormState(
|
||||
options: UseDynamicFormStateOptions
|
||||
): UseDynamicFormStateReturn {
|
||||
const { sections, initialValues, onSubmit } = options;
|
||||
|
||||
// 초기 상태 생성
|
||||
const [state, setState] = useState<FormState>(() => ({
|
||||
values: buildInitialValues(sections, initialValues),
|
||||
errors: {},
|
||||
touched: {},
|
||||
isSubmitting: false,
|
||||
isValid: true,
|
||||
}));
|
||||
|
||||
/**
|
||||
* 단일 필드 값 설정
|
||||
*/
|
||||
const setValue = useCallback((fieldKey: string, value: FormValue) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
values: {
|
||||
...prev.values,
|
||||
[fieldKey]: value,
|
||||
},
|
||||
// 값 변경 시 해당 필드 에러 클리어
|
||||
errors: {
|
||||
...prev.errors,
|
||||
[fieldKey]: undefined as unknown as string,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 여러 필드 값 일괄 설정
|
||||
*/
|
||||
const setValues = useCallback((values: FormData) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
values: {
|
||||
...prev.values,
|
||||
...values,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 필드 에러 설정
|
||||
*/
|
||||
const setError = useCallback((fieldKey: string, message: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
errors: {
|
||||
...prev.errors,
|
||||
[fieldKey]: message,
|
||||
},
|
||||
isValid: false,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 필드 에러 클리어
|
||||
*/
|
||||
const clearError = useCallback((fieldKey: string) => {
|
||||
setState((prev) => {
|
||||
const newErrors = { ...prev.errors };
|
||||
delete newErrors[fieldKey];
|
||||
return {
|
||||
...prev,
|
||||
errors: newErrors,
|
||||
isValid: Object.keys(newErrors).length === 0,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 필드 touched 상태 설정
|
||||
*/
|
||||
const setTouched = useCallback((fieldKey: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
touched: {
|
||||
...prev.touched,
|
||||
[fieldKey]: true,
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 전체 폼 유효성 검증
|
||||
*/
|
||||
const validate = useCallback((): boolean => {
|
||||
const errors = validateForm(sections, state.values);
|
||||
const isValid = Object.keys(errors).length === 0;
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
errors,
|
||||
isValid,
|
||||
}));
|
||||
|
||||
return isValid;
|
||||
}, [sections, state.values]);
|
||||
|
||||
/**
|
||||
* 폼 리셋
|
||||
*/
|
||||
const reset = useCallback(
|
||||
(resetValues?: FormData) => {
|
||||
setState({
|
||||
values: buildInitialValues(sections, resetValues || initialValues),
|
||||
errors: {},
|
||||
touched: {},
|
||||
isSubmitting: false,
|
||||
isValid: true,
|
||||
});
|
||||
},
|
||||
[sections, initialValues]
|
||||
);
|
||||
|
||||
/**
|
||||
* 폼 제출 핸들러 생성
|
||||
*/
|
||||
const handleSubmit = useCallback(
|
||||
(submitFn: (data: FormData) => Promise<void>) => {
|
||||
return async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 유효성 검증
|
||||
const isValid = validate();
|
||||
if (!isValid) {
|
||||
console.warn('[useDynamicFormState] Form validation failed');
|
||||
return;
|
||||
}
|
||||
|
||||
// 제출 시작
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isSubmitting: true,
|
||||
}));
|
||||
|
||||
try {
|
||||
await submitFn(state.values);
|
||||
} catch (error) {
|
||||
console.error('[useDynamicFormState] Submit error:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isSubmitting: false,
|
||||
}));
|
||||
}
|
||||
};
|
||||
},
|
||||
[validate, state.values]
|
||||
);
|
||||
|
||||
return {
|
||||
state,
|
||||
setValue,
|
||||
setValues,
|
||||
setError,
|
||||
clearError,
|
||||
setTouched,
|
||||
validate,
|
||||
reset,
|
||||
handleSubmit,
|
||||
};
|
||||
}
|
||||
|
||||
export default useDynamicFormState;
|
||||
995
src/components/items/DynamicItemForm/hooks/useFormStructure.ts
Normal file
995
src/components/items/DynamicItemForm/hooks/useFormStructure.ts
Normal file
@@ -0,0 +1,995 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user