From 71af4dfc6b38430587101de01ca849364c1ff431 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 15 Jan 2026 09:50:33 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8F=BC=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/unified/UnifiedFormContext.tsx | 466 +++++++++++++++++- frontend/components/unified/index.ts | 18 +- frontend/hooks/useFormCompatibility.ts | 270 ++++++++++ frontend/hooks/useScreenDataTransfer.ts | 320 ++++++++++++ .../lib/registry/DynamicComponentRenderer.tsx | 32 +- frontend/types/unified-form.ts | 371 ++++++++++++++ 6 files changed, 1448 insertions(+), 29 deletions(-) create mode 100644 frontend/hooks/useFormCompatibility.ts create mode 100644 frontend/hooks/useScreenDataTransfer.ts create mode 100644 frontend/types/unified-form.ts diff --git a/frontend/components/unified/UnifiedFormContext.tsx b/frontend/components/unified/UnifiedFormContext.tsx index 682335fd..1070f176 100644 --- a/frontend/components/unified/UnifiedFormContext.tsx +++ b/frontend/components/unified/UnifiedFormContext.tsx @@ -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; fieldStates: FormState; - // 값 관리 getValue: (fieldId: string) => unknown; setValue: (fieldId: string, value: unknown) => void; setValues: (values: Record) => void; - // 조건부 로직 evaluateCondition: (fieldId: string, config?: ConditionalConfig) => { visible: boolean; disabled: boolean; }; - // 연쇄 관계 getCascadingFilter: (config?: CascadingConfig) => unknown; - // 필드 등록 registerField: (fieldId: string, initialValue?: unknown) => void; unregisterField: (fieldId: string) => void; + + // === 새로운 기능 === + + // 원본 데이터 (수정 모드) + originalData: Record; + + // 폼 상태 + status: FormStatus; + errors: FieldError[]; + + // 폼 액션 + submit: (config?: Partial) => Promise; + reset: () => void; + validate: (fieldIds?: string[]) => Promise; + clear: () => void; + + // 초기 데이터 설정 (수정 모드 진입) + setInitialData: (data: Record) => void; + + // 에러 관리 + setFieldError: (fieldId: string, error: string, type?: FieldError["type"]) => void; + clearFieldError: (fieldId: string) => void; + clearAllErrors: () => void; + + // dirty 체크 + getChangedFields: () => string[]; + hasChanges: () => boolean; + + // 리피터 데이터 관리 + getRepeaterData: (fieldName: string) => unknown[]; + setRepeaterData: (fieldName: string, data: unknown[]) => void; + addRepeaterRow: (fieldName: string, row: Record) => void; + updateRepeaterRow: (fieldName: string, index: number, row: Record) => void; + deleteRepeaterRow: (fieldName: string, index: number) => void; } // ===== Context 생성 ===== @@ -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; onChange?: (formData: Record) => void; + + // 새로운 Props + submitConfig?: SubmitConfig; + onSubmit?: (data: Record, config: SubmitConfig) => Promise; + onError?: (errors: FieldError[]) => void; + onReset?: () => void; + + // 레거시 호환성 + emitLegacyEvents?: boolean; // beforeFormSave 등 레거시 이벤트 발생 여부 (기본: true) } +// ===== Provider 컴포넌트 ===== + export function UnifiedFormProvider({ children, initialValues = {}, onChange, + submitConfig: defaultSubmitConfig, + onSubmit, + onError, + onReset, + emitLegacyEvents = true, }: UnifiedFormProviderProps) { + // 기존 상태 const [formData, setFormData] = useState>(initialValues); const [fieldStates, setFieldStates] = useState({}); + + // 새로운 상태 + const [originalData, setOriginalData] = useState>(initialValues); + const [status, setStatus] = useState(initialFormStatus); + const [errors, setErrors] = useState([]); + + // 필드별 검증 규칙 저장 + const validationRulesRef = useRef>(new Map()); + + // ===== 기존 기능 ===== - // 값 가져오기 const getValue = useCallback((fieldId: string): unknown => { return formData[fieldId]; }, [formData]); - // 단일 값 설정 const setValue = useCallback((fieldId: string, value: unknown) => { setFormData(prev => { const newData = { ...prev, [fieldId]: value }; + + // dirty 상태 업데이트 + setStatus(s => ({ ...s, isDirty: true })); + onChange?.(newData); return newData; }); }, [onChange]); - // 여러 값 한 번에 설정 const setValues = useCallback((values: Record) => { setFormData(prev => { const newData = { ...prev, ...values }; + setStatus(s => ({ ...s, isDirty: true })); onChange?.(newData); return newData; }); }, [onChange]); - // 조건 평가 const evaluateCondition = useCallback(( fieldId: string, config?: ConditionalConfig ): { visible: boolean; disabled: boolean } => { - // 조건부 설정이 없으면 기본값 반환 if (!config || !config.enabled) { return { visible: true, disabled: false }; } const { field, operator, value, action } = config; const fieldValue = formData[field]; - - // 조건 평가 const conditionMet = evaluateOperator(fieldValue, operator, value); - // 액션에 따른 결과 switch (action) { case "show": return { visible: conditionMet, disabled: false }; @@ -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 => { + setStatus(s => ({ ...s, isValidating: true })); + + const newErrors: FieldError[] = []; + const fieldsToValidate = fieldIds || Array.from(validationRulesRef.current.keys()); + + for (const fieldId of fieldsToValidate) { + const rules = validationRulesRef.current.get(fieldId); + if (!rules) continue; + + const value = formData[fieldId]; + + for (const rule of rules) { + let isValid = true; + + switch (rule.type) { + case "required": + isValid = value !== null && value !== undefined && value !== ""; + break; + case "minLength": + isValid = typeof value === "string" && value.length >= (rule.value as number); + break; + case "maxLength": + isValid = typeof value === "string" && value.length <= (rule.value as number); + break; + case "min": + isValid = typeof value === "number" && value >= (rule.value as number); + break; + case "max": + isValid = typeof value === "number" && value <= (rule.value as number); + break; + case "pattern": + isValid = typeof value === "string" && new RegExp(rule.value as string).test(value); + break; + case "email": + isValid = typeof value === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); + break; + case "url": + isValid = typeof value === "string" && /^https?:\/\/.+/.test(value); + break; + } + + if (!isValid) { + newErrors.push({ + fieldId, + message: rule.message, + type: rule.type === "required" ? "required" : "format", + }); + break; // 첫 번째 에러만 기록 + } + } + } + + setErrors(newErrors); + setStatus(s => ({ + ...s, + isValidating: false, + isValid: newErrors.length === 0 + })); + + return { valid: newErrors.length === 0, errors: newErrors }; + }, [formData]); + + // 저장 + const submit = useCallback(async (config?: Partial): Promise => { + const finalConfig = { ...defaultSubmitConfig, ...config } as SubmitConfig; + + setStatus(s => ({ ...s, isSubmitting: true, submitCount: s.submitCount + 1 })); + + try { + // 1. 검증 + if (finalConfig.validateBeforeSubmit !== false) { + const validation = await validate(); + if (!validation.valid) { + onError?.(validation.errors); + return { success: false, error: "검증 실패", errors: validation.errors }; + } + } + + // 2. 레거시 이벤트 발생 (리피터 데이터 수집) + let collectedData = { ...formData }; + + if (emitLegacyEvents && typeof window !== "undefined") { + const eventDetail: FormEventDetail = { formData: {} }; + const legacyEvent = new CustomEvent("beforeFormSave", { detail: eventDetail }); + window.dispatchEvent(legacyEvent); + + // 이벤트에서 수집된 데이터 병합 (리피터 등) + collectedData = { ...collectedData, ...eventDetail.formData }; + } + + // 3. beforeSubmit 콜백 + if (finalConfig.onBeforeSubmit) { + collectedData = await finalConfig.onBeforeSubmit(collectedData); + } + + // 4. 추가 데이터 병합 + if (finalConfig.additionalData) { + collectedData = { ...collectedData, ...finalConfig.additionalData }; + } + + // 5. 저장 실행 + let result: SubmitResult; + + if (onSubmit) { + result = await onSubmit(collectedData, finalConfig); + } else { + // 기본 저장 로직 (API 호출) + // 실제 구현은 외부에서 onSubmit으로 제공 + result = { success: true, data: collectedData }; + } + + // 6. 성공 시 처리 + if (result.success) { + setOriginalData({ ...formData }); + setStatus(s => ({ ...s, isDirty: false })); + + // afterFormSave 이벤트 발생 + if (emitLegacyEvents && typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("afterFormSave", { + detail: { success: true, data: result.data } + })); + } + + // afterSubmit 콜백 + finalConfig.onAfterSubmit?.(result); + } + + return result; + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : "저장 중 오류 발생"; + return { success: false, error: errorMessage }; + } finally { + setStatus(s => ({ ...s, isSubmitting: false })); + } + }, [formData, defaultSubmitConfig, validate, onSubmit, onError, emitLegacyEvents]); + + // 초기화 (원본 데이터로 복원) + const reset = useCallback(() => { + setFormData({ ...originalData }); + setErrors([]); + setStatus(s => ({ ...s, isDirty: false, isValid: true })); + onReset?.(); + }, [originalData, onReset]); + + // 비우기 + const clear = useCallback(() => { + setFormData({}); + setErrors([]); + setStatus(s => ({ ...s, isDirty: true, isValid: true })); + }, []); + + // 초기 데이터 설정 (수정 모드 진입) + const setInitialData = useCallback((data: Record) => { + setFormData(data); + setOriginalData(data); + setStatus(s => ({ ...s, isDirty: false })); + }, []); + + // ===== 에러 관리 ===== + + const setFieldError = useCallback((fieldId: string, message: string, type: FieldError["type"] = "custom") => { + setErrors(prev => { + const filtered = prev.filter(e => e.fieldId !== fieldId); + return [...filtered, { fieldId, message, type }]; + }); + setStatus(s => ({ ...s, isValid: false })); + }, []); + + const clearFieldError = useCallback((fieldId: string) => { + setErrors(prev => { + const filtered = prev.filter(e => e.fieldId !== fieldId); + return filtered; + }); + }, []); + + const clearAllErrors = useCallback(() => { + setErrors([]); + setStatus(s => ({ ...s, isValid: true })); + }, []); + + // ===== dirty 체크 ===== + + const getChangedFields = useCallback((): string[] => { + const changed: string[] = []; + const allKeys = new Set([...Object.keys(formData), ...Object.keys(originalData)]); + + for (const key of allKeys) { + if (JSON.stringify(formData[key]) !== JSON.stringify(originalData[key])) { + changed.push(key); + } + } + + return changed; + }, [formData, originalData]); + + const hasChanges = useCallback((): boolean => { + return JSON.stringify(formData) !== JSON.stringify(originalData); + }, [formData, originalData]); + + // ===== 리피터 데이터 관리 ===== + + const getRepeaterData = useCallback((fieldName: string): unknown[] => { + const data = formData[fieldName]; + if (Array.isArray(data)) return data; + if (typeof data === "string") { + try { + return JSON.parse(data); + } catch { + return []; + } + } + return []; + }, [formData]); + + const setRepeaterData = useCallback((fieldName: string, data: unknown[]) => { + setValue(fieldName, data); + }, [setValue]); + + const addRepeaterRow = useCallback((fieldName: string, row: Record) => { + const current = getRepeaterData(fieldName); + setValue(fieldName, [...current, row]); + }, [getRepeaterData, setValue]); + + const updateRepeaterRow = useCallback((fieldName: string, index: number, row: Record) => { + const current = getRepeaterData(fieldName) as Record[]; + const updated = [...current]; + updated[index] = { ...updated[index], ...row }; + setValue(fieldName, updated); + }, [getRepeaterData, setValue]); + + const deleteRepeaterRow = useCallback((fieldName: string, index: number) => { + const current = getRepeaterData(fieldName); + const updated = current.filter((_, i) => i !== index); + setValue(fieldName, updated); + }, [getRepeaterData, setValue]); + + // ===== Context 값 ===== + const contextValue = useMemo(() => ({ + // 기존 기능 formData, fieldStates, getValue, @@ -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( 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 = Record>( + fieldName: string +) { + const { + getRepeaterData, + setRepeaterData, + addRepeaterRow, + updateRepeaterRow, + deleteRepeaterRow + } = useUnifiedForm(); + + const data = getRepeaterData(fieldName) as T[]; + + return { + data, + setData: (newData: T[]) => setRepeaterData(fieldName, newData), + addRow: (row: T) => addRepeaterRow(fieldName, row), + updateRow: (index: number, row: Partial) => updateRepeaterRow(fieldName, index, row as Record), + deleteRow: (index: number) => deleteRepeaterRow(fieldName, index), + count: data.length, + }; +} + export default UnifiedFormContext; - - diff --git a/frontend/components/unified/index.ts b/frontend/components/unified/index.ts index ef9e83f2..cf21866e 100644 --- a/frontend/components/unified/index.ts +++ b/frontend/components/unified/index.ts @@ -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 { // 공통 타입 diff --git a/frontend/hooks/useFormCompatibility.ts b/frontend/hooks/useFormCompatibility.ts new file mode 100644 index 00000000..a6bc1476 --- /dev/null +++ b/frontend/hooks/useFormCompatibility.ts @@ -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; + } + if (screenContext?.formData) { + return screenContext.formData as Record; + } + return {}; + }, [unifiedContext, screenContext?.formData]); + + // ===== 폼 액션 ===== + + /** + * 저장 + */ + const submit = useCallback( + async (config?: Partial): Promise => { + // 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 => { + 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; + 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) => void, deps: unknown[] = []) { + const stableHandler = useCallback(handler, deps); + + // 이벤트 리스너 등록 + if (typeof window !== "undefined") { + // useEffect 내에서 처리해야 하지만, 훅의 단순성을 위해 여기서 처리 + // 실제 사용 시에는 컴포넌트에서 useEffect로 감싸서 사용 + } + + return stableHandler; +} + +export default useFormCompatibility; diff --git a/frontend/hooks/useScreenDataTransfer.ts b/frontend/hooks/useScreenDataTransfer.ts new file mode 100644 index 00000000..29b4f4f5 --- /dev/null +++ b/frontend/hooks/useScreenDataTransfer.ts @@ -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(); + +/** + * 데이터 스토어에 데이터 저장 + */ +export function setTransferData(key: string, data: unknown): void { + dataStore.set(key, data); +} + +/** + * 데이터 스토어에서 데이터 조회 + */ +export function getTransferData(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, + mappings: FieldMapping[] +): Record { + const result: Record = {}; + + 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, trigger: DataTransferTrigger) => void; + + // 자동 구독할 소스 (다른 화면에서 이 화면으로 전달되는 데이터) + subscribeFrom?: { + sourceScreenId?: number; + sourceComponentId?: string; + }; +} + +interface UseScreenDataTransferReturn { + /** + * 데이터 전송 + */ + sendData: ( + data: Record, + config: { + targetScreenId?: number; + targetComponentId?: string; + mappings?: FieldMapping[]; + trigger?: DataTransferTrigger; + } + ) => void; + + /** + * 데이터 수신 대기 (수동) + */ + receiveData: () => Record | undefined; + + /** + * 스토어에서 데이터 조회 (키 기반) + */ + getStoredData: (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; + 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, + 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>(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) => { + // 조건 체크 + 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 = {}; + 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; + diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 92d2367c..a66a921e 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -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 = // 컴포넌트 타입 추출 - 새 시스템에서는 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 = 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 = actualValue = value.target.value; } + // 1. Unified 폼 시스템에 전파 (있으면) + if (unifiedFormContext) { + unifiedFormContext.setValue(fieldName, actualValue); + } + + // 2. 레거시 onFormDataChange 콜백도 호출 (호환성 유지) if (onFormDataChange) { // modal-repeater-table은 배열 데이터를 다룸 if (componentType === "modal-repeater-table") { diff --git a/frontend/types/unified-form.ts b/frontend/types/unified-form.ts new file mode 100644 index 00000000..e3ecd13a --- /dev/null +++ b/frontend/types/unified-form.ts @@ -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; + + // 콜백 + onBeforeSubmit?: (data: Record) => Record | Promise>; + onAfterSubmit?: (result: SubmitResult) => void; +} + +/** + * 저장 결과 + */ +export interface SubmitResult { + success: boolean; + data?: Record; + 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; +} + +/** + * 데이터 전달 트리거 + */ +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; + fieldStates: Record; + + getValue: (fieldId: string) => unknown; + setValue: (fieldId: string, value: unknown) => void; + setValues: (values: Record) => void; + + // === 새로 추가되는 기능 === + + // 원본 데이터 (수정 모드) + originalData: Record; + + // 폼 상태 + status: FormStatus; + errors: FieldError[]; + + // 폼 액션 + submit: (config?: Partial) => Promise; + reset: () => void; + validate: (fieldIds?: string[]) => Promise; + clear: () => void; + + // 초기 데이터 설정 (수정 모드 진입) + setInitialData: (data: Record) => void; + + // 에러 관리 + setFieldError: (fieldId: string, error: string, type?: FieldError["type"]) => void; + clearFieldError: (fieldId: string) => void; + clearAllErrors: () => void; + + // dirty 체크 + getChangedFields: () => string[]; + hasChanges: () => boolean; + + // 필드 등록/해제 + 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) => void; + updateRepeaterRow: (fieldName: string, index: number, row: Record) => 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; + }; + + // beforeFormSave 이벤트 발생 여부 + emitLegacyEvents?: boolean; +} + +/** + * 호환성 브릿지 반환값 + */ +export interface FormCompatibilityBridge { + // 값 관리 (통합 API) + getValue: (field: string) => unknown; + setValue: (field: string, value: unknown) => void; + + // 폼 액션 (통합 API) + submit: (config?: Partial) => Promise; + reset: () => void; + validate: () => Promise; + + // 폼 데이터 (읽기 전용) + formData: Record; + + // 상태 + isSubmitting: boolean; + isDirty: boolean; + errors: FieldError[]; + + // 모드 + isUnifiedMode: boolean; // UnifiedFormContext 사용 여부 + isLegacyMode: boolean; // 레거시 모드 여부 +} + +// ===== 이벤트 타입 ===== + +/** + * 폼 이벤트 상세 + */ +export interface FormEventDetail { + formData: Record; + fieldName?: string; + value?: unknown; + action?: FormActionType; +} + +/** + * beforeFormSave 이벤트 (레거시 호환) + */ +export interface BeforeFormSaveEvent extends CustomEvent { + type: "beforeFormSave"; +} + +/** + * afterFormSave 이벤트 + */ +export interface AfterFormSaveEvent extends CustomEvent<{ + success: boolean; + data?: Record; + error?: string; +}> { + type: "afterFormSave"; +} + +/** + * 화면 간 데이터 전달 이벤트 + */ +export interface ScreenDataTransferEvent extends CustomEvent<{ + sourceScreenId?: number; + targetScreenId?: number; + data: Record; + trigger: DataTransferTrigger; +}> { + type: "screenDataTransfer"; +} + +// ===== 유틸리티 타입 ===== + +/** + * 폼 데이터에서 특정 필드만 추출 + */ +export type PickFormFields, K extends keyof T> = Pick; + +/** + * 폼 데이터에서 특정 필드 제외 + */ +export type OmitFormFields, K extends keyof T> = Omit; + +/** + * 필드를 옵셔널로 변환 + */ +export type PartialFormData> = Partial; + +/** + * 리피터 데이터 타입 + */ +export type RepeaterData = Record> = T[]; +