ERP-node/frontend/components/unified/UnifiedFormContext.tsx

694 lines
20 KiB
TypeScript

"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<string, unknown>;
fieldStates: FormState;
getValue: (fieldId: string) => unknown;
setValue: (fieldId: string, value: unknown) => void;
setValues: (values: Record<string, unknown>) => 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<string, unknown>;
// 폼 상태
status: FormStatus;
errors: FieldError[];
// 폼 액션
submit: (config?: Partial<SubmitConfig>) => Promise<SubmitResult>;
reset: () => void;
validate: (fieldIds?: string[]) => Promise<ValidationResult>;
clear: () => void;
// 초기 데이터 설정 (수정 모드 진입)
setInitialData: (data: Record<string, unknown>) => 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<string, unknown>) => void;
updateRepeaterRow: (fieldName: string, index: number, row: Record<string, unknown>) => void;
deleteRepeaterRow: (fieldName: string, index: number) => void;
}
// ===== Context 생성 =====
const UnifiedFormContext = createContext<UnifiedFormContextValue | null>(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<string, unknown>;
onChange?: (formData: Record<string, unknown>) => void;
// 새로운 Props
submitConfig?: SubmitConfig;
onSubmit?: (data: Record<string, unknown>, config: SubmitConfig) => Promise<SubmitResult>;
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<Record<string, unknown>>(initialValues);
const [fieldStates, setFieldStates] = useState<FormState>({});
// 새로운 상태
const [originalData, setOriginalData] = useState<Record<string, unknown>>(initialValues);
const [status, setStatus] = useState<FormStatus>(initialFormStatus);
const [errors, setErrors] = useState<FieldError[]>([]);
// 필드별 검증 규칙 저장
const validationRulesRef = useRef<Map<string, ValidationRule[]>>(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<string, unknown>) => {
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<ValidationResult> => {
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<SubmitConfig>): Promise<SubmitResult> => {
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<string, unknown>) => {
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<string, unknown>) => {
const current = getRepeaterData(fieldName);
setValue(fieldName, [...current, row]);
}, [getRepeaterData, setValue]);
const updateRepeaterRow = useCallback((fieldName: string, index: number, row: Record<string, unknown>) => {
const current = getRepeaterData(fieldName) as Record<string, unknown>[];
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<UnifiedFormContextValue>(() => ({
// 기존 기능
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 (
<UnifiedFormContext.Provider value={contextValue}>
{children}
</UnifiedFormContext.Provider>
);
}
// ===== 커스텀 훅 =====
/**
* 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<T extends { parentValue?: unknown }>(
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<T extends Record<string, unknown> = Record<string, unknown>>(
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<T>) => updateRepeaterRow(fieldName, index, row as Record<string, unknown>),
deleteRow: (index: number) => deleteRepeaterRow(fieldName, index),
count: data.length,
};
}
export default UnifiedFormContext;