refactor: 품목관리 시스템 리팩토링 및 Sales 페이지 추가
DynamicItemForm 개선: - 품목코드 자동생성 기능 추가 - 조건부 표시 로직 개선 - 불필요한 컴포넌트 정리 (DynamicField, DynamicSection 등) - 타입 시스템 단순화 새로운 기능: - Sales 페이지 마이그레이션 (견적관리, 거래처관리) - 공통 컴포넌트 추가 (atoms, molecules, organisms, templates) 문서화: - 구현 문서 및 참조 문서 추가 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,3 @@
|
||||
/**
|
||||
* 동적 폼 훅 인덱스
|
||||
*/
|
||||
|
||||
export { useFormStructure, clearFormStructureCache, invalidateFormStructureCache } from './useFormStructure';
|
||||
export { useConditionalFields } from './useConditionalFields';
|
||||
export { useFormStructure } from './useFormStructure';
|
||||
export { useDynamicFormState } from './useDynamicFormState';
|
||||
export { useConditionalDisplay } from './useConditionalDisplay';
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 조건부 표시 훅
|
||||
*
|
||||
* display_condition을 기반으로 섹션/필드의 visibility를 결정
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type {
|
||||
DynamicFormStructure,
|
||||
DynamicFormData,
|
||||
DisplayCondition,
|
||||
FieldConditionConfig,
|
||||
} from '../types';
|
||||
|
||||
interface ConditionalDisplayResult {
|
||||
/** 섹션이 표시되어야 하는지 확인 */
|
||||
shouldShowSection: (sectionId: number) => boolean;
|
||||
/** 필드가 표시되어야 하는지 확인 */
|
||||
shouldShowField: (fieldId: number) => boolean;
|
||||
/** 조건부 표시 설정이 있는 트리거 필드 목록 */
|
||||
triggerFields: Array<{
|
||||
fieldKey: string;
|
||||
fieldId: number;
|
||||
condition: DisplayCondition;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 표시 훅
|
||||
*
|
||||
* @param structure - 폼 구조
|
||||
* @param formData - 현재 폼 데이터
|
||||
* @returns 조건부 표시 관련 함수들
|
||||
*/
|
||||
export function useConditionalDisplay(
|
||||
structure: DynamicFormStructure | null,
|
||||
formData: DynamicFormData
|
||||
): ConditionalDisplayResult {
|
||||
// 조건부 표시 설정이 있는 필드들 수집
|
||||
const triggerFields = useMemo(() => {
|
||||
if (!structure) return [];
|
||||
|
||||
const triggers: ConditionalDisplayResult['triggerFields'] = [];
|
||||
|
||||
// 모든 섹션의 필드 검사
|
||||
structure.sections.forEach((section) => {
|
||||
section.fields.forEach((dynamicField) => {
|
||||
const field = dynamicField.field;
|
||||
if (field.display_condition) {
|
||||
const condition = field.display_condition as DisplayCondition;
|
||||
triggers.push({
|
||||
fieldKey: field.field_key || `field_${field.id}`,
|
||||
fieldId: field.id,
|
||||
condition,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 직접 필드도 검사
|
||||
structure.directFields.forEach((dynamicField) => {
|
||||
const field = dynamicField.field;
|
||||
if (field.display_condition) {
|
||||
const condition = field.display_condition as DisplayCondition;
|
||||
triggers.push({
|
||||
fieldKey: field.field_key || `field_${field.id}`,
|
||||
fieldId: field.id,
|
||||
condition,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 디버깅: 조건부 표시 설정 확인
|
||||
console.log('[useConditionalDisplay] 트리거 필드 목록:', triggers.map(t => ({
|
||||
fieldKey: t.fieldKey,
|
||||
fieldId: t.fieldId,
|
||||
fieldConditions: t.condition.fieldConditions?.map(fc => ({
|
||||
expectedValue: fc.expectedValue,
|
||||
targetFieldIds: fc.targetFieldIds,
|
||||
targetSectionIds: fc.targetSectionIds,
|
||||
})),
|
||||
})));
|
||||
|
||||
return triggers;
|
||||
}, [structure]);
|
||||
|
||||
// 현재 활성화된 조건들 계산
|
||||
const activeConditions = useMemo(() => {
|
||||
const activeSectionIds = new Set<string>();
|
||||
const activeFieldIds = new Set<string>();
|
||||
|
||||
// 각 트리거 필드의 현재 값 확인
|
||||
triggerFields.forEach((trigger) => {
|
||||
const currentValue = formData[trigger.fieldKey];
|
||||
const condition = trigger.condition;
|
||||
|
||||
// fieldConditions 배열 순회
|
||||
if (condition.fieldConditions) {
|
||||
condition.fieldConditions.forEach((fc: FieldConditionConfig) => {
|
||||
// 현재 값과 기대값이 일치하는지 확인
|
||||
const isMatch = String(currentValue) === fc.expectedValue;
|
||||
|
||||
// 디버깅: 조건 매칭 확인
|
||||
console.log('[useConditionalDisplay] 조건 매칭 체크:', {
|
||||
triggerFieldKey: trigger.fieldKey,
|
||||
currentValue: String(currentValue),
|
||||
expectedValue: fc.expectedValue,
|
||||
isMatch,
|
||||
targetFieldIds: fc.targetFieldIds,
|
||||
});
|
||||
|
||||
if (isMatch) {
|
||||
// 일치하면 타겟 섹션/필드 활성화
|
||||
if (fc.targetSectionIds) {
|
||||
fc.targetSectionIds.forEach((id) => activeSectionIds.add(id));
|
||||
}
|
||||
if (fc.targetFieldIds) {
|
||||
fc.targetFieldIds.forEach((id) => activeFieldIds.add(id));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[useConditionalDisplay] 활성화된 필드 ID:', [...activeFieldIds]);
|
||||
|
||||
return { activeSectionIds, activeFieldIds };
|
||||
}, [triggerFields, formData]);
|
||||
|
||||
// 조건부 표시가 적용되는 섹션 ID 목록
|
||||
const conditionalSectionIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
|
||||
triggerFields.forEach((trigger) => {
|
||||
const condition = trigger.condition;
|
||||
if (condition.fieldConditions) {
|
||||
condition.fieldConditions.forEach((fc: FieldConditionConfig) => {
|
||||
if (fc.targetSectionIds) {
|
||||
fc.targetSectionIds.forEach((id) => ids.add(id));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return ids;
|
||||
}, [triggerFields]);
|
||||
|
||||
// 조건부 표시가 적용되는 필드 ID 목록
|
||||
const conditionalFieldIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
|
||||
triggerFields.forEach((trigger) => {
|
||||
const condition = trigger.condition;
|
||||
if (condition.fieldConditions) {
|
||||
condition.fieldConditions.forEach((fc: FieldConditionConfig) => {
|
||||
if (fc.targetFieldIds) {
|
||||
fc.targetFieldIds.forEach((id) => ids.add(id));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return ids;
|
||||
}, [triggerFields]);
|
||||
|
||||
// 섹션 표시 여부 확인
|
||||
const shouldShowSection = useMemo(() => {
|
||||
return (sectionId: number): boolean => {
|
||||
const sectionIdStr = String(sectionId);
|
||||
|
||||
// 이 섹션이 조건부 표시 대상인지 확인
|
||||
if (!conditionalSectionIds.has(sectionIdStr)) {
|
||||
// 조건부 표시 대상이 아니면 항상 표시
|
||||
return true;
|
||||
}
|
||||
|
||||
// 조건부 표시 대상이면 활성화된 섹션인지 확인
|
||||
return activeConditions.activeSectionIds.has(sectionIdStr);
|
||||
};
|
||||
}, [conditionalSectionIds, activeConditions]);
|
||||
|
||||
// 필드 표시 여부 확인
|
||||
const shouldShowField = useMemo(() => {
|
||||
return (fieldId: number): boolean => {
|
||||
const fieldIdStr = String(fieldId);
|
||||
|
||||
// 이 필드가 조건부 표시 대상인지 확인
|
||||
if (!conditionalFieldIds.has(fieldIdStr)) {
|
||||
// 조건부 표시 대상이 아니면 항상 표시
|
||||
return true;
|
||||
}
|
||||
|
||||
// 조건부 표시 대상이면 활성화된 필드인지 확인
|
||||
return activeConditions.activeFieldIds.has(fieldIdStr);
|
||||
};
|
||||
}, [conditionalFieldIds, activeConditions]);
|
||||
|
||||
return {
|
||||
shouldShowSection,
|
||||
shouldShowField,
|
||||
triggerFields,
|
||||
};
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -1,341 +1,188 @@
|
||||
/**
|
||||
* useDynamicFormState Hook
|
||||
* 동적 폼 상태 관리 훅
|
||||
*
|
||||
* 동적 폼의 상태 관리 훅
|
||||
* - 필드 값 관리
|
||||
* - 유효성 검증
|
||||
* - 에러 상태 관리
|
||||
* - 폼 제출 처리
|
||||
* - 밸리데이션
|
||||
* - 에러 관리
|
||||
* - 폼 제출
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import type {
|
||||
FormState,
|
||||
FormData,
|
||||
FormValue,
|
||||
DynamicSection,
|
||||
DynamicField,
|
||||
UseDynamicFormStateReturn,
|
||||
ValidationRules,
|
||||
FIELD_TYPE_DEFAULTS,
|
||||
DynamicFormData,
|
||||
DynamicFormErrors,
|
||||
DynamicFieldValue,
|
||||
UseDynamicFormStateResult,
|
||||
} from '../types';
|
||||
import type { ItemFieldResponse } from '@/types/item-master-api';
|
||||
|
||||
/**
|
||||
* 폼 구조에서 초기 값 생성
|
||||
*/
|
||||
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;
|
||||
initialData?: DynamicFormData
|
||||
): UseDynamicFormStateResult {
|
||||
const [formData, setFormData] = useState<DynamicFormData>(initialData || {});
|
||||
const [errors, setErrors] = useState<DynamicFormErrors>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 초기 상태 생성
|
||||
const [state, setState] = useState<FormState>(() => ({
|
||||
values: buildInitialValues(sections, initialValues),
|
||||
errors: {},
|
||||
touched: {},
|
||||
isSubmitting: false,
|
||||
isValid: true,
|
||||
}));
|
||||
|
||||
/**
|
||||
* 단일 필드 값 설정
|
||||
*/
|
||||
const setValue = useCallback((fieldKey: string, value: FormValue) => {
|
||||
setState((prev) => ({
|
||||
// 필드 값 설정
|
||||
const setFieldValue = useCallback((fieldKey: string, value: DynamicFieldValue) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
values: {
|
||||
...prev.values,
|
||||
[fieldKey]: value,
|
||||
},
|
||||
// 값 변경 시 해당 필드 에러 클리어
|
||||
errors: {
|
||||
...prev.errors,
|
||||
[fieldKey]: undefined as unknown as string,
|
||||
},
|
||||
[fieldKey]: value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 여러 필드 값 일괄 설정
|
||||
*/
|
||||
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 };
|
||||
// 값이 변경되면 해당 필드의 에러 제거
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[fieldKey];
|
||||
return {
|
||||
...prev,
|
||||
errors: newErrors,
|
||||
isValid: Object.keys(newErrors).length === 0,
|
||||
};
|
||||
return newErrors;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 필드 touched 상태 설정
|
||||
*/
|
||||
const setTouched = useCallback((fieldKey: string) => {
|
||||
setState((prev) => ({
|
||||
// 에러 설정
|
||||
const setError = useCallback((fieldKey: string, error: string) => {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
touched: {
|
||||
...prev.touched,
|
||||
[fieldKey]: true,
|
||||
},
|
||||
[fieldKey]: error,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 전체 폼 유효성 검증
|
||||
*/
|
||||
const validate = useCallback((): boolean => {
|
||||
const errors = validateForm(sections, state.values);
|
||||
const isValid = Object.keys(errors).length === 0;
|
||||
// 에러 제거
|
||||
const clearError = useCallback((fieldKey: string) => {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[fieldKey];
|
||||
return newErrors;
|
||||
});
|
||||
}, []);
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
errors,
|
||||
isValid,
|
||||
}));
|
||||
// 모든 에러 제거
|
||||
const clearAllErrors = useCallback(() => {
|
||||
setErrors({});
|
||||
}, []);
|
||||
|
||||
return isValid;
|
||||
}, [sections, state.values]);
|
||||
// 단일 필드 밸리데이션
|
||||
const validateField = useCallback(
|
||||
(field: ItemFieldResponse, value: DynamicFieldValue): string | null => {
|
||||
const fieldKey = field.field_key || `field_${field.id}`;
|
||||
|
||||
/**
|
||||
* 폼 리셋
|
||||
*/
|
||||
const reset = useCallback(
|
||||
(resetValues?: FormData) => {
|
||||
setState({
|
||||
values: buildInitialValues(sections, resetValues || initialValues),
|
||||
errors: {},
|
||||
touched: {},
|
||||
isSubmitting: false,
|
||||
isValid: true,
|
||||
});
|
||||
// 필수 필드 체크
|
||||
if (field.is_required) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return `${field.field_name}은(는) 필수 입력 항목입니다.`;
|
||||
}
|
||||
}
|
||||
|
||||
// 값이 없으면 추가 검증 스킵
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// validation_rules 적용
|
||||
const rules = field.validation_rules;
|
||||
if (rules) {
|
||||
// min 검증 (숫자)
|
||||
if (rules.min !== undefined && field.field_type === 'number') {
|
||||
const numValue = Number(value);
|
||||
if (numValue < rules.min) {
|
||||
return `${field.field_name}은(는) ${rules.min} 이상이어야 합니다.`;
|
||||
}
|
||||
}
|
||||
|
||||
// max 검증 (숫자)
|
||||
if (rules.max !== undefined && field.field_type === 'number') {
|
||||
const numValue = Number(value);
|
||||
if (numValue > rules.max) {
|
||||
return `${field.field_name}은(는) ${rules.max} 이하여야 합니다.`;
|
||||
}
|
||||
}
|
||||
|
||||
// minLength 검증 (문자열)
|
||||
if (rules.minLength !== undefined && typeof value === 'string') {
|
||||
if (value.length < rules.minLength) {
|
||||
return `${field.field_name}은(는) ${rules.minLength}자 이상이어야 합니다.`;
|
||||
}
|
||||
}
|
||||
|
||||
// maxLength 검증 (문자열)
|
||||
if (rules.maxLength !== undefined && typeof value === 'string') {
|
||||
if (value.length > rules.maxLength) {
|
||||
return `${field.field_name}은(는) ${rules.maxLength}자 이하여야 합니다.`;
|
||||
}
|
||||
}
|
||||
|
||||
// pattern 검증 (정규식)
|
||||
if (rules.pattern && typeof value === 'string') {
|
||||
const regex = new RegExp(rules.pattern);
|
||||
if (!regex.test(value)) {
|
||||
return rules.patternMessage || `${field.field_name}의 형식이 올바르지 않습니다.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[sections, initialValues]
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* 폼 제출 핸들러 생성
|
||||
*/
|
||||
// 전체 필드 밸리데이션
|
||||
const validateAll = useCallback(
|
||||
(fields: ItemFieldResponse[]): boolean => {
|
||||
const newErrors: DynamicFormErrors = {};
|
||||
let isValid = true;
|
||||
|
||||
for (const field of fields) {
|
||||
const fieldKey = field.field_key || `field_${field.id}`;
|
||||
const value = formData[fieldKey];
|
||||
const error = validateField(field, value);
|
||||
|
||||
if (error) {
|
||||
newErrors[fieldKey] = error;
|
||||
isValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return isValid;
|
||||
},
|
||||
[formData, validateField]
|
||||
);
|
||||
|
||||
// 폼 제출
|
||||
const handleSubmit = useCallback(
|
||||
(submitFn: (data: FormData) => Promise<void>) => {
|
||||
return async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
async (onSubmit: (data: DynamicFormData) => Promise<void>) => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
// 유효성 검증
|
||||
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,
|
||||
}));
|
||||
}
|
||||
};
|
||||
try {
|
||||
await onSubmit(formData);
|
||||
} catch (err) {
|
||||
console.error('폼 제출 실패:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[validate, state.values]
|
||||
[formData]
|
||||
);
|
||||
|
||||
// 폼 초기화
|
||||
const resetForm = useCallback((newInitialData?: DynamicFormData) => {
|
||||
setFormData(newInitialData || {});
|
||||
setErrors({});
|
||||
setIsSubmitting(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
setValue,
|
||||
setValues,
|
||||
formData,
|
||||
errors,
|
||||
isSubmitting,
|
||||
setFieldValue,
|
||||
setError,
|
||||
clearError,
|
||||
setTouched,
|
||||
validate,
|
||||
reset,
|
||||
clearAllErrors,
|
||||
validateField,
|
||||
validateAll,
|
||||
handleSubmit,
|
||||
resetForm,
|
||||
};
|
||||
}
|
||||
|
||||
export default useDynamicFormState;
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user