2025-12-19 15:44:38 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* UnifiedFormContext
|
|
|
|
|
*
|
|
|
|
|
* Unified 컴포넌트들이 폼 상태를 공유하고
|
2026-01-15 09:50:33 +09:00
|
|
|
* 조건부 로직, 저장/검증/초기화 등의 폼 액션을 처리할 수 있도록 하는 Context
|
|
|
|
|
*
|
|
|
|
|
* 레거시 컴포넌트와의 호환성을 유지하면서 새로운 기능을 제공합니다.
|
2025-12-19 15:44:38 +09:00
|
|
|
*/
|
|
|
|
|
|
2026-01-15 09:50:33 +09:00
|
|
|
import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from "react";
|
2025-12-19 15:44:38 +09:00
|
|
|
import { ConditionalConfig, CascadingConfig } from "@/types/unified-components";
|
2026-01-15 09:50:33 +09:00
|
|
|
import { ValidationRule } from "@/types/unified-core";
|
|
|
|
|
import type {
|
|
|
|
|
FormStatus,
|
|
|
|
|
FieldError,
|
|
|
|
|
FieldState,
|
|
|
|
|
SubmitConfig,
|
|
|
|
|
SubmitResult,
|
|
|
|
|
ValidationResult,
|
|
|
|
|
FormEventDetail,
|
|
|
|
|
} from "@/types/unified-form";
|
2025-12-19 15:44:38 +09:00
|
|
|
|
2026-01-15 09:50:33 +09:00
|
|
|
// ===== 레거시 타입 호환 (기존 코드와 호환) =====
|
2025-12-19 15:44:38 +09:00
|
|
|
|
|
|
|
|
export interface FormFieldState {
|
|
|
|
|
value: unknown;
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
visible?: boolean;
|
|
|
|
|
error?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface FormState {
|
|
|
|
|
[fieldId: string]: FormFieldState;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 09:50:33 +09:00
|
|
|
// ===== 확장된 Context 타입 =====
|
|
|
|
|
|
2025-12-19 15:44:38 +09:00
|
|
|
export interface UnifiedFormContextValue {
|
2026-01-15 09:50:33 +09:00
|
|
|
// === 기존 기능 (하위 호환) ===
|
2025-12-19 15:44:38 +09:00
|
|
|
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;
|
2026-01-15 09:50:33 +09:00
|
|
|
|
|
|
|
|
// === 새로운 기능 ===
|
|
|
|
|
|
|
|
|
|
// 원본 데이터 (수정 모드)
|
|
|
|
|
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;
|
2025-12-19 15:44:38 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 09:50:33 +09:00
|
|
|
// ===== 초기 상태 =====
|
|
|
|
|
|
|
|
|
|
const initialFormStatus: FormStatus = {
|
|
|
|
|
isSubmitting: false,
|
|
|
|
|
isValidating: false,
|
|
|
|
|
isDirty: false,
|
|
|
|
|
isValid: true,
|
|
|
|
|
isLoading: false,
|
|
|
|
|
submitCount: 0,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ===== Provider Props =====
|
2025-12-19 15:44:38 +09:00
|
|
|
|
|
|
|
|
interface UnifiedFormProviderProps {
|
|
|
|
|
children: React.ReactNode;
|
|
|
|
|
initialValues?: Record<string, unknown>;
|
|
|
|
|
onChange?: (formData: Record<string, unknown>) => void;
|
2026-01-15 09:50:33 +09:00
|
|
|
|
|
|
|
|
// 새로운 Props
|
|
|
|
|
submitConfig?: SubmitConfig;
|
|
|
|
|
onSubmit?: (data: Record<string, unknown>, config: SubmitConfig) => Promise<SubmitResult>;
|
|
|
|
|
onError?: (errors: FieldError[]) => void;
|
|
|
|
|
onReset?: () => void;
|
|
|
|
|
|
|
|
|
|
// 레거시 호환성
|
|
|
|
|
emitLegacyEvents?: boolean; // beforeFormSave 등 레거시 이벤트 발생 여부 (기본: true)
|
2025-12-19 15:44:38 +09:00
|
|
|
}
|
|
|
|
|
|
2026-01-15 09:50:33 +09:00
|
|
|
// ===== Provider 컴포넌트 =====
|
|
|
|
|
|
2025-12-19 15:44:38 +09:00
|
|
|
export function UnifiedFormProvider({
|
|
|
|
|
children,
|
|
|
|
|
initialValues = {},
|
|
|
|
|
onChange,
|
2026-01-15 09:50:33 +09:00
|
|
|
submitConfig: defaultSubmitConfig,
|
|
|
|
|
onSubmit,
|
|
|
|
|
onError,
|
|
|
|
|
onReset,
|
|
|
|
|
emitLegacyEvents = true,
|
2025-12-19 15:44:38 +09:00
|
|
|
}: UnifiedFormProviderProps) {
|
2026-01-15 09:50:33 +09:00
|
|
|
// 기존 상태
|
2025-12-19 15:44:38 +09:00
|
|
|
const [formData, setFormData] = useState<Record<string, unknown>>(initialValues);
|
|
|
|
|
const [fieldStates, setFieldStates] = useState<FormState>({});
|
2026-01-15 12:22:45 +09:00
|
|
|
|
2026-01-15 09:50:33 +09:00
|
|
|
// 새로운 상태
|
|
|
|
|
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());
|
|
|
|
|
|
|
|
|
|
// ===== 기존 기능 =====
|
2025-12-19 15:44:38 +09:00
|
|
|
|
|
|
|
|
const getValue = useCallback((fieldId: string): unknown => {
|
|
|
|
|
return formData[fieldId];
|
|
|
|
|
}, [formData]);
|
|
|
|
|
|
|
|
|
|
const setValue = useCallback((fieldId: string, value: unknown) => {
|
|
|
|
|
setFormData(prev => {
|
|
|
|
|
const newData = { ...prev, [fieldId]: value };
|
2026-01-15 09:50:33 +09:00
|
|
|
|
|
|
|
|
// dirty 상태 업데이트
|
|
|
|
|
setStatus(s => ({ ...s, isDirty: true }));
|
|
|
|
|
|
2025-12-19 15:44:38 +09:00
|
|
|
onChange?.(newData);
|
|
|
|
|
return newData;
|
|
|
|
|
});
|
|
|
|
|
}, [onChange]);
|
|
|
|
|
|
|
|
|
|
const setValues = useCallback((values: Record<string, unknown>) => {
|
|
|
|
|
setFormData(prev => {
|
|
|
|
|
const newData = { ...prev, ...values };
|
2026-01-15 09:50:33 +09:00
|
|
|
setStatus(s => ({ ...s, isDirty: true }));
|
2025-12-19 15:44:38 +09:00
|
|
|
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;
|
|
|
|
|
});
|
2026-01-15 09:50:33 +09:00
|
|
|
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 }));
|
2025-12-19 15:44:38 +09:00
|
|
|
}, []);
|
|
|
|
|
|
2026-01-15 09:50:33 +09:00
|
|
|
// ===== 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 값 =====
|
|
|
|
|
|
2025-12-19 15:44:38 +09:00
|
|
|
const contextValue = useMemo<UnifiedFormContextValue>(() => ({
|
2026-01-15 09:50:33 +09:00
|
|
|
// 기존 기능
|
2025-12-19 15:44:38 +09:00
|
|
|
formData,
|
|
|
|
|
fieldStates,
|
|
|
|
|
getValue,
|
|
|
|
|
setValue,
|
|
|
|
|
setValues,
|
|
|
|
|
evaluateCondition,
|
|
|
|
|
getCascadingFilter,
|
|
|
|
|
registerField,
|
|
|
|
|
unregisterField,
|
2026-01-15 09:50:33 +09:00
|
|
|
|
|
|
|
|
// 새로운 기능
|
|
|
|
|
originalData,
|
|
|
|
|
status,
|
|
|
|
|
errors,
|
|
|
|
|
submit,
|
|
|
|
|
reset,
|
|
|
|
|
validate,
|
|
|
|
|
clear,
|
|
|
|
|
setInitialData,
|
|
|
|
|
setFieldError,
|
|
|
|
|
clearFieldError,
|
|
|
|
|
clearAllErrors,
|
|
|
|
|
getChangedFields,
|
|
|
|
|
hasChanges,
|
|
|
|
|
getRepeaterData,
|
|
|
|
|
setRepeaterData,
|
|
|
|
|
addRepeaterRow,
|
|
|
|
|
updateRepeaterRow,
|
|
|
|
|
deleteRepeaterRow,
|
2025-12-19 15:44:38 +09:00
|
|
|
}), [
|
|
|
|
|
formData,
|
|
|
|
|
fieldStates,
|
|
|
|
|
getValue,
|
|
|
|
|
setValue,
|
|
|
|
|
setValues,
|
|
|
|
|
evaluateCondition,
|
|
|
|
|
getCascadingFilter,
|
|
|
|
|
registerField,
|
|
|
|
|
unregisterField,
|
2026-01-15 09:50:33 +09:00
|
|
|
originalData,
|
|
|
|
|
status,
|
|
|
|
|
errors,
|
|
|
|
|
submit,
|
|
|
|
|
reset,
|
|
|
|
|
validate,
|
|
|
|
|
clear,
|
|
|
|
|
setInitialData,
|
|
|
|
|
setFieldError,
|
|
|
|
|
clearFieldError,
|
|
|
|
|
clearAllErrors,
|
|
|
|
|
getChangedFields,
|
|
|
|
|
hasChanges,
|
|
|
|
|
getRepeaterData,
|
|
|
|
|
setRepeaterData,
|
|
|
|
|
addRepeaterRow,
|
|
|
|
|
updateRepeaterRow,
|
|
|
|
|
deleteRepeaterRow,
|
2025-12-19 15:44:38 +09:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<UnifiedFormContext.Provider value={contextValue}>
|
|
|
|
|
{children}
|
|
|
|
|
</UnifiedFormContext.Provider>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== 커스텀 훅 =====
|
|
|
|
|
|
2026-01-15 09:50:33 +09:00
|
|
|
/**
|
|
|
|
|
* UnifiedForm 컨텍스트 사용 (Context가 없으면 에러)
|
|
|
|
|
*/
|
2025-12-19 15:44:38 +09:00
|
|
|
export function useUnifiedForm(): UnifiedFormContextValue {
|
|
|
|
|
const context = useContext(UnifiedFormContext);
|
|
|
|
|
if (!context) {
|
|
|
|
|
throw new Error("useUnifiedForm must be used within UnifiedFormProvider");
|
|
|
|
|
}
|
|
|
|
|
return context;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 09:50:33 +09:00
|
|
|
/**
|
|
|
|
|
* UnifiedForm 컨텍스트 사용 (Context가 없어도 에러 안 남, null 반환)
|
|
|
|
|
* 레거시 호환성을 위해 사용
|
|
|
|
|
*/
|
|
|
|
|
export function useUnifiedFormOptional(): UnifiedFormContextValue | null {
|
|
|
|
|
return useContext(UnifiedFormContext);
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-19 15:44:38 +09:00
|
|
|
/**
|
|
|
|
|
* 개별 필드 훅 - 조건부 상태와 값을 한 번에 관리
|
|
|
|
|
*/
|
|
|
|
|
export function useUnifiedField(
|
|
|
|
|
fieldId: string,
|
|
|
|
|
conditional?: ConditionalConfig
|
|
|
|
|
): {
|
|
|
|
|
value: unknown;
|
|
|
|
|
setValue: (value: unknown) => void;
|
|
|
|
|
visible: boolean;
|
|
|
|
|
disabled: boolean;
|
2026-01-15 09:50:33 +09:00
|
|
|
error?: FieldError;
|
2025-12-19 15:44:38 +09:00
|
|
|
} {
|
2026-01-15 09:50:33 +09:00
|
|
|
const { getValue, setValue, evaluateCondition, errors } = useUnifiedForm();
|
2025-12-19 15:44:38 +09:00
|
|
|
|
|
|
|
|
const value = getValue(fieldId);
|
|
|
|
|
const { visible, disabled } = evaluateCondition(fieldId, conditional);
|
2026-01-15 09:50:33 +09:00
|
|
|
const error = errors.find(e => e.fieldId === fieldId);
|
2025-12-19 15:44:38 +09:00
|
|
|
|
|
|
|
|
const handleSetValue = useCallback((newValue: unknown) => {
|
|
|
|
|
setValue(fieldId, newValue);
|
|
|
|
|
}, [fieldId, setValue]);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
value,
|
|
|
|
|
setValue: handleSetValue,
|
|
|
|
|
visible,
|
|
|
|
|
disabled,
|
2026-01-15 09:50:33 +09:00
|
|
|
error,
|
2025-12-19 15:44:38 +09:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 연쇄 선택 훅 - 부모 필드 값에 따라 옵션 필터링
|
|
|
|
|
*/
|
|
|
|
|
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 === "") {
|
2026-01-15 09:50:33 +09:00
|
|
|
return [];
|
2025-12-19 15:44:38 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return options.filter(opt => opt.parentValue === parentValue);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 09:50:33 +09:00
|
|
|
/**
|
|
|
|
|
* 폼 액션 훅 - 저장/검증/초기화 등 액션에만 접근
|
|
|
|
|
*/
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
2025-12-19 15:44:38 +09:00
|
|
|
|
2026-01-15 09:50:33 +09:00
|
|
|
/**
|
|
|
|
|
* 리피터 데이터 훅 - 특정 리피터 필드 관리
|
|
|
|
|
*/
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
2025-12-19 15:44:38 +09:00
|
|
|
|
2026-01-15 09:50:33 +09:00
|
|
|
export default UnifiedFormContext;
|