폼 통합

This commit is contained in:
kjs 2026-01-15 09:50:33 +09:00
parent 08ea14eed7
commit 71af4dfc6b
6 changed files with 1448 additions and 29 deletions

View File

@ -4,13 +4,25 @@
* UnifiedFormContext
*
* 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 { ValidationRule } from "@/types/unified-core";
import type {
FormStatus,
FieldError,
FieldState,
SubmitConfig,
SubmitResult,
ValidationResult,
FormEventDetail,
} from "@/types/unified-form";
// ===== 타입 정의 =====
// ===== 레거시 타입 호환 (기존 코드와 호환) =====
export interface FormFieldState {
value: unknown;
@ -23,28 +35,60 @@ 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 생성 =====
@ -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 {
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 };
@ -158,13 +237,11 @@ export function UnifiedFormProvider({
}
}, [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 }));
@ -175,17 +252,259 @@ export function UnifiedFormProvider({
}));
}, [formData]);
// 필드 해제
const unregisterField = useCallback((fieldId: string) => {
setFieldStates(prev => {
const next = { ...prev };
delete next[fieldId];
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>(() => ({
// 기존 기능
formData,
fieldStates,
getValue,
@ -195,6 +514,26 @@ export function UnifiedFormProvider({
getCascadingFilter,
registerField,
unregisterField,
// 새로운 기능
originalData,
status,
errors,
submit,
reset,
validate,
clear,
setInitialData,
setFieldError,
clearFieldError,
clearAllErrors,
getChangedFields,
hasChanges,
getRepeaterData,
setRepeaterData,
addRepeaterRow,
updateRepeaterRow,
deleteRepeaterRow,
}), [
formData,
fieldStates,
@ -205,6 +544,24 @@ export function UnifiedFormProvider({
getCascadingFilter,
registerField,
unregisterField,
originalData,
status,
errors,
submit,
reset,
validate,
clear,
setInitialData,
setFieldError,
clearFieldError,
clearAllErrors,
getChangedFields,
hasChanges,
getRepeaterData,
setRepeaterData,
addRepeaterRow,
updateRepeaterRow,
deleteRepeaterRow,
]);
return (
@ -216,6 +573,9 @@ export function UnifiedFormProvider({
// ===== 커스텀 훅 =====
/**
* UnifiedForm (Context가 )
*/
export function useUnifiedForm(): UnifiedFormContextValue {
const context = useContext(UnifiedFormContext);
if (!context) {
@ -224,6 +584,14 @@ export function useUnifiedForm(): UnifiedFormContextValue {
return context;
}
/**
* UnifiedForm (Context가 , null )
*
*/
export function useUnifiedFormOptional(): UnifiedFormContextValue | null {
return useContext(UnifiedFormContext);
}
/**
* -
*/
@ -235,11 +603,13 @@ export function useUnifiedField(
setValue: (value: unknown) => void;
visible: boolean;
disabled: boolean;
error?: FieldError;
} {
const { getValue, setValue, evaluateCondition } = useUnifiedForm();
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);
@ -250,6 +620,7 @@ export function useUnifiedField(
setValue: handleSetValue,
visible,
disabled,
error,
};
}
@ -267,13 +638,56 @@ export function useCascadingOptions<T extends { parentValue?: unknown }>(
const parentValue = getCascadingFilter(cascading);
if (parentValue === undefined || parentValue === null || parentValue === "") {
return []; // 부모 값이 없으면 빈 배열
return [];
}
// 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;

View File

@ -30,17 +30,33 @@ export { DynamicConfigPanel, COMMON_SCHEMAS } from "./DynamicConfigPanel";
// 데모 컴포넌트
export { UnifiedComponentsDemo } from "./UnifiedComponentsDemo";
// 폼 컨텍스트
// 폼 컨텍스트 및 액션
export {
UnifiedFormProvider,
useUnifiedForm,
useUnifiedFormOptional,
useUnifiedField,
useCascadingOptions,
useFormActions,
useRepeaterField,
} from "./UnifiedFormContext";
// 설정 UI 패널
export { ConditionalConfigPanel } from "./ConditionalConfigPanel";
// 폼 관련 타입 re-export
export type {
FormStatus,
FieldError,
FieldState,
SubmitConfig,
SubmitResult,
ValidationResult,
FieldMapping,
ScreenDataTransferConfig,
FormCompatibilityBridge,
} from "@/types/unified-form";
// 타입 re-export
export type {
// 공통 타입

View File

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

View File

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

View File

@ -1,6 +1,6 @@
"use client";
import React from "react";
import React, { useCallback } from "react";
import { ComponentData } from "@/types/screen";
import { DynamicLayoutRenderer } from "./DynamicLayoutRenderer";
import { ComponentRegistry } from "./ComponentRegistry";
@ -20,6 +20,9 @@ import {
} from "@/components/unified";
import { UnifiedRepeater } from "@/components/unified/UnifiedRepeater";
// 통합 폼 시스템 import
import { useUnifiedFormOptional } from "@/components/unified/UnifiedFormContext";
// 컴포넌트 렌더러 인터페이스
export interface ComponentRenderer {
(props: {
@ -181,14 +184,28 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
const componentType = (component as any).componentType || component.type;
// 🆕 Unified 폼 시스템 연동 (최상위에서 한 번만 호출)
// eslint-disable-next-line react-hooks/rules-of-hooks
const unifiedFormContextForUnified = useUnifiedFormOptional();
// 🆕 Unified 컴포넌트 처리
if (componentType?.startsWith("unified-")) {
const unifiedType = componentType as string;
const config = (component as any).componentConfig || {};
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) => {
// 1. Unified 시스템에 전파
if (unifiedFormContextForUnified) {
unifiedFormContextForUnified.setValue(fieldName, value);
}
// 2. 레거시 콜백도 호출 (호환성)
if (props.onFormDataChange) {
props.onFormDataChange(fieldName, value);
}
@ -589,7 +606,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
currentValue = formData?.[fieldName] || "";
}
// 🆕 Unified 폼 시스템 연동 (Context가 있으면 사용, 없으면 null)
// eslint-disable-next-line react-hooks/rules-of-hooks
const unifiedFormContext = useUnifiedFormOptional();
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
// 🆕 Unified 시스템과 레거시 시스템 모두에 전파
const handleChange = (value: any) => {
// autocomplete-search-input, entity-search-input은 자체적으로 onFormDataChange를 호출하므로 중복 저장 방지
if (componentType === "autocomplete-search-input" || componentType === "entity-search-input") {
@ -603,6 +625,12 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
actualValue = value.target.value;
}
// 1. Unified 폼 시스템에 전파 (있으면)
if (unifiedFormContext) {
unifiedFormContext.setValue(fieldName, actualValue);
}
// 2. 레거시 onFormDataChange 콜백도 호출 (호환성 유지)
if (onFormDataChange) {
// modal-repeater-table은 배열 데이터를 다룸
if (componentType === "modal-repeater-table") {

View File

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