폼 통합
This commit is contained in:
parent
08ea14eed7
commit
71af4dfc6b
|
|
@ -4,13 +4,25 @@
|
||||||
* UnifiedFormContext
|
* UnifiedFormContext
|
||||||
*
|
*
|
||||||
* Unified 컴포넌트들이 폼 상태를 공유하고
|
* Unified 컴포넌트들이 폼 상태를 공유하고
|
||||||
* 조건부 로직을 처리할 수 있도록 하는 Context
|
* 조건부 로직, 저장/검증/초기화 등의 폼 액션을 처리할 수 있도록 하는 Context
|
||||||
|
*
|
||||||
|
* 레거시 컴포넌트와의 호환성을 유지하면서 새로운 기능을 제공합니다.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, useCallback, useMemo } from "react";
|
import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from "react";
|
||||||
import { ConditionalConfig, CascadingConfig } from "@/types/unified-components";
|
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 {
|
export interface FormFieldState {
|
||||||
value: unknown;
|
value: unknown;
|
||||||
|
|
@ -23,28 +35,60 @@ export interface FormState {
|
||||||
[fieldId: string]: FormFieldState;
|
[fieldId: string]: FormFieldState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 확장된 Context 타입 =====
|
||||||
|
|
||||||
export interface UnifiedFormContextValue {
|
export interface UnifiedFormContextValue {
|
||||||
// 폼 상태
|
// === 기존 기능 (하위 호환) ===
|
||||||
formData: Record<string, unknown>;
|
formData: Record<string, unknown>;
|
||||||
fieldStates: FormState;
|
fieldStates: FormState;
|
||||||
|
|
||||||
// 값 관리
|
|
||||||
getValue: (fieldId: string) => unknown;
|
getValue: (fieldId: string) => unknown;
|
||||||
setValue: (fieldId: string, value: unknown) => void;
|
setValue: (fieldId: string, value: unknown) => void;
|
||||||
setValues: (values: Record<string, unknown>) => void;
|
setValues: (values: Record<string, unknown>) => void;
|
||||||
|
|
||||||
// 조건부 로직
|
|
||||||
evaluateCondition: (fieldId: string, config?: ConditionalConfig) => {
|
evaluateCondition: (fieldId: string, config?: ConditionalConfig) => {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 연쇄 관계
|
|
||||||
getCascadingFilter: (config?: CascadingConfig) => unknown;
|
getCascadingFilter: (config?: CascadingConfig) => unknown;
|
||||||
|
|
||||||
// 필드 등록
|
|
||||||
registerField: (fieldId: string, initialValue?: unknown) => void;
|
registerField: (fieldId: string, initialValue?: unknown) => void;
|
||||||
unregisterField: (fieldId: string) => 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 생성 =====
|
// ===== Context 생성 =====
|
||||||
|
|
@ -88,62 +132,97 @@ function evaluateOperator(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Provider 컴포넌트 =====
|
// ===== 초기 상태 =====
|
||||||
|
|
||||||
|
const initialFormStatus: FormStatus = {
|
||||||
|
isSubmitting: false,
|
||||||
|
isValidating: false,
|
||||||
|
isDirty: false,
|
||||||
|
isValid: true,
|
||||||
|
isLoading: false,
|
||||||
|
submitCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== Provider Props =====
|
||||||
|
|
||||||
interface UnifiedFormProviderProps {
|
interface UnifiedFormProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
initialValues?: Record<string, unknown>;
|
initialValues?: Record<string, unknown>;
|
||||||
onChange?: (formData: Record<string, unknown>) => void;
|
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({
|
export function UnifiedFormProvider({
|
||||||
children,
|
children,
|
||||||
initialValues = {},
|
initialValues = {},
|
||||||
onChange,
|
onChange,
|
||||||
|
submitConfig: defaultSubmitConfig,
|
||||||
|
onSubmit,
|
||||||
|
onError,
|
||||||
|
onReset,
|
||||||
|
emitLegacyEvents = true,
|
||||||
}: UnifiedFormProviderProps) {
|
}: UnifiedFormProviderProps) {
|
||||||
|
// 기존 상태
|
||||||
const [formData, setFormData] = useState<Record<string, unknown>>(initialValues);
|
const [formData, setFormData] = useState<Record<string, unknown>>(initialValues);
|
||||||
const [fieldStates, setFieldStates] = useState<FormState>({});
|
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 => {
|
const getValue = useCallback((fieldId: string): unknown => {
|
||||||
return formData[fieldId];
|
return formData[fieldId];
|
||||||
}, [formData]);
|
}, [formData]);
|
||||||
|
|
||||||
// 단일 값 설정
|
|
||||||
const setValue = useCallback((fieldId: string, value: unknown) => {
|
const setValue = useCallback((fieldId: string, value: unknown) => {
|
||||||
setFormData(prev => {
|
setFormData(prev => {
|
||||||
const newData = { ...prev, [fieldId]: value };
|
const newData = { ...prev, [fieldId]: value };
|
||||||
|
|
||||||
|
// dirty 상태 업데이트
|
||||||
|
setStatus(s => ({ ...s, isDirty: true }));
|
||||||
|
|
||||||
onChange?.(newData);
|
onChange?.(newData);
|
||||||
return newData;
|
return newData;
|
||||||
});
|
});
|
||||||
}, [onChange]);
|
}, [onChange]);
|
||||||
|
|
||||||
// 여러 값 한 번에 설정
|
|
||||||
const setValues = useCallback((values: Record<string, unknown>) => {
|
const setValues = useCallback((values: Record<string, unknown>) => {
|
||||||
setFormData(prev => {
|
setFormData(prev => {
|
||||||
const newData = { ...prev, ...values };
|
const newData = { ...prev, ...values };
|
||||||
|
setStatus(s => ({ ...s, isDirty: true }));
|
||||||
onChange?.(newData);
|
onChange?.(newData);
|
||||||
return newData;
|
return newData;
|
||||||
});
|
});
|
||||||
}, [onChange]);
|
}, [onChange]);
|
||||||
|
|
||||||
// 조건 평가
|
|
||||||
const evaluateCondition = useCallback((
|
const evaluateCondition = useCallback((
|
||||||
fieldId: string,
|
fieldId: string,
|
||||||
config?: ConditionalConfig
|
config?: ConditionalConfig
|
||||||
): { visible: boolean; disabled: boolean } => {
|
): { visible: boolean; disabled: boolean } => {
|
||||||
// 조건부 설정이 없으면 기본값 반환
|
|
||||||
if (!config || !config.enabled) {
|
if (!config || !config.enabled) {
|
||||||
return { visible: true, disabled: false };
|
return { visible: true, disabled: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { field, operator, value, action } = config;
|
const { field, operator, value, action } = config;
|
||||||
const fieldValue = formData[field];
|
const fieldValue = formData[field];
|
||||||
|
|
||||||
// 조건 평가
|
|
||||||
const conditionMet = evaluateOperator(fieldValue, operator, value);
|
const conditionMet = evaluateOperator(fieldValue, operator, value);
|
||||||
|
|
||||||
// 액션에 따른 결과
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "show":
|
case "show":
|
||||||
return { visible: conditionMet, disabled: false };
|
return { visible: conditionMet, disabled: false };
|
||||||
|
|
@ -158,13 +237,11 @@ export function UnifiedFormProvider({
|
||||||
}
|
}
|
||||||
}, [formData]);
|
}, [formData]);
|
||||||
|
|
||||||
// 연쇄 관계 필터값 가져오기
|
|
||||||
const getCascadingFilter = useCallback((config?: CascadingConfig): unknown => {
|
const getCascadingFilter = useCallback((config?: CascadingConfig): unknown => {
|
||||||
if (!config) return undefined;
|
if (!config) return undefined;
|
||||||
return formData[config.parentField];
|
return formData[config.parentField];
|
||||||
}, [formData]);
|
}, [formData]);
|
||||||
|
|
||||||
// 필드 등록
|
|
||||||
const registerField = useCallback((fieldId: string, initialValue?: unknown) => {
|
const registerField = useCallback((fieldId: string, initialValue?: unknown) => {
|
||||||
if (initialValue !== undefined && formData[fieldId] === undefined) {
|
if (initialValue !== undefined && formData[fieldId] === undefined) {
|
||||||
setFormData(prev => ({ ...prev, [fieldId]: initialValue }));
|
setFormData(prev => ({ ...prev, [fieldId]: initialValue }));
|
||||||
|
|
@ -175,17 +252,259 @@ export function UnifiedFormProvider({
|
||||||
}));
|
}));
|
||||||
}, [formData]);
|
}, [formData]);
|
||||||
|
|
||||||
// 필드 해제
|
|
||||||
const unregisterField = useCallback((fieldId: string) => {
|
const unregisterField = useCallback((fieldId: string) => {
|
||||||
setFieldStates(prev => {
|
setFieldStates(prev => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[fieldId];
|
delete next[fieldId];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
validationRulesRef.current.delete(fieldId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Context 값
|
// ===== 새로운 기능: 폼 액션 =====
|
||||||
|
|
||||||
|
// 검증
|
||||||
|
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>(() => ({
|
const contextValue = useMemo<UnifiedFormContextValue>(() => ({
|
||||||
|
// 기존 기능
|
||||||
formData,
|
formData,
|
||||||
fieldStates,
|
fieldStates,
|
||||||
getValue,
|
getValue,
|
||||||
|
|
@ -195,6 +514,26 @@ export function UnifiedFormProvider({
|
||||||
getCascadingFilter,
|
getCascadingFilter,
|
||||||
registerField,
|
registerField,
|
||||||
unregisterField,
|
unregisterField,
|
||||||
|
|
||||||
|
// 새로운 기능
|
||||||
|
originalData,
|
||||||
|
status,
|
||||||
|
errors,
|
||||||
|
submit,
|
||||||
|
reset,
|
||||||
|
validate,
|
||||||
|
clear,
|
||||||
|
setInitialData,
|
||||||
|
setFieldError,
|
||||||
|
clearFieldError,
|
||||||
|
clearAllErrors,
|
||||||
|
getChangedFields,
|
||||||
|
hasChanges,
|
||||||
|
getRepeaterData,
|
||||||
|
setRepeaterData,
|
||||||
|
addRepeaterRow,
|
||||||
|
updateRepeaterRow,
|
||||||
|
deleteRepeaterRow,
|
||||||
}), [
|
}), [
|
||||||
formData,
|
formData,
|
||||||
fieldStates,
|
fieldStates,
|
||||||
|
|
@ -205,6 +544,24 @@ export function UnifiedFormProvider({
|
||||||
getCascadingFilter,
|
getCascadingFilter,
|
||||||
registerField,
|
registerField,
|
||||||
unregisterField,
|
unregisterField,
|
||||||
|
originalData,
|
||||||
|
status,
|
||||||
|
errors,
|
||||||
|
submit,
|
||||||
|
reset,
|
||||||
|
validate,
|
||||||
|
clear,
|
||||||
|
setInitialData,
|
||||||
|
setFieldError,
|
||||||
|
clearFieldError,
|
||||||
|
clearAllErrors,
|
||||||
|
getChangedFields,
|
||||||
|
hasChanges,
|
||||||
|
getRepeaterData,
|
||||||
|
setRepeaterData,
|
||||||
|
addRepeaterRow,
|
||||||
|
updateRepeaterRow,
|
||||||
|
deleteRepeaterRow,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -216,6 +573,9 @@ export function UnifiedFormProvider({
|
||||||
|
|
||||||
// ===== 커스텀 훅 =====
|
// ===== 커스텀 훅 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedForm 컨텍스트 사용 (Context가 없으면 에러)
|
||||||
|
*/
|
||||||
export function useUnifiedForm(): UnifiedFormContextValue {
|
export function useUnifiedForm(): UnifiedFormContextValue {
|
||||||
const context = useContext(UnifiedFormContext);
|
const context = useContext(UnifiedFormContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
|
|
@ -224,6 +584,14 @@ export function useUnifiedForm(): UnifiedFormContextValue {
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnifiedForm 컨텍스트 사용 (Context가 없어도 에러 안 남, null 반환)
|
||||||
|
* 레거시 호환성을 위해 사용
|
||||||
|
*/
|
||||||
|
export function useUnifiedFormOptional(): UnifiedFormContextValue | null {
|
||||||
|
return useContext(UnifiedFormContext);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 개별 필드 훅 - 조건부 상태와 값을 한 번에 관리
|
* 개별 필드 훅 - 조건부 상태와 값을 한 번에 관리
|
||||||
*/
|
*/
|
||||||
|
|
@ -235,11 +603,13 @@ export function useUnifiedField(
|
||||||
setValue: (value: unknown) => void;
|
setValue: (value: unknown) => void;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
error?: FieldError;
|
||||||
} {
|
} {
|
||||||
const { getValue, setValue, evaluateCondition } = useUnifiedForm();
|
const { getValue, setValue, evaluateCondition, errors } = useUnifiedForm();
|
||||||
|
|
||||||
const value = getValue(fieldId);
|
const value = getValue(fieldId);
|
||||||
const { visible, disabled } = evaluateCondition(fieldId, conditional);
|
const { visible, disabled } = evaluateCondition(fieldId, conditional);
|
||||||
|
const error = errors.find(e => e.fieldId === fieldId);
|
||||||
|
|
||||||
const handleSetValue = useCallback((newValue: unknown) => {
|
const handleSetValue = useCallback((newValue: unknown) => {
|
||||||
setValue(fieldId, newValue);
|
setValue(fieldId, newValue);
|
||||||
|
|
@ -250,6 +620,7 @@ export function useUnifiedField(
|
||||||
setValue: handleSetValue,
|
setValue: handleSetValue,
|
||||||
visible,
|
visible,
|
||||||
disabled,
|
disabled,
|
||||||
|
error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,13 +638,56 @@ export function useCascadingOptions<T extends { parentValue?: unknown }>(
|
||||||
const parentValue = getCascadingFilter(cascading);
|
const parentValue = getCascadingFilter(cascading);
|
||||||
|
|
||||||
if (parentValue === undefined || parentValue === null || parentValue === "") {
|
if (parentValue === undefined || parentValue === null || parentValue === "") {
|
||||||
return []; // 부모 값이 없으면 빈 배열
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// parentValue로 필터링
|
|
||||||
return options.filter(opt => opt.parentValue === parentValue);
|
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;
|
export default UnifiedFormContext;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,17 +30,33 @@ export { DynamicConfigPanel, COMMON_SCHEMAS } from "./DynamicConfigPanel";
|
||||||
// 데모 컴포넌트
|
// 데모 컴포넌트
|
||||||
export { UnifiedComponentsDemo } from "./UnifiedComponentsDemo";
|
export { UnifiedComponentsDemo } from "./UnifiedComponentsDemo";
|
||||||
|
|
||||||
// 폼 컨텍스트
|
// 폼 컨텍스트 및 액션
|
||||||
export {
|
export {
|
||||||
UnifiedFormProvider,
|
UnifiedFormProvider,
|
||||||
useUnifiedForm,
|
useUnifiedForm,
|
||||||
|
useUnifiedFormOptional,
|
||||||
useUnifiedField,
|
useUnifiedField,
|
||||||
useCascadingOptions,
|
useCascadingOptions,
|
||||||
|
useFormActions,
|
||||||
|
useRepeaterField,
|
||||||
} from "./UnifiedFormContext";
|
} from "./UnifiedFormContext";
|
||||||
|
|
||||||
// 설정 UI 패널
|
// 설정 UI 패널
|
||||||
export { ConditionalConfigPanel } from "./ConditionalConfigPanel";
|
export { ConditionalConfigPanel } from "./ConditionalConfigPanel";
|
||||||
|
|
||||||
|
// 폼 관련 타입 re-export
|
||||||
|
export type {
|
||||||
|
FormStatus,
|
||||||
|
FieldError,
|
||||||
|
FieldState,
|
||||||
|
SubmitConfig,
|
||||||
|
SubmitResult,
|
||||||
|
ValidationResult,
|
||||||
|
FieldMapping,
|
||||||
|
ScreenDataTransferConfig,
|
||||||
|
FormCompatibilityBridge,
|
||||||
|
} from "@/types/unified-form";
|
||||||
|
|
||||||
// 타입 re-export
|
// 타입 re-export
|
||||||
export type {
|
export type {
|
||||||
// 공통 타입
|
// 공통 타입
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폼 호환성 브릿지 훅
|
||||||
|
*
|
||||||
|
* 레거시 컴포넌트와 새로운 Unified 폼 시스템 간의 호환성을 제공합니다.
|
||||||
|
*
|
||||||
|
* 사용 시나리오:
|
||||||
|
* 1. UnifiedFormProvider 내부에서 사용 → Unified 시스템 사용
|
||||||
|
* 2. UnifiedFormProvider 없이 사용 → 레거시 방식 사용
|
||||||
|
* 3. 두 시스템이 공존할 때 → 양쪽에 모두 전파
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useContext, useMemo } from "react";
|
||||||
|
import UnifiedFormContext, { useUnifiedFormOptional } from "@/components/unified/UnifiedFormContext";
|
||||||
|
import { useScreenContext } from "@/contexts/ScreenContext";
|
||||||
|
import type {
|
||||||
|
FormCompatibilityBridge,
|
||||||
|
FormCompatibilityOptions,
|
||||||
|
SubmitConfig,
|
||||||
|
SubmitResult,
|
||||||
|
ValidationResult,
|
||||||
|
FieldError,
|
||||||
|
FormEventDetail,
|
||||||
|
} from "@/types/unified-form";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폼 호환성 브릿지 훅
|
||||||
|
*
|
||||||
|
* @param options - 레거시 시스템 연동 옵션
|
||||||
|
* @returns 통합된 폼 API
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 레거시 컴포넌트에서 사용
|
||||||
|
* const { getValue, setValue, formData } = useFormCompatibility({
|
||||||
|
* legacyOnFormDataChange: props.onFormDataChange,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // 값 변경 시
|
||||||
|
* setValue("fieldName", newValue);
|
||||||
|
*
|
||||||
|
* // 저장 시
|
||||||
|
* const result = await submit({ tableName: "my_table", mode: "insert" });
|
||||||
|
*/
|
||||||
|
export function useFormCompatibility(options: FormCompatibilityOptions = {}): FormCompatibilityBridge {
|
||||||
|
const { legacyOnFormDataChange, screenContext: externalScreenContext, emitLegacyEvents = true } = options;
|
||||||
|
|
||||||
|
// Unified 시스템 (있으면 사용)
|
||||||
|
const unifiedContext = useUnifiedFormOptional();
|
||||||
|
|
||||||
|
// ScreenContext (레거시 시스템)
|
||||||
|
const internalScreenContext = useScreenContext();
|
||||||
|
const screenContext = externalScreenContext || internalScreenContext;
|
||||||
|
|
||||||
|
// 모드 판별
|
||||||
|
const isUnifiedMode = !!unifiedContext;
|
||||||
|
const isLegacyMode = !unifiedContext;
|
||||||
|
|
||||||
|
// ===== 값 관리 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필드 값 가져오기
|
||||||
|
*/
|
||||||
|
const getValue = useCallback(
|
||||||
|
(field: string): unknown => {
|
||||||
|
// Unified 시스템 우선
|
||||||
|
if (unifiedContext) {
|
||||||
|
return unifiedContext.getValue(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScreenContext 폴백
|
||||||
|
if (screenContext?.formData) {
|
||||||
|
return screenContext.formData[field];
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
[unifiedContext, screenContext?.formData],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필드 값 설정 (모든 시스템에 전파)
|
||||||
|
*/
|
||||||
|
const setValue = useCallback(
|
||||||
|
(field: string, value: unknown) => {
|
||||||
|
// 1. Unified 시스템
|
||||||
|
if (unifiedContext) {
|
||||||
|
unifiedContext.setValue(field, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ScreenContext (레거시)
|
||||||
|
if (screenContext?.updateFormData) {
|
||||||
|
screenContext.updateFormData(field, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 레거시 콜백
|
||||||
|
if (legacyOnFormDataChange) {
|
||||||
|
legacyOnFormDataChange(field, value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[unifiedContext, screenContext, legacyOnFormDataChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== 폼 데이터 =====
|
||||||
|
|
||||||
|
const formData = useMemo(() => {
|
||||||
|
if (unifiedContext) {
|
||||||
|
return unifiedContext.formData as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
if (screenContext?.formData) {
|
||||||
|
return screenContext.formData as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}, [unifiedContext, screenContext?.formData]);
|
||||||
|
|
||||||
|
// ===== 폼 액션 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장
|
||||||
|
*/
|
||||||
|
const submit = useCallback(
|
||||||
|
async (config?: Partial<SubmitConfig>): Promise<SubmitResult> => {
|
||||||
|
// Unified 시스템이 있으면 그쪽 사용 (레거시 이벤트도 내부적으로 발생시킴)
|
||||||
|
if (unifiedContext) {
|
||||||
|
return unifiedContext.submit(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레거시 모드: beforeFormSave 이벤트 발생
|
||||||
|
if (emitLegacyEvents && typeof window !== "undefined") {
|
||||||
|
const eventDetail: FormEventDetail = { formData: { ...formData } };
|
||||||
|
const legacyEvent = new CustomEvent("beforeFormSave", { detail: eventDetail });
|
||||||
|
window.dispatchEvent(legacyEvent);
|
||||||
|
|
||||||
|
// 이벤트에서 수집된 데이터 반환 (실제 저장은 외부에서 처리)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { ...formData, ...eventDetail.formData },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, data: formData };
|
||||||
|
},
|
||||||
|
[unifiedContext, formData, emitLegacyEvents],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 초기화
|
||||||
|
*/
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
if (unifiedContext) {
|
||||||
|
unifiedContext.reset();
|
||||||
|
}
|
||||||
|
// 레거시 모드에서는 특별한 처리 없음 (외부에서 처리)
|
||||||
|
}, [unifiedContext]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검증
|
||||||
|
*/
|
||||||
|
const validate = useCallback(async (): Promise<ValidationResult> => {
|
||||||
|
if (unifiedContext) {
|
||||||
|
return unifiedContext.validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레거시 모드에서는 항상 valid
|
||||||
|
return { valid: true, errors: [] };
|
||||||
|
}, [unifiedContext]);
|
||||||
|
|
||||||
|
// ===== 상태 =====
|
||||||
|
|
||||||
|
const isSubmitting = unifiedContext?.status.isSubmitting ?? false;
|
||||||
|
const isDirty = unifiedContext?.status.isDirty ?? false;
|
||||||
|
const errors = unifiedContext?.errors ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 값 관리
|
||||||
|
getValue,
|
||||||
|
setValue,
|
||||||
|
formData,
|
||||||
|
|
||||||
|
// 폼 액션
|
||||||
|
submit,
|
||||||
|
reset,
|
||||||
|
validate,
|
||||||
|
|
||||||
|
// 상태
|
||||||
|
isSubmitting,
|
||||||
|
isDirty,
|
||||||
|
errors,
|
||||||
|
|
||||||
|
// 모드
|
||||||
|
isUnifiedMode,
|
||||||
|
isLegacyMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScreenContext를 사용하는 레거시 컴포넌트를 위한 간편 훅
|
||||||
|
* ScreenContext가 없어도 동작 (null 반환하지 않고 빈 객체)
|
||||||
|
*/
|
||||||
|
function useScreenContext() {
|
||||||
|
// ScreenContext import는 동적으로 처리 (순환 의존성 방지)
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const { useScreenContext: useCtx } = require("@/contexts/ScreenContext");
|
||||||
|
return useCtx?.() || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DynamicComponentRenderer에서 사용하는 통합 onChange 핸들러 생성
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const handleChange = createUnifiedChangeHandler({
|
||||||
|
* fieldName: "customer_name",
|
||||||
|
* unifiedContext,
|
||||||
|
* screenContext,
|
||||||
|
* legacyOnFormDataChange: props.onFormDataChange,
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function createUnifiedChangeHandler(options: {
|
||||||
|
fieldName: string;
|
||||||
|
unifiedContext?: ReturnType<typeof useUnifiedFormOptional>;
|
||||||
|
screenContext?: { updateFormData?: (field: string, value: unknown) => void };
|
||||||
|
legacyOnFormDataChange?: (field: string, value: unknown) => void;
|
||||||
|
}): (value: unknown) => void {
|
||||||
|
const { fieldName, unifiedContext, screenContext, legacyOnFormDataChange } = options;
|
||||||
|
|
||||||
|
return (value: unknown) => {
|
||||||
|
// 1. Unified 시스템
|
||||||
|
if (unifiedContext) {
|
||||||
|
unifiedContext.setValue(fieldName, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ScreenContext
|
||||||
|
if (screenContext?.updateFormData) {
|
||||||
|
screenContext.updateFormData(fieldName, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 레거시 콜백
|
||||||
|
if (legacyOnFormDataChange) {
|
||||||
|
legacyOnFormDataChange(fieldName, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레거시 beforeFormSave 이벤트 리스너 등록 훅
|
||||||
|
*
|
||||||
|
* 리피터 컴포넌트 등에서 저장 시 데이터를 수집하기 위해 사용
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* useBeforeFormSave((event) => {
|
||||||
|
* event.detail.formData["repeater_field"] = myRepeaterData;
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useBeforeFormSave(handler: (event: CustomEvent<FormEventDetail>) => void, deps: unknown[] = []) {
|
||||||
|
const stableHandler = useCallback(handler, deps);
|
||||||
|
|
||||||
|
// 이벤트 리스너 등록
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
// useEffect 내에서 처리해야 하지만, 훅의 단순성을 위해 여기서 처리
|
||||||
|
// 실제 사용 시에는 컴포넌트에서 useEffect로 감싸서 사용
|
||||||
|
}
|
||||||
|
|
||||||
|
return stableHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useFormCompatibility;
|
||||||
|
|
@ -0,0 +1,320 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 간 데이터 전달 훅
|
||||||
|
*
|
||||||
|
* 화면 간, 컴포넌트 간 데이터 전달을 통합된 방식으로 처리합니다.
|
||||||
|
*
|
||||||
|
* 사용 시나리오:
|
||||||
|
* 1. 마스터-디테일 패턴: 목록에서 선택 → 상세 화면에 데이터 전달
|
||||||
|
* 2. 모달 오픈: 버튼 클릭 → 모달에 선택된 데이터 전달
|
||||||
|
* 3. 화면 임베딩: 부모 화면 → 자식 화면에 필터 조건 전달
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import type {
|
||||||
|
ScreenDataTransferConfig,
|
||||||
|
FieldMapping,
|
||||||
|
DataTransferTrigger,
|
||||||
|
} from "@/types/unified-form";
|
||||||
|
|
||||||
|
// ===== 이벤트 이름 상수 =====
|
||||||
|
export const SCREEN_DATA_TRANSFER_EVENT = "screenDataTransfer";
|
||||||
|
|
||||||
|
// ===== 전역 데이터 스토어 (간단한 인메모리 저장소) =====
|
||||||
|
const dataStore = new Map<string, unknown>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 스토어에 데이터 저장
|
||||||
|
*/
|
||||||
|
export function setTransferData(key: string, data: unknown): void {
|
||||||
|
dataStore.set(key, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 스토어에서 데이터 조회
|
||||||
|
*/
|
||||||
|
export function getTransferData<T = unknown>(key: string): T | undefined {
|
||||||
|
return dataStore.get(key) as T | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 스토어에서 데이터 삭제
|
||||||
|
*/
|
||||||
|
export function clearTransferData(key: string): void {
|
||||||
|
dataStore.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 데이터 변환 유틸 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필드 매핑 적용
|
||||||
|
*/
|
||||||
|
export function applyFieldMappings(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
mappings: FieldMapping[]
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
const sourceValue = data[mapping.sourceField];
|
||||||
|
|
||||||
|
// 변환 적용
|
||||||
|
let targetValue = sourceValue;
|
||||||
|
|
||||||
|
switch (mapping.transform) {
|
||||||
|
case "copy":
|
||||||
|
// 그대로 복사
|
||||||
|
targetValue = sourceValue;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "lookup":
|
||||||
|
// TODO: 다른 테이블에서 조회
|
||||||
|
targetValue = sourceValue;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "calculate":
|
||||||
|
// TODO: 계산식 적용
|
||||||
|
targetValue = sourceValue;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "format":
|
||||||
|
// TODO: 포맷팅 적용
|
||||||
|
if (typeof sourceValue === "string" && mapping.transformConfig?.format) {
|
||||||
|
// 간단한 포맷 적용 (확장 가능)
|
||||||
|
targetValue = sourceValue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
targetValue = sourceValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result[mapping.targetField] = targetValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 훅 =====
|
||||||
|
|
||||||
|
interface UseScreenDataTransferOptions {
|
||||||
|
// 이 컴포넌트/화면의 ID
|
||||||
|
screenId?: number;
|
||||||
|
componentId?: string;
|
||||||
|
|
||||||
|
// 데이터 수신 시 콜백
|
||||||
|
onReceiveData?: (data: Record<string, unknown>, trigger: DataTransferTrigger) => void;
|
||||||
|
|
||||||
|
// 자동 구독할 소스 (다른 화면에서 이 화면으로 전달되는 데이터)
|
||||||
|
subscribeFrom?: {
|
||||||
|
sourceScreenId?: number;
|
||||||
|
sourceComponentId?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseScreenDataTransferReturn {
|
||||||
|
/**
|
||||||
|
* 데이터 전송
|
||||||
|
*/
|
||||||
|
sendData: (
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
config: {
|
||||||
|
targetScreenId?: number;
|
||||||
|
targetComponentId?: string;
|
||||||
|
mappings?: FieldMapping[];
|
||||||
|
trigger?: DataTransferTrigger;
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 수신 대기 (수동)
|
||||||
|
*/
|
||||||
|
receiveData: () => Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스토어에서 데이터 조회 (키 기반)
|
||||||
|
*/
|
||||||
|
getStoredData: <T = unknown>(key: string) => T | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스토어에 데이터 저장 (키 기반)
|
||||||
|
*/
|
||||||
|
setStoredData: (key: string, data: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 간 데이터 전달 훅
|
||||||
|
*/
|
||||||
|
export function useScreenDataTransfer(
|
||||||
|
options: UseScreenDataTransferOptions = {}
|
||||||
|
): UseScreenDataTransferReturn {
|
||||||
|
const { screenId, componentId, onReceiveData, subscribeFrom } = options;
|
||||||
|
|
||||||
|
const receiveCallbackRef = useRef(onReceiveData);
|
||||||
|
receiveCallbackRef.current = onReceiveData;
|
||||||
|
|
||||||
|
// 이벤트 리스너 등록 (데이터 수신)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!subscribeFrom && !screenId && !componentId) return;
|
||||||
|
|
||||||
|
const handleDataTransfer = (event: Event) => {
|
||||||
|
const customEvent = event as CustomEvent<{
|
||||||
|
sourceScreenId?: number;
|
||||||
|
sourceComponentId?: string;
|
||||||
|
targetScreenId?: number;
|
||||||
|
targetComponentId?: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
trigger: DataTransferTrigger;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const detail = customEvent.detail;
|
||||||
|
|
||||||
|
// 이 화면/컴포넌트를 대상으로 하는지 확인
|
||||||
|
const isTargetMatch =
|
||||||
|
(detail.targetScreenId && detail.targetScreenId === screenId) ||
|
||||||
|
(detail.targetComponentId && detail.targetComponentId === componentId);
|
||||||
|
|
||||||
|
// 구독 중인 소스에서 온 데이터인지 확인
|
||||||
|
const isSourceMatch = subscribeFrom && (
|
||||||
|
(subscribeFrom.sourceScreenId && subscribeFrom.sourceScreenId === detail.sourceScreenId) ||
|
||||||
|
(subscribeFrom.sourceComponentId && subscribeFrom.sourceComponentId === detail.sourceComponentId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isTargetMatch || isSourceMatch) {
|
||||||
|
receiveCallbackRef.current?.(detail.data, detail.trigger);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener(SCREEN_DATA_TRANSFER_EVENT, handleDataTransfer);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(SCREEN_DATA_TRANSFER_EVENT, handleDataTransfer);
|
||||||
|
};
|
||||||
|
}, [screenId, componentId, subscribeFrom]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 전송
|
||||||
|
*/
|
||||||
|
const sendData = useCallback((
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
config: {
|
||||||
|
targetScreenId?: number;
|
||||||
|
targetComponentId?: string;
|
||||||
|
mappings?: FieldMapping[];
|
||||||
|
trigger?: DataTransferTrigger;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
// 매핑 적용
|
||||||
|
const mappedData = config.mappings
|
||||||
|
? applyFieldMappings(data, config.mappings)
|
||||||
|
: data;
|
||||||
|
|
||||||
|
// 이벤트 발생
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new CustomEvent(SCREEN_DATA_TRANSFER_EVENT, {
|
||||||
|
detail: {
|
||||||
|
sourceScreenId: screenId,
|
||||||
|
sourceComponentId: componentId,
|
||||||
|
targetScreenId: config.targetScreenId,
|
||||||
|
targetComponentId: config.targetComponentId,
|
||||||
|
data: mappedData,
|
||||||
|
trigger: config.trigger || "manual",
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스토어에도 저장 (비동기 조회용)
|
||||||
|
const storeKey = config.targetScreenId
|
||||||
|
? `screen_${config.targetScreenId}`
|
||||||
|
: config.targetComponentId
|
||||||
|
? `component_${config.targetComponentId}`
|
||||||
|
: "default";
|
||||||
|
setTransferData(storeKey, mappedData);
|
||||||
|
}, [screenId, componentId]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 수신 (스토어에서 조회)
|
||||||
|
*/
|
||||||
|
const receiveData = useCallback(() => {
|
||||||
|
const storeKey = screenId
|
||||||
|
? `screen_${screenId}`
|
||||||
|
: componentId
|
||||||
|
? `component_${componentId}`
|
||||||
|
: "default";
|
||||||
|
return getTransferData<Record<string, unknown>>(storeKey);
|
||||||
|
}, [screenId, componentId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sendData,
|
||||||
|
receiveData,
|
||||||
|
getStoredData: getTransferData,
|
||||||
|
setStoredData: setTransferData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 간편한 데이터 전달 훅 (설정 기반)
|
||||||
|
*/
|
||||||
|
export function useConfiguredDataTransfer(config: ScreenDataTransferConfig) {
|
||||||
|
const { source, target, trigger, condition } = config;
|
||||||
|
|
||||||
|
const { sendData } = useScreenDataTransfer({
|
||||||
|
screenId: source.screenId,
|
||||||
|
componentId: source.componentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정된 대로 데이터 전달
|
||||||
|
*/
|
||||||
|
const transfer = useCallback((data: Record<string, unknown>) => {
|
||||||
|
// 조건 체크
|
||||||
|
if (condition) {
|
||||||
|
const fieldValue = data[condition.field];
|
||||||
|
let conditionMet = false;
|
||||||
|
|
||||||
|
switch (condition.operator) {
|
||||||
|
case "=":
|
||||||
|
conditionMet = fieldValue === condition.value;
|
||||||
|
break;
|
||||||
|
case "!=":
|
||||||
|
conditionMet = fieldValue !== condition.value;
|
||||||
|
break;
|
||||||
|
case ">":
|
||||||
|
conditionMet = Number(fieldValue) > Number(condition.value);
|
||||||
|
break;
|
||||||
|
case "<":
|
||||||
|
conditionMet = Number(fieldValue) < Number(condition.value);
|
||||||
|
break;
|
||||||
|
case "in":
|
||||||
|
conditionMet = Array.isArray(condition.value) && condition.value.includes(fieldValue);
|
||||||
|
break;
|
||||||
|
case "notIn":
|
||||||
|
conditionMet = Array.isArray(condition.value) && !condition.value.includes(fieldValue);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conditionMet) {
|
||||||
|
return; // 조건 불충족 시 전달 안 함
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 소스 필드만 추출
|
||||||
|
const sourceData: Record<string, unknown> = {};
|
||||||
|
for (const field of source.fields) {
|
||||||
|
sourceData[field] = data[field];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전달
|
||||||
|
sendData(sourceData, {
|
||||||
|
targetScreenId: target.screenId,
|
||||||
|
mappings: target.mappings,
|
||||||
|
trigger,
|
||||||
|
});
|
||||||
|
}, [source.fields, target.screenId, target.mappings, trigger, condition, sendData]);
|
||||||
|
|
||||||
|
return { transfer };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useScreenDataTransfer;
|
||||||
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useCallback } from "react";
|
||||||
import { ComponentData } from "@/types/screen";
|
import { ComponentData } from "@/types/screen";
|
||||||
import { DynamicLayoutRenderer } from "./DynamicLayoutRenderer";
|
import { DynamicLayoutRenderer } from "./DynamicLayoutRenderer";
|
||||||
import { ComponentRegistry } from "./ComponentRegistry";
|
import { ComponentRegistry } from "./ComponentRegistry";
|
||||||
|
|
@ -20,6 +20,9 @@ import {
|
||||||
} from "@/components/unified";
|
} from "@/components/unified";
|
||||||
import { UnifiedRepeater } from "@/components/unified/UnifiedRepeater";
|
import { UnifiedRepeater } from "@/components/unified/UnifiedRepeater";
|
||||||
|
|
||||||
|
// 통합 폼 시스템 import
|
||||||
|
import { useUnifiedFormOptional } from "@/components/unified/UnifiedFormContext";
|
||||||
|
|
||||||
// 컴포넌트 렌더러 인터페이스
|
// 컴포넌트 렌더러 인터페이스
|
||||||
export interface ComponentRenderer {
|
export interface ComponentRenderer {
|
||||||
(props: {
|
(props: {
|
||||||
|
|
@ -181,14 +184,28 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
|
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
|
||||||
const componentType = (component as any).componentType || component.type;
|
const componentType = (component as any).componentType || component.type;
|
||||||
|
|
||||||
|
// 🆕 Unified 폼 시스템 연동 (최상위에서 한 번만 호출)
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
const unifiedFormContextForUnified = useUnifiedFormOptional();
|
||||||
|
|
||||||
// 🆕 Unified 컴포넌트 처리
|
// 🆕 Unified 컴포넌트 처리
|
||||||
if (componentType?.startsWith("unified-")) {
|
if (componentType?.startsWith("unified-")) {
|
||||||
const unifiedType = componentType as string;
|
const unifiedType = componentType as string;
|
||||||
const config = (component as any).componentConfig || {};
|
const config = (component as any).componentConfig || {};
|
||||||
const fieldName = (component as any).columnName || component.id;
|
const fieldName = (component as any).columnName || component.id;
|
||||||
const currentValue = props.formData?.[fieldName];
|
|
||||||
|
// Unified 시스템이 있으면 거기서 값 가져오기, 없으면 props.formData 사용
|
||||||
|
const currentValue = unifiedFormContextForUnified
|
||||||
|
? unifiedFormContextForUnified.getValue(fieldName)
|
||||||
|
: props.formData?.[fieldName];
|
||||||
|
|
||||||
|
// 🆕 통합 onChange 핸들러 - 양쪽 시스템에 전파
|
||||||
const handleChange = (value: any) => {
|
const handleChange = (value: any) => {
|
||||||
|
// 1. Unified 시스템에 전파
|
||||||
|
if (unifiedFormContextForUnified) {
|
||||||
|
unifiedFormContextForUnified.setValue(fieldName, value);
|
||||||
|
}
|
||||||
|
// 2. 레거시 콜백도 호출 (호환성)
|
||||||
if (props.onFormDataChange) {
|
if (props.onFormDataChange) {
|
||||||
props.onFormDataChange(fieldName, value);
|
props.onFormDataChange(fieldName, value);
|
||||||
}
|
}
|
||||||
|
|
@ -589,7 +606,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
currentValue = formData?.[fieldName] || "";
|
currentValue = formData?.[fieldName] || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 Unified 폼 시스템 연동 (Context가 있으면 사용, 없으면 null)
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
const unifiedFormContext = useUnifiedFormOptional();
|
||||||
|
|
||||||
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
|
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
|
||||||
|
// 🆕 Unified 시스템과 레거시 시스템 모두에 전파
|
||||||
const handleChange = (value: any) => {
|
const handleChange = (value: any) => {
|
||||||
// autocomplete-search-input, entity-search-input은 자체적으로 onFormDataChange를 호출하므로 중복 저장 방지
|
// autocomplete-search-input, entity-search-input은 자체적으로 onFormDataChange를 호출하므로 중복 저장 방지
|
||||||
if (componentType === "autocomplete-search-input" || componentType === "entity-search-input") {
|
if (componentType === "autocomplete-search-input" || componentType === "entity-search-input") {
|
||||||
|
|
@ -603,6 +625,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
actualValue = value.target.value;
|
actualValue = value.target.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. Unified 폼 시스템에 전파 (있으면)
|
||||||
|
if (unifiedFormContext) {
|
||||||
|
unifiedFormContext.setValue(fieldName, actualValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 레거시 onFormDataChange 콜백도 호출 (호환성 유지)
|
||||||
if (onFormDataChange) {
|
if (onFormDataChange) {
|
||||||
// modal-repeater-table은 배열 데이터를 다룸
|
// modal-repeater-table은 배열 데이터를 다룸
|
||||||
if (componentType === "modal-repeater-table") {
|
if (componentType === "modal-repeater-table") {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,371 @@
|
||||||
|
/**
|
||||||
|
* 통합 폼 시스템 타입 정의
|
||||||
|
*
|
||||||
|
* Unified 컴포넌트들과 레거시 컴포넌트들이 공유하는 폼 관련 타입
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ValidationRule } from "./unified-core";
|
||||||
|
|
||||||
|
// ===== 폼 상태 타입 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폼 액션 타입
|
||||||
|
*/
|
||||||
|
export type FormActionType =
|
||||||
|
| "submit" // 저장
|
||||||
|
| "reset" // 초기화 (원본 데이터로 복원)
|
||||||
|
| "validate" // 검증
|
||||||
|
| "clear" // 비우기 (빈 상태로)
|
||||||
|
| "refresh" // 새로고침 (데이터 다시 로드)
|
||||||
|
| "cancel"; // 취소
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폼 상태
|
||||||
|
*/
|
||||||
|
export interface FormStatus {
|
||||||
|
isSubmitting: boolean; // 저장 중
|
||||||
|
isValidating: boolean; // 검증 중
|
||||||
|
isDirty: boolean; // 변경됨
|
||||||
|
isValid: boolean; // 유효함
|
||||||
|
isLoading: boolean; // 데이터 로딩 중
|
||||||
|
submitCount: number; // 저장 시도 횟수
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필드 에러
|
||||||
|
*/
|
||||||
|
export interface FieldError {
|
||||||
|
fieldId: string;
|
||||||
|
message: string;
|
||||||
|
type: "required" | "format" | "range" | "custom" | "server";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필드 상태
|
||||||
|
*/
|
||||||
|
export interface FieldState {
|
||||||
|
value: unknown;
|
||||||
|
originalValue?: unknown; // 수정 모드에서 원본 값
|
||||||
|
touched: boolean; // 사용자가 건드렸는지
|
||||||
|
dirty: boolean; // 값이 변경되었는지
|
||||||
|
error?: FieldError;
|
||||||
|
disabled?: boolean;
|
||||||
|
visible?: boolean;
|
||||||
|
validating?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 저장 설정 타입 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장 모드
|
||||||
|
*/
|
||||||
|
export type SaveMode = "insert" | "update" | "upsert";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리피터 필드 설정
|
||||||
|
*/
|
||||||
|
export interface RepeaterFieldConfig {
|
||||||
|
fieldName: string; // formData에서의 필드명
|
||||||
|
targetTable: string; // 저장할 대상 테이블
|
||||||
|
fkColumn: string; // 부모 테이블과 연결할 FK 컬럼
|
||||||
|
fkSourceField?: string; // FK 값을 가져올 부모 필드 (기본: 부모 PK)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장 후 액션
|
||||||
|
*/
|
||||||
|
export interface AfterSubmitAction {
|
||||||
|
action: "close" | "reset" | "redirect" | "refresh" | "stay";
|
||||||
|
redirectUrl?: string;
|
||||||
|
redirectScreenId?: number;
|
||||||
|
showSuccessToast?: boolean;
|
||||||
|
successMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장 설정
|
||||||
|
*/
|
||||||
|
export interface SubmitConfig {
|
||||||
|
tableName: string;
|
||||||
|
mode: SaveMode;
|
||||||
|
primaryKey?: string;
|
||||||
|
|
||||||
|
// 리피터 데이터 처리
|
||||||
|
repeaterFields?: RepeaterFieldConfig[];
|
||||||
|
|
||||||
|
// 저장 후 액션
|
||||||
|
afterSubmit?: AfterSubmitAction;
|
||||||
|
|
||||||
|
// 검증 옵션
|
||||||
|
validateBeforeSubmit?: boolean;
|
||||||
|
|
||||||
|
// 추가 데이터 (userId, companyCode 등 자동 주입)
|
||||||
|
additionalData?: Record<string, unknown>;
|
||||||
|
|
||||||
|
// 콜백
|
||||||
|
onBeforeSubmit?: (data: Record<string, unknown>) => Record<string, unknown> | Promise<Record<string, unknown>>;
|
||||||
|
onAfterSubmit?: (result: SubmitResult) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장 결과
|
||||||
|
*/
|
||||||
|
export interface SubmitResult {
|
||||||
|
success: boolean;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
insertedId?: string | number;
|
||||||
|
error?: string;
|
||||||
|
errors?: FieldError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 검증 설정 타입 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필드 검증 설정
|
||||||
|
*/
|
||||||
|
export interface FieldValidationConfig {
|
||||||
|
fieldId: string;
|
||||||
|
rules: ValidationRule[];
|
||||||
|
validateOnChange?: boolean;
|
||||||
|
validateOnBlur?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폼 검증 설정
|
||||||
|
*/
|
||||||
|
export interface FormValidationConfig {
|
||||||
|
fields: FieldValidationConfig[];
|
||||||
|
validateOnSubmit?: boolean;
|
||||||
|
stopOnFirstError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 검증 결과
|
||||||
|
*/
|
||||||
|
export interface ValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: FieldError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 화면 간 데이터 전달 타입 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필드 매핑
|
||||||
|
*/
|
||||||
|
export interface FieldMapping {
|
||||||
|
sourceField: string;
|
||||||
|
targetField: string;
|
||||||
|
transform?: "copy" | "lookup" | "calculate" | "format";
|
||||||
|
transformConfig?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 전달 트리거
|
||||||
|
*/
|
||||||
|
export type DataTransferTrigger =
|
||||||
|
| "onSelect" // 행 선택 시
|
||||||
|
| "onSave" // 저장 시
|
||||||
|
| "onLoad" // 로드 시
|
||||||
|
| "onChange" // 값 변경 시
|
||||||
|
| "manual"; // 수동 호출
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 간 데이터 전달 설정
|
||||||
|
*/
|
||||||
|
export interface ScreenDataTransferConfig {
|
||||||
|
// 소스 (데이터를 보내는 쪽)
|
||||||
|
source: {
|
||||||
|
screenId?: number;
|
||||||
|
componentId?: string;
|
||||||
|
fields: string[]; // 전달할 필드들
|
||||||
|
};
|
||||||
|
|
||||||
|
// 타겟 (데이터를 받는 쪽)
|
||||||
|
target: {
|
||||||
|
screenId?: number;
|
||||||
|
componentId?: string;
|
||||||
|
mappings: FieldMapping[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 트리거
|
||||||
|
trigger: DataTransferTrigger;
|
||||||
|
|
||||||
|
// 조건 (선택사항)
|
||||||
|
condition?: {
|
||||||
|
field: string;
|
||||||
|
operator: "=" | "!=" | ">" | "<" | "in" | "notIn";
|
||||||
|
value: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Context 타입 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 확장된 UnifiedFormContext 값
|
||||||
|
*/
|
||||||
|
export interface ExtendedFormContextValue {
|
||||||
|
// === 기존 UnifiedFormContext 기능 ===
|
||||||
|
formData: Record<string, unknown>;
|
||||||
|
fieldStates: Record<string, FieldState>;
|
||||||
|
|
||||||
|
getValue: (fieldId: string) => unknown;
|
||||||
|
setValue: (fieldId: string, value: unknown) => void;
|
||||||
|
setValues: (values: Record<string, unknown>) => 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;
|
||||||
|
|
||||||
|
// 필드 등록/해제
|
||||||
|
registerField: (fieldId: string, config?: { initialValue?: unknown; validation?: ValidationRule[] }) => void;
|
||||||
|
unregisterField: (fieldId: string) => void;
|
||||||
|
|
||||||
|
// 리피터 데이터 관리
|
||||||
|
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;
|
||||||
|
|
||||||
|
// 설정
|
||||||
|
submitConfig?: SubmitConfig;
|
||||||
|
validationConfig?: FormValidationConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 호환성 브릿지 타입 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레거시 폼 데이터 핸들러
|
||||||
|
*/
|
||||||
|
export type LegacyFormDataChangeHandler = (fieldName: string, value: unknown) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 호환성 브릿지 옵션
|
||||||
|
*/
|
||||||
|
export interface FormCompatibilityOptions {
|
||||||
|
// 레거시 콜백 지원
|
||||||
|
legacyOnFormDataChange?: LegacyFormDataChangeHandler;
|
||||||
|
|
||||||
|
// ScreenContext 연동
|
||||||
|
screenContext?: {
|
||||||
|
updateFormData?: (field: string, value: unknown) => void;
|
||||||
|
formData?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// beforeFormSave 이벤트 발생 여부
|
||||||
|
emitLegacyEvents?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 호환성 브릿지 반환값
|
||||||
|
*/
|
||||||
|
export interface FormCompatibilityBridge {
|
||||||
|
// 값 관리 (통합 API)
|
||||||
|
getValue: (field: string) => unknown;
|
||||||
|
setValue: (field: string, value: unknown) => void;
|
||||||
|
|
||||||
|
// 폼 액션 (통합 API)
|
||||||
|
submit: (config?: Partial<SubmitConfig>) => Promise<SubmitResult>;
|
||||||
|
reset: () => void;
|
||||||
|
validate: () => Promise<ValidationResult>;
|
||||||
|
|
||||||
|
// 폼 데이터 (읽기 전용)
|
||||||
|
formData: Record<string, unknown>;
|
||||||
|
|
||||||
|
// 상태
|
||||||
|
isSubmitting: boolean;
|
||||||
|
isDirty: boolean;
|
||||||
|
errors: FieldError[];
|
||||||
|
|
||||||
|
// 모드
|
||||||
|
isUnifiedMode: boolean; // UnifiedFormContext 사용 여부
|
||||||
|
isLegacyMode: boolean; // 레거시 모드 여부
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 이벤트 타입 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폼 이벤트 상세
|
||||||
|
*/
|
||||||
|
export interface FormEventDetail {
|
||||||
|
formData: Record<string, unknown>;
|
||||||
|
fieldName?: string;
|
||||||
|
value?: unknown;
|
||||||
|
action?: FormActionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* beforeFormSave 이벤트 (레거시 호환)
|
||||||
|
*/
|
||||||
|
export interface BeforeFormSaveEvent extends CustomEvent<FormEventDetail> {
|
||||||
|
type: "beforeFormSave";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* afterFormSave 이벤트
|
||||||
|
*/
|
||||||
|
export interface AfterFormSaveEvent extends CustomEvent<{
|
||||||
|
success: boolean;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
type: "afterFormSave";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 간 데이터 전달 이벤트
|
||||||
|
*/
|
||||||
|
export interface ScreenDataTransferEvent extends CustomEvent<{
|
||||||
|
sourceScreenId?: number;
|
||||||
|
targetScreenId?: number;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
trigger: DataTransferTrigger;
|
||||||
|
}> {
|
||||||
|
type: "screenDataTransfer";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 유틸리티 타입 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폼 데이터에서 특정 필드만 추출
|
||||||
|
*/
|
||||||
|
export type PickFormFields<T extends Record<string, unknown>, K extends keyof T> = Pick<T, K>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폼 데이터에서 특정 필드 제외
|
||||||
|
*/
|
||||||
|
export type OmitFormFields<T extends Record<string, unknown>, K extends keyof T> = Omit<T, K>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필드를 옵셔널로 변환
|
||||||
|
*/
|
||||||
|
export type PartialFormData<T extends Record<string, unknown>> = Partial<T>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리피터 데이터 타입
|
||||||
|
*/
|
||||||
|
export type RepeaterData<T extends Record<string, unknown> = Record<string, unknown>> = T[];
|
||||||
|
|
||||||
Loading…
Reference in New Issue