"use client"; import React from "react"; import { ComponentData } from "@/types/screen"; import { DynamicLayoutRenderer } from "./DynamicLayoutRenderer"; import { ComponentRegistry } from "./ComponentRegistry"; import { filterDOMProps } from "@/lib/utils/domPropsFilter"; // 통합 폼 시스템 import import { useV2FormOptional } from "@/components/v2/V2FormContext"; // 컴포넌트 렌더러 인터페이스 export interface ComponentRenderer { (props: { component: ComponentData; isSelected?: boolean; isInteractive?: boolean; formData?: Record; originalData?: Record; // 부분 업데이트용 원본 데이터 onFormDataChange?: (fieldName: string, value: any) => void; onClick?: (e?: React.MouseEvent) => void; onDragStart?: (e: React.DragEvent) => void; onDragEnd?: () => void; children?: React.ReactNode; onZoneComponentDrop?: (e: React.DragEvent, zoneId: string, layoutId: string) => void; onZoneClick?: (zoneId: string) => void; // 버튼 액션을 위한 추가 props screenId?: number; tableName?: string; onRefresh?: () => void; onClose?: () => void; // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; selectedRowsData?: any[]; onSelectedRowsChange?: ( selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[], ) => void; // 테이블 정렬 정보 (엑셀 다운로드용) sortBy?: string; sortOrder?: "asc" | "desc"; tableDisplayData?: any[]; // 🆕 화면 표시 데이터 // 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용) flowSelectedData?: any[]; flowSelectedStepId?: number | null; onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void; // 테이블 새로고침 키 refreshKey?: number; // 편집 모드 mode?: "view" | "edit"; // 설정 변경 핸들러 (상세설정과 연동) onConfigChange?: (config: any) => void; [key: string]: any; }): React.ReactElement; } // 레거시 렌더러 레지스트리 (기존 컴포넌트들용) class LegacyComponentRegistry { private renderers: Map = new Map(); // 컴포넌트 렌더러 등록 register(componentType: string, renderer: ComponentRenderer) { this.renderers.set(componentType, renderer); } // 컴포넌트 렌더러 조회 get(componentType: string): ComponentRenderer | undefined { return this.renderers.get(componentType); } // 등록된 모든 컴포넌트 타입 조회 getRegisteredTypes(): string[] { return Array.from(this.renderers.keys()); } // 컴포넌트 타입이 등록되어 있는지 확인 has(componentType: string): boolean { const result = this.renderers.has(componentType); return result; } } // 전역 레거시 레지스트리 인스턴스 export const legacyComponentRegistry = new LegacyComponentRegistry(); // 하위 호환성을 위한 기존 이름 유지 export const componentRegistry = legacyComponentRegistry; // 동적 컴포넌트 렌더러 컴포넌트 export interface DynamicComponentRendererProps { component: ComponentData; isSelected?: boolean; isPreview?: boolean; // 반응형 모드 플래그 isDesignMode?: boolean; // 디자인 모드 여부 (false일 때 데이터 로드) onClick?: (e?: React.MouseEvent) => void; onDragStart?: (e: React.DragEvent) => void; onDragEnd?: () => void; children?: React.ReactNode; // 폼 데이터 관련 formData?: Record; originalData?: Record; // 부분 업데이트용 원본 데이터 onFormDataChange?: (fieldName: string, value: any) => void; // 버튼 액션을 위한 추가 props screenId?: number; tableName?: string; menuId?: number; // 🆕 메뉴 ID (카테고리 관리 등에 필요) menuObjid?: number; // 🆕 메뉴 OBJID (메뉴 스코프 - 카테고리/채번) selectedScreen?: any; // 🆕 화면 정보 전체 (menuId 등 추출용) userId?: string; // 🆕 현재 사용자 ID userName?: string; // 🆕 현재 사용자 이름 companyCode?: string; // 🆕 현재 사용자의 회사 코드 onRefresh?: () => void; onClose?: () => void; onSave?: () => Promise; // 🆕 EditModal의 handleSave 콜백 // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; // 🆕 그룹 데이터 (EditModal → ModalRepeaterTable) groupedData?: Record[]; // 🆕 비활성화할 필드 목록 (EditModal → 각 컴포넌트) disabledFields?: string[]; selectedRowsData?: any[]; onSelectedRowsChange?: ( selectedRows: any[], selectedRowsData: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[], tableDisplayData?: any[], ) => void; // 테이블 정렬 정보 (엑셀 다운로드용) sortBy?: string; sortOrder?: "asc" | "desc"; columnOrder?: string[]; tableDisplayData?: any[]; // 🆕 화면 표시 데이터 // 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용) flowSelectedData?: any[]; // 🆕 컴포넌트 업데이트 콜백 (탭 내부 컴포넌트 위치 조정 등) onUpdateComponent?: (updatedComponent: any) => void; // 🆕 탭 내부 컴포넌트 선택 콜백 onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void; selectedTabComponentId?: string; // 🆕 분할 패널 내부 컴포넌트 선택 콜백 onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void; selectedPanelComponentId?: string; flowSelectedStepId?: number | null; onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void; // 테이블 새로고침 키 refreshKey?: number; // 플로우 새로고침 키 flowRefreshKey?: number; onFlowRefresh?: () => void; // 편집 모드 mode?: "view" | "edit"; // 모달 내에서 렌더링 여부 isInModal?: boolean; // 탭 관련 정보 (탭 내부의 컴포넌트에서 사용) parentTabId?: string; // 부모 탭 ID parentTabsComponentId?: string; // 부모 탭 컴포넌트 ID // 🆕 조건부 비활성화 상태 conditionalDisabled?: boolean; [key: string]: any; } export const DynamicComponentRenderer: React.FC = ({ component, isSelected = false, isPreview = false, onClick, onDragStart, onDragEnd, children, ...props }) => { // 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용 // 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input") const extractTypeFromUrl = (url: string | undefined): string | undefined => { if (!url) return undefined; // url의 마지막 세그먼트를 컴포넌트 타입으로 사용 const segments = url.split("/"); return segments[segments.length - 1]; }; const rawComponentType = (component as any).componentType || component.type || extractTypeFromUrl((component as any).url); // 레거시 타입을 v2 컴포넌트로 매핑 (v2 컴포넌트가 없으면 원본 유지) const mapToV2ComponentType = (type: string | undefined): string | undefined => { if (!type) return type; // 이미 v2- 접두사가 있으면 그대로 반환 if (type.startsWith("v2-")) return type; // 레거시 타입을 v2로 매핑 시도 const v2Type = `v2-${type}`; // v2 버전이 등록되어 있는지 확인 if (ComponentRegistry.hasComponent(v2Type)) { return v2Type; } // v2 버전이 없으면 원본 유지 return type; }; const componentType = mapToV2ComponentType(rawComponentType); // 컴포넌트 타입 변환 완료 // 🆕 조건부 렌더링 체크 (conditionalConfig) // componentConfig 또는 overrides에서 conditionalConfig를 가져와서 formData와 비교 const conditionalConfig = (component as any).componentConfig?.conditionalConfig || (component as any).overrides?.conditionalConfig; // 디버그: 조건부 렌더링 설정 확인 if (conditionalConfig?.enabled) { console.log(`🔍 [조건부 렌더링] ${component.id}:`, { conditionalConfig, formData: props.formData, hasFormData: !!props.formData }); } if (conditionalConfig?.enabled && props.formData) { const { field, operator, value, action } = conditionalConfig; const fieldValue = props.formData[field]; console.log(`🔍 [조건부 렌더링 평가] ${component.id}:`, { field, fieldValue, operator, expectedValue: value, action }); // 조건 평가 let conditionMet = false; switch (operator) { case "=": case "==": case "===": conditionMet = fieldValue === value; break; case "!=": case "!==": conditionMet = fieldValue !== value; break; case ">": conditionMet = Number(fieldValue) > Number(value); break; case "<": conditionMet = Number(fieldValue) < Number(value); break; case ">=": conditionMet = Number(fieldValue) >= Number(value); break; case "<=": conditionMet = Number(fieldValue) <= Number(value); break; case "contains": conditionMet = String(fieldValue || "").includes(String(value)); break; case "empty": conditionMet = !fieldValue || fieldValue === ""; break; case "notEmpty": conditionMet = !!fieldValue && fieldValue !== ""; break; default: conditionMet = fieldValue === value; } // 액션에 따라 렌더링 결정 console.log(`🔍 [조건부 렌더링 결과] ${component.id}:`, { conditionMet, action, shouldRender: action === "show" ? conditionMet : !conditionMet }); if (action === "show" && !conditionMet) { // "show" 액션: 조건이 충족되지 않으면 렌더링하지 않음 console.log(`❌ [조건부 렌더링] ${component.id} 숨김 처리 (show 조건 불충족)`); return null; } if (action === "hide" && conditionMet) { // "hide" 액션: 조건이 충족되면 렌더링하지 않음 console.log(`❌ [조건부 렌더링] ${component.id} 숨김 처리 (hide 조건 충족)`); return null; } // "enable"/"disable" 액션은 conditionalDisabled props로 전달 } // 🆕 모든 v2- 컴포넌트는 ComponentRegistry에서 통합 처리 // (v2-input, v2-select, v2-repeat-container 등 모두 동일하게 처리) // 🎯 카테고리 타입 우선 처리 (inputType 또는 webType 확인) const inputType = (component as any).componentConfig?.inputType || (component as any).inputType; const webType = (component as any).componentConfig?.webType; const tableName = (component as any).tableName; const columnName = (component as any).columnName; // 카테고리 셀렉트: webType이 "category"이고 tableName과 columnName이 있는 경우만 // ⚠️ 단, componentType이 "select-basic"인 경우는 ComponentRegistry로 처리 (다중선택 등 고급 기능 지원) if ( (inputType === "category" || webType === "category") && tableName && columnName && componentType === "select-basic" ) { // select-basic은 ComponentRegistry에서 처리하도록 아래로 통과 } else if ((inputType === "category" || webType === "category") && tableName && columnName) { try { const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent"); const fieldName = columnName || component.id; const currentValue = props.formData?.[fieldName] || ""; const handleChange = (value: any) => { if (props.onFormDataChange) { props.onFormDataChange(fieldName, value); } }; // 🆕 disabledFields 체크 + readonly 체크 const isFieldDisabled = props.disabledFields?.includes(columnName) || (component as any).disabled; const isFieldReadonly = (component as any).readonly || (component as any).componentConfig?.readonly; return ( ); } catch (error) { console.error("❌ CategorySelectComponent 로드 실패:", error); } } // 레이아웃 컴포넌트 처리 if (componentType === "layout") { // DOM 안전한 props만 전달 const safeLayoutProps = filterDOMProps(props); return ( ); } // 1. 새 컴포넌트 시스템에서 먼저 조회 const newComponent = ComponentRegistry.getComponent(componentType); if (newComponent) { // 새 컴포넌트 시스템으로 렌더링 try { const NewComponentRenderer = newComponent.component; if (NewComponentRenderer) { // React 전용 props들을 명시적으로 분리하고 DOM 안전한 props만 전달 const { isInteractive, formData, onFormDataChange, tableName, menuId, // 🆕 메뉴 ID menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프) selectedScreen, // 🆕 화면 정보 onRefresh, onClose, onSave, // 🆕 EditModal의 handleSave 콜백 screenId, userId, // 🆕 사용자 ID userName, // 🆕 사용자 이름 companyCode, // 🆕 회사 코드 mode, isInModal, originalData, allComponents, onUpdateLayout, onZoneClick, selectedRows, selectedRowsData, onSelectedRowsChange, sortBy, // 🆕 정렬 컬럼 sortOrder, // 🆕 정렬 방향 tableDisplayData, // 🆕 화면 표시 데이터 flowSelectedData, flowSelectedStepId, onFlowSelectedDataChange, refreshKey, flowRefreshKey, // Added this onFlowRefresh, // Added this onConfigChange, isPreview, autoGeneration, disabledFields, // 🆕 비활성화 필드 목록 ...restProps } = props; // DOM 안전한 props만 필터링 const safeProps = filterDOMProps(restProps); // 컴포넌트의 columnName에 해당하는 formData 값 추출 const fieldName = (component as any).columnName || (component as any).componentConfig?.columnName || component.id; // 🔍 파일 업로드 컴포넌트 디버깅 if (componentType === "v2-media" || componentType === "file-upload") { console.log("[DynamicComponentRenderer] 파일 업로드:", { componentType, componentId: component.id, columnName: (component as any).columnName, configColumnName: (component as any).componentConfig?.columnName, fieldName, formDataValue: props.formData?.[fieldName], formDataKeys: props.formData ? Object.keys(props.formData) : [] }); } // 다중 레코드를 다루는 컴포넌트는 배열 데이터로 초기화 let currentValue; if (componentType === "modal-repeater-table" || componentType === "repeat-screen-modal" || componentType === "selected-items-detail-input") { // EditModal/ScreenModal에서 전달된 groupedData가 있으면 우선 사용 currentValue = props.groupedData || formData?.[fieldName] || []; } else { currentValue = formData?.[fieldName] || ""; } // 🆕 V2 폼 시스템 연동 (Context가 있으면 사용, 없으면 null) // eslint-disable-next-line react-hooks/rules-of-hooks const v2FormContext = useV2FormOptional(); // onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리 // 🆕 V2 시스템과 레거시 시스템 모두에 전파 const handleChange = (value: any) => { // autocomplete-search-input, entity-search-input은 자체적으로 onFormDataChange를 호출하므로 중복 저장 방지 if (componentType === "autocomplete-search-input" || componentType === "entity-search-input") { return; } // React 이벤트 객체인 경우 값 추출 let actualValue = value; if (value && typeof value === "object" && value.nativeEvent && value.target) { // SyntheticEvent인 경우 target.value 추출 actualValue = value.target.value; } // 1. V2 폼 시스템에 전파 (있으면) if (v2FormContext) { v2FormContext.setValue(fieldName, actualValue); } // 2. 레거시 onFormDataChange 콜백도 호출 (호환성 유지) if (onFormDataChange) { // modal-repeater-table은 배열 데이터를 다룸 if (componentType === "modal-repeater-table") { onFormDataChange(fieldName, actualValue); } // RepeaterInput 같은 복합 컴포넌트는 전체 데이터를 전달 else if (componentType === "repeater-field-group" || componentType === "repeater") { onFormDataChange(fieldName, actualValue); } else { onFormDataChange(fieldName, actualValue); } } }; // 렌더러 props 구성 // 숨김 값 추출 const hiddenValue = component.hidden || component.componentConfig?.hidden; // 숨김 처리: 인터랙티브 모드(실제 뷰)에서만 숨김, 디자인 모드에서는 표시 if (hiddenValue && isInteractive) { return null; } // size.width와 size.height를 style.width와 style.height로 변환 const finalStyle: React.CSSProperties = { ...component.style, width: component.size?.width ? `${component.size.width}px` : component.style?.width, height: component.size?.height ? `${component.size.height}px` : component.style?.height, }; // 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName을 사용해야 함 (화면 테이블이 아닌 검색 대상 테이블) // 🆕 v2-input도 포함 (채번 규칙 조회 시 tableName 필요) const useConfigTableName = componentType === "entity-search-input" || componentType === "autocomplete-search-input" || componentType === "modal-repeater-table" || componentType === "v2-input"; const rendererProps = { component, isSelected, onClick, onDragStart, onDragEnd, size: component.size || newComponent.defaultSize, position: component.position, style: finalStyle, // size를 포함한 최종 style config: component.componentConfig, componentConfig: component.componentConfig, // componentConfig의 모든 속성을 props로 spread (tableName, displayField 등) ...(component.componentConfig || {}), // 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 inputType: (component as any).inputType || component.componentConfig?.inputType, columnName: (component as any).columnName || component.componentConfig?.columnName, value: currentValue, // formData에서 추출한 현재 값 전달 // 새로운 기능들 전달 // 🆕 webTypeConfig.numberingRuleId가 있으면 autoGeneration으로 변환 autoGeneration: component.autoGeneration || component.componentConfig?.autoGeneration || ((component as any).webTypeConfig?.numberingRuleId ? { type: "numbering_rule" as const, enabled: true, options: { numberingRuleId: (component as any).webTypeConfig.numberingRuleId, }, } : undefined), hidden: hiddenValue, // React 전용 props들은 직접 전달 (DOM에 전달되지 않음) isInteractive, formData, onFormDataChange, onChange: handleChange, // 개선된 onChange 핸들러 전달 // 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName 유지, 그 외는 화면 테이블명 사용 // 🆕 component.tableName도 확인 (V2 레이아웃에서 overrides.tableName이 복원됨) tableName: useConfigTableName ? component.componentConfig?.tableName || (component as any).tableName || tableName : tableName, menuId, // 🆕 메뉴 ID menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프) selectedScreen, // 🆕 화면 정보 onRefresh, onClose, onSave, // 🆕 EditModal의 handleSave 콜백 screenId, userId, // 🆕 사용자 ID userName, // 🆕 사용자 이름 companyCode, // 🆕 회사 코드 // 🆕 화면 모드 (edit/view)와 컴포넌트 UI 모드 구분 screenMode: mode, // componentConfig.mode가 있으면 유지 (entity-search-input의 UI 모드) mode: component.componentConfig?.mode || mode, isInModal, readonly: component.readonly, // 🆕 disabledFields 체크 또는 기존 readonly disabled: disabledFields?.includes(fieldName) || component.readonly, originalData, allComponents, onUpdateLayout, onZoneClick, // 테이블 선택된 행 정보 전달 selectedRows, selectedRowsData, onSelectedRowsChange, // 테이블 정렬 정보 전달 sortBy, sortOrder, tableDisplayData, // 🆕 화면 표시 데이터 // 플로우 선택된 데이터 정보 전달 flowSelectedData, flowSelectedStepId, onFlowSelectedDataChange, // 설정 변경 핸들러 전달 onConfigChange, refreshKey, // 플로우 새로고침 키 flowRefreshKey, onFlowRefresh, // 반응형 모드 플래그 전달 isPreview, // 디자인 모드 플래그 전달 - isPreview와 명확히 구분 isDesignMode: props.isDesignMode !== undefined ? props.isDesignMode : false, // 🆕 그룹 데이터 전달 (EditModal → ConditionalContainer → ModalRepeaterTable) // Note: 이 props들은 DOM 요소에 전달되면 안 됨 // 각 컴포넌트에서 명시적으로 destructure하여 사용해야 함 groupedData: props.groupedData, // ✅ 언더스코어 제거하여 직접 전달 _groupedData: props.groupedData, // 하위 호환성 유지 // 🆕 UniversalFormModal용 initialData 전달 // 우선순위: props.initialData > originalData > formData // 조건부 컨테이너에서 전달된 initialData가 있으면 그것을 사용 _initialData: props.initialData || ((originalData && Object.keys(originalData).length > 0) ? originalData : formData), _originalData: originalData, // 🆕 initialData도 직접 전달 (조건부 컨테이너 → 내부 컴포넌트) initialData: props.initialData, // 🆕 탭 관련 정보 전달 (탭 내부의 테이블 컴포넌트에서 사용) parentTabId: props.parentTabId, parentTabsComponentId: props.parentTabsComponentId, // 🆕 컴포넌트 업데이트 콜백 (탭 내부 컴포넌트 위치 조정 등) onUpdateComponent: props.onUpdateComponent, // 🆕 탭 내부 컴포넌트 선택 콜백 onSelectTabComponent: props.onSelectTabComponent, selectedTabComponentId: props.selectedTabComponentId, // 🆕 분할 패널 내부 컴포넌트 선택 콜백 onSelectPanelComponent: props.onSelectPanelComponent, selectedPanelComponentId: props.selectedPanelComponentId, }; // 렌더러가 클래스인지 함수인지 확인 const isClass = typeof NewComponentRenderer === "function" && NewComponentRenderer.prototype && NewComponentRenderer.prototype.render; if (isClass) { // 클래스 기반 렌더러 (AutoRegisteringComponentRenderer 상속) const rendererInstance = new NewComponentRenderer(rendererProps); return rendererInstance.render(); } else { // 함수형 컴포넌트 // refreshKey를 React key로 전달하여 컴포넌트 리마운트 강제 return ; } } } catch (error) { console.error(`❌ 새 컴포넌트 렌더링 실패 (${componentType}):`, error); } } // 2. 레거시 시스템에서 조회 const renderer = legacyComponentRegistry.get(componentType); if (!renderer) { console.error(`⚠️ 등록되지 않은 컴포넌트 타입: ${componentType}`, { component: component, componentId: component.id, componentLabel: component.label, componentType: componentType, originalType: component.type, originalComponentType: (component as any).componentType, componentConfig: component.componentConfig, webTypeConfig: (component as any).webTypeConfig, autoGeneration: (component as any).autoGeneration, availableNewComponents: ComponentRegistry.getAllComponents().map((c) => c.id), availableLegacyComponents: legacyComponentRegistry.getRegisteredTypes(), }); // 폴백 렌더링 - 기본 플레이스홀더 return (
{component.label || component.id}
미구현 컴포넌트: {componentType}
); } // 동적 렌더링 실행 try { // 레거시 시스템에서도 DOM 안전한 props만 전달 const safeLegacyProps = filterDOMProps(props); return renderer({ component, isSelected, onClick, onDragStart, onDragEnd, children, // React 전용 props들은 명시적으로 전달 (레거시 컴포넌트가 필요한 경우) isInteractive: props.isInteractive, formData: props.formData, onFormDataChange: props.onFormDataChange, screenId: props.screenId, tableName: props.tableName, userId: props.userId, // 🆕 사용자 ID userName: props.userName, // 🆕 사용자 이름 companyCode: props.companyCode, // 🆕 회사 코드 onRefresh: props.onRefresh, onClose: props.onClose, mode: props.mode, isInModal: props.isInModal, originalData: props.originalData, onUpdateLayout: props.onUpdateLayout, onZoneClick: props.onZoneClick, onZoneComponentDrop: props.onZoneComponentDrop, allComponents: props.allComponents, // 테이블 선택된 행 정보 전달 selectedRows: props.selectedRows, selectedRowsData: props.selectedRowsData, onSelectedRowsChange: props.onSelectedRowsChange, // 플로우 선택된 데이터 정보 전달 flowSelectedData: props.flowSelectedData, flowSelectedStepId: props.flowSelectedStepId, onFlowSelectedDataChange: props.onFlowSelectedDataChange, refreshKey: props.refreshKey, // DOM 안전한 props들 ...safeLegacyProps, }); } catch (error) { console.error(`❌ 컴포넌트 렌더링 실패 (${componentType}):`, error); // 오류 발생 시 폴백 렌더링 return (
렌더링 오류
{componentType}: {error instanceof Error ? error.message : "알 수 없는 오류"}
); } }; export default DynamicComponentRenderer;