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:
byeongcheolryu
2025-12-04 12:48:41 +09:00
parent 0552b02ba9
commit 3be5714805
73 changed files with 9318 additions and 4353 deletions

View File

@@ -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';

View File

@@ -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,
};
}

View File

@@ -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;

View File

@@ -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