"use client"; /** * UnifiedFormContext * * Unified 컴포넌트들이 폼 상태를 공유하고 * 조건부 로직, 저장/검증/초기화 등의 폼 액션을 처리할 수 있도록 하는 Context * * 레거시 컴포넌트와의 호환성을 유지하면서 새로운 기능을 제공합니다. */ import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from "react"; import { ConditionalConfig, CascadingConfig } from "@/types/unified-components"; import { ValidationRule } from "@/types/unified-core"; import type { FormStatus, FieldError, FieldState, SubmitConfig, SubmitResult, ValidationResult, FormEventDetail, } from "@/types/unified-form"; // ===== 레거시 타입 호환 (기존 코드와 호환) ===== export interface FormFieldState { value: unknown; disabled?: boolean; visible?: boolean; error?: string; } export interface FormState { [fieldId: string]: FormFieldState; } // ===== 확장된 Context 타입 ===== export interface UnifiedFormContextValue { // === 기존 기능 (하위 호환) === formData: Record; fieldStates: FormState; getValue: (fieldId: string) => unknown; setValue: (fieldId: string, value: unknown) => void; setValues: (values: Record) => void; evaluateCondition: (fieldId: string, config?: ConditionalConfig) => { visible: boolean; disabled: boolean; }; getCascadingFilter: (config?: CascadingConfig) => unknown; registerField: (fieldId: string, initialValue?: unknown) => void; unregisterField: (fieldId: string) => void; // === 새로운 기능 === // 원본 데이터 (수정 모드) originalData: Record; // 폼 상태 status: FormStatus; errors: FieldError[]; // 폼 액션 submit: (config?: Partial) => Promise; reset: () => void; validate: (fieldIds?: string[]) => Promise; clear: () => void; // 초기 데이터 설정 (수정 모드 진입) setInitialData: (data: Record) => void; // 에러 관리 setFieldError: (fieldId: string, error: string, type?: FieldError["type"]) => void; clearFieldError: (fieldId: string) => void; clearAllErrors: () => void; // dirty 체크 getChangedFields: () => string[]; hasChanges: () => boolean; // 리피터 데이터 관리 getRepeaterData: (fieldName: string) => unknown[]; setRepeaterData: (fieldName: string, data: unknown[]) => void; addRepeaterRow: (fieldName: string, row: Record) => void; updateRepeaterRow: (fieldName: string, index: number, row: Record) => void; deleteRepeaterRow: (fieldName: string, index: number) => void; } // ===== Context 생성 ===== const UnifiedFormContext = createContext(null); // ===== 조건 평가 함수 ===== function evaluateOperator( fieldValue: unknown, operator: ConditionalConfig["operator"], conditionValue: unknown ): boolean { switch (operator) { case "=": return fieldValue === conditionValue; case "!=": return fieldValue !== conditionValue; case ">": return Number(fieldValue) > Number(conditionValue); case "<": return Number(fieldValue) < Number(conditionValue); case "in": if (Array.isArray(conditionValue)) { return conditionValue.includes(fieldValue); } return false; case "notIn": if (Array.isArray(conditionValue)) { return !conditionValue.includes(fieldValue); } return true; case "isEmpty": return fieldValue === null || fieldValue === undefined || fieldValue === "" || (Array.isArray(fieldValue) && fieldValue.length === 0); case "isNotEmpty": return fieldValue !== null && fieldValue !== undefined && fieldValue !== "" && !(Array.isArray(fieldValue) && fieldValue.length === 0); default: return true; } } // ===== 초기 상태 ===== const initialFormStatus: FormStatus = { isSubmitting: false, isValidating: false, isDirty: false, isValid: true, isLoading: false, submitCount: 0, }; // ===== Provider Props ===== interface UnifiedFormProviderProps { children: React.ReactNode; initialValues?: Record; onChange?: (formData: Record) => void; // 새로운 Props submitConfig?: SubmitConfig; onSubmit?: (data: Record, config: SubmitConfig) => Promise; onError?: (errors: FieldError[]) => void; onReset?: () => void; // 레거시 호환성 emitLegacyEvents?: boolean; // beforeFormSave 등 레거시 이벤트 발생 여부 (기본: true) } // ===== Provider 컴포넌트 ===== export function UnifiedFormProvider({ children, initialValues = {}, onChange, submitConfig: defaultSubmitConfig, onSubmit, onError, onReset, emitLegacyEvents = true, }: UnifiedFormProviderProps) { // 기존 상태 const [formData, setFormData] = useState>(initialValues); const [fieldStates, setFieldStates] = useState({}); // 새로운 상태 const [originalData, setOriginalData] = useState>(initialValues); const [status, setStatus] = useState(initialFormStatus); const [errors, setErrors] = useState([]); // 필드별 검증 규칙 저장 const validationRulesRef = useRef>(new Map()); // ===== 기존 기능 ===== const getValue = useCallback((fieldId: string): unknown => { return formData[fieldId]; }, [formData]); const setValue = useCallback((fieldId: string, value: unknown) => { setFormData(prev => { const newData = { ...prev, [fieldId]: value }; // dirty 상태 업데이트 setStatus(s => ({ ...s, isDirty: true })); onChange?.(newData); return newData; }); }, [onChange]); const setValues = useCallback((values: Record) => { setFormData(prev => { const newData = { ...prev, ...values }; setStatus(s => ({ ...s, isDirty: true })); onChange?.(newData); return newData; }); }, [onChange]); const evaluateCondition = useCallback(( fieldId: string, config?: ConditionalConfig ): { visible: boolean; disabled: boolean } => { if (!config || !config.enabled) { return { visible: true, disabled: false }; } const { field, operator, value, action } = config; const fieldValue = formData[field]; const conditionMet = evaluateOperator(fieldValue, operator, value); switch (action) { case "show": return { visible: conditionMet, disabled: false }; case "hide": return { visible: !conditionMet, disabled: false }; case "enable": return { visible: true, disabled: !conditionMet }; case "disable": return { visible: true, disabled: conditionMet }; default: return { visible: true, disabled: false }; } }, [formData]); const getCascadingFilter = useCallback((config?: CascadingConfig): unknown => { if (!config) return undefined; return formData[config.parentField]; }, [formData]); const registerField = useCallback((fieldId: string, initialValue?: unknown) => { if (initialValue !== undefined && formData[fieldId] === undefined) { setFormData(prev => ({ ...prev, [fieldId]: initialValue })); } setFieldStates(prev => ({ ...prev, [fieldId]: { value: initialValue, visible: true, disabled: false }, })); }, [formData]); const unregisterField = useCallback((fieldId: string) => { setFieldStates(prev => { const next = { ...prev }; delete next[fieldId]; return next; }); validationRulesRef.current.delete(fieldId); }, []); // ===== 새로운 기능: 폼 액션 ===== // 검증 const validate = useCallback(async (fieldIds?: string[]): Promise => { setStatus(s => ({ ...s, isValidating: true })); const newErrors: FieldError[] = []; const fieldsToValidate = fieldIds || Array.from(validationRulesRef.current.keys()); for (const fieldId of fieldsToValidate) { const rules = validationRulesRef.current.get(fieldId); if (!rules) continue; const value = formData[fieldId]; for (const rule of rules) { let isValid = true; switch (rule.type) { case "required": isValid = value !== null && value !== undefined && value !== ""; break; case "minLength": isValid = typeof value === "string" && value.length >= (rule.value as number); break; case "maxLength": isValid = typeof value === "string" && value.length <= (rule.value as number); break; case "min": isValid = typeof value === "number" && value >= (rule.value as number); break; case "max": isValid = typeof value === "number" && value <= (rule.value as number); break; case "pattern": isValid = typeof value === "string" && new RegExp(rule.value as string).test(value); break; case "email": isValid = typeof value === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); break; case "url": isValid = typeof value === "string" && /^https?:\/\/.+/.test(value); break; } if (!isValid) { newErrors.push({ fieldId, message: rule.message, type: rule.type === "required" ? "required" : "format", }); break; // 첫 번째 에러만 기록 } } } setErrors(newErrors); setStatus(s => ({ ...s, isValidating: false, isValid: newErrors.length === 0 })); return { valid: newErrors.length === 0, errors: newErrors }; }, [formData]); // 저장 const submit = useCallback(async (config?: Partial): Promise => { const finalConfig = { ...defaultSubmitConfig, ...config } as SubmitConfig; setStatus(s => ({ ...s, isSubmitting: true, submitCount: s.submitCount + 1 })); try { // 1. 검증 if (finalConfig.validateBeforeSubmit !== false) { const validation = await validate(); if (!validation.valid) { onError?.(validation.errors); return { success: false, error: "검증 실패", errors: validation.errors }; } } // 2. 레거시 이벤트 발생 (리피터 데이터 수집) let collectedData = { ...formData }; if (emitLegacyEvents && typeof window !== "undefined") { const eventDetail: FormEventDetail = { formData: {} }; const legacyEvent = new CustomEvent("beforeFormSave", { detail: eventDetail }); window.dispatchEvent(legacyEvent); // 이벤트에서 수집된 데이터 병합 (리피터 등) collectedData = { ...collectedData, ...eventDetail.formData }; } // 3. beforeSubmit 콜백 if (finalConfig.onBeforeSubmit) { collectedData = await finalConfig.onBeforeSubmit(collectedData); } // 4. 추가 데이터 병합 if (finalConfig.additionalData) { collectedData = { ...collectedData, ...finalConfig.additionalData }; } // 5. 저장 실행 let result: SubmitResult; if (onSubmit) { result = await onSubmit(collectedData, finalConfig); } else { // 기본 저장 로직 (API 호출) // 실제 구현은 외부에서 onSubmit으로 제공 result = { success: true, data: collectedData }; } // 6. 성공 시 처리 if (result.success) { setOriginalData({ ...formData }); setStatus(s => ({ ...s, isDirty: false })); // afterFormSave 이벤트 발생 if (emitLegacyEvents && typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("afterFormSave", { detail: { success: true, data: result.data } })); } // afterSubmit 콜백 finalConfig.onAfterSubmit?.(result); } return result; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "저장 중 오류 발생"; return { success: false, error: errorMessage }; } finally { setStatus(s => ({ ...s, isSubmitting: false })); } }, [formData, defaultSubmitConfig, validate, onSubmit, onError, emitLegacyEvents]); // 초기화 (원본 데이터로 복원) const reset = useCallback(() => { setFormData({ ...originalData }); setErrors([]); setStatus(s => ({ ...s, isDirty: false, isValid: true })); onReset?.(); }, [originalData, onReset]); // 비우기 const clear = useCallback(() => { setFormData({}); setErrors([]); setStatus(s => ({ ...s, isDirty: true, isValid: true })); }, []); // 초기 데이터 설정 (수정 모드 진입) const setInitialData = useCallback((data: Record) => { setFormData(data); setOriginalData(data); setStatus(s => ({ ...s, isDirty: false })); }, []); // ===== 에러 관리 ===== const setFieldError = useCallback((fieldId: string, message: string, type: FieldError["type"] = "custom") => { setErrors(prev => { const filtered = prev.filter(e => e.fieldId !== fieldId); return [...filtered, { fieldId, message, type }]; }); setStatus(s => ({ ...s, isValid: false })); }, []); const clearFieldError = useCallback((fieldId: string) => { setErrors(prev => { const filtered = prev.filter(e => e.fieldId !== fieldId); return filtered; }); }, []); const clearAllErrors = useCallback(() => { setErrors([]); setStatus(s => ({ ...s, isValid: true })); }, []); // ===== dirty 체크 ===== const getChangedFields = useCallback((): string[] => { const changed: string[] = []; const allKeys = new Set([...Object.keys(formData), ...Object.keys(originalData)]); for (const key of allKeys) { if (JSON.stringify(formData[key]) !== JSON.stringify(originalData[key])) { changed.push(key); } } return changed; }, [formData, originalData]); const hasChanges = useCallback((): boolean => { return JSON.stringify(formData) !== JSON.stringify(originalData); }, [formData, originalData]); // ===== 리피터 데이터 관리 ===== const getRepeaterData = useCallback((fieldName: string): unknown[] => { const data = formData[fieldName]; if (Array.isArray(data)) return data; if (typeof data === "string") { try { return JSON.parse(data); } catch { return []; } } return []; }, [formData]); const setRepeaterData = useCallback((fieldName: string, data: unknown[]) => { setValue(fieldName, data); }, [setValue]); const addRepeaterRow = useCallback((fieldName: string, row: Record) => { const current = getRepeaterData(fieldName); setValue(fieldName, [...current, row]); }, [getRepeaterData, setValue]); const updateRepeaterRow = useCallback((fieldName: string, index: number, row: Record) => { const current = getRepeaterData(fieldName) as Record[]; const updated = [...current]; updated[index] = { ...updated[index], ...row }; setValue(fieldName, updated); }, [getRepeaterData, setValue]); const deleteRepeaterRow = useCallback((fieldName: string, index: number) => { const current = getRepeaterData(fieldName); const updated = current.filter((_, i) => i !== index); setValue(fieldName, updated); }, [getRepeaterData, setValue]); // ===== Context 값 ===== const contextValue = useMemo(() => ({ // 기존 기능 formData, fieldStates, getValue, setValue, setValues, evaluateCondition, getCascadingFilter, registerField, unregisterField, // 새로운 기능 originalData, status, errors, submit, reset, validate, clear, setInitialData, setFieldError, clearFieldError, clearAllErrors, getChangedFields, hasChanges, getRepeaterData, setRepeaterData, addRepeaterRow, updateRepeaterRow, deleteRepeaterRow, }), [ formData, fieldStates, getValue, setValue, setValues, evaluateCondition, getCascadingFilter, registerField, unregisterField, originalData, status, errors, submit, reset, validate, clear, setInitialData, setFieldError, clearFieldError, clearAllErrors, getChangedFields, hasChanges, getRepeaterData, setRepeaterData, addRepeaterRow, updateRepeaterRow, deleteRepeaterRow, ]); return ( {children} ); } // ===== 커스텀 훅 ===== /** * UnifiedForm 컨텍스트 사용 (Context가 없으면 에러) */ export function useUnifiedForm(): UnifiedFormContextValue { const context = useContext(UnifiedFormContext); if (!context) { throw new Error("useUnifiedForm must be used within UnifiedFormProvider"); } return context; } /** * UnifiedForm 컨텍스트 사용 (Context가 없어도 에러 안 남, null 반환) * 레거시 호환성을 위해 사용 */ export function useUnifiedFormOptional(): UnifiedFormContextValue | null { return useContext(UnifiedFormContext); } /** * 개별 필드 훅 - 조건부 상태와 값을 한 번에 관리 */ export function useUnifiedField( fieldId: string, conditional?: ConditionalConfig ): { value: unknown; setValue: (value: unknown) => void; visible: boolean; disabled: boolean; error?: FieldError; } { const { getValue, setValue, evaluateCondition, errors } = useUnifiedForm(); const value = getValue(fieldId); const { visible, disabled } = evaluateCondition(fieldId, conditional); const error = errors.find(e => e.fieldId === fieldId); const handleSetValue = useCallback((newValue: unknown) => { setValue(fieldId, newValue); }, [fieldId, setValue]); return { value, setValue: handleSetValue, visible, disabled, error, }; } /** * 연쇄 선택 훅 - 부모 필드 값에 따라 옵션 필터링 */ export function useCascadingOptions( options: T[], cascading?: CascadingConfig ): T[] { const { getCascadingFilter } = useUnifiedForm(); if (!cascading) return options; const parentValue = getCascadingFilter(cascading); if (parentValue === undefined || parentValue === null || parentValue === "") { return []; } return options.filter(opt => opt.parentValue === parentValue); } /** * 폼 액션 훅 - 저장/검증/초기화 등 액션에만 접근 */ export function useFormActions() { const { submit, reset, validate, clear, hasChanges, status, errors } = useUnifiedForm(); return { submit, reset, validate, clear, hasChanges, isSubmitting: status.isSubmitting, isValidating: status.isValidating, isDirty: status.isDirty, isValid: status.isValid, errors, }; } /** * 리피터 데이터 훅 - 특정 리피터 필드 관리 */ export function useRepeaterField = Record>( fieldName: string ) { const { getRepeaterData, setRepeaterData, addRepeaterRow, updateRepeaterRow, deleteRepeaterRow } = useUnifiedForm(); const data = getRepeaterData(fieldName) as T[]; return { data, setData: (newData: T[]) => setRepeaterData(fieldName, newData), addRow: (row: T) => addRepeaterRow(fieldName, row), updateRow: (index: number, row: Partial) => updateRepeaterRow(fieldName, index, row as Record), deleteRow: (index: number) => deleteRepeaterRow(fieldName, index), count: data.length, }; } export default UnifiedFormContext;