- Updated the EditModal component to check for registered V2Repeater instances before saving detail data, enhancing the reliability of the repeater save process. - Simplified the V2Repeater component by removing unnecessary groupedData handling, ensuring it manages its own data effectively. - Enhanced the DynamicComponentRenderer to correctly handle V2Repeater's data management, improving overall component behavior. - Refactored button actions to wait for V2Repeater save completion only when active repeaters are present, optimizing performance and user experience.
867 lines
35 KiB
TypeScript
867 lines
35 KiB
TypeScript
"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";
|
|
import { apiClient } from "@/lib/api/client";
|
|
|
|
// 컬럼 메타데이터 캐시 (테이블명 → 컬럼 설정 맵)
|
|
const columnMetaCache: Record<string, Record<string, any>> = {};
|
|
const columnMetaLoading: Record<string, Promise<void>> = {};
|
|
|
|
async function loadColumnMeta(tableName: string): Promise<void> {
|
|
if (columnMetaCache[tableName] || columnMetaLoading[tableName]) return;
|
|
|
|
columnMetaLoading[tableName] = (async () => {
|
|
try {
|
|
const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=1000`);
|
|
const data = response.data.data || response.data;
|
|
const columns = data.columns || data || [];
|
|
const map: Record<string, any> = {};
|
|
for (const col of columns) {
|
|
const name = col.column_name || col.columnName;
|
|
if (name) map[name] = col;
|
|
}
|
|
columnMetaCache[tableName] = map;
|
|
} catch {
|
|
columnMetaCache[tableName] = {};
|
|
} finally {
|
|
delete columnMetaLoading[tableName];
|
|
}
|
|
})();
|
|
|
|
await columnMetaLoading[tableName];
|
|
}
|
|
|
|
// table_type_columns 기반 componentConfig 병합 (기존 설정이 없을 때만 DB 메타데이터로 보완)
|
|
function mergeColumnMeta(tableName: string | undefined, columnName: string | undefined, componentConfig: any): any {
|
|
if (!tableName || !columnName) return componentConfig;
|
|
|
|
const meta = columnMetaCache[tableName]?.[columnName];
|
|
if (!meta) return componentConfig;
|
|
|
|
const inputType = meta.input_type || meta.inputType;
|
|
if (!inputType) return componentConfig;
|
|
|
|
// 이미 source가 올바르게 설정된 경우 건드리지 않음
|
|
const existingSource = componentConfig?.source;
|
|
if (existingSource && existingSource !== "static" && existingSource !== "distinct" && existingSource !== "select") {
|
|
return componentConfig;
|
|
}
|
|
|
|
const merged = { ...componentConfig };
|
|
|
|
// source가 미설정/기본값일 때만 DB 메타데이터로 보완
|
|
if (inputType === "entity") {
|
|
const refTable = meta.reference_table || meta.referenceTable;
|
|
const refColumn = meta.reference_column || meta.referenceColumn;
|
|
const displayCol = meta.display_column || meta.displayColumn;
|
|
if (refTable && !merged.entityTable) {
|
|
merged.source = "entity";
|
|
merged.entityTable = refTable;
|
|
merged.entityValueColumn = refColumn || "id";
|
|
merged.entityLabelColumn = displayCol || "name";
|
|
}
|
|
} else if (inputType === "category" && !existingSource) {
|
|
merged.source = "category";
|
|
} else if (inputType === "select" && !existingSource) {
|
|
const detail = typeof meta.detail_settings === "string" ? JSON.parse(meta.detail_settings || "{}") : (meta.detail_settings || {});
|
|
if (detail.options && !merged.options?.length) {
|
|
merged.options = detail.options;
|
|
}
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
// 컴포넌트 렌더러 인터페이스
|
|
export interface ComponentRenderer {
|
|
(props: {
|
|
component: ComponentData;
|
|
isSelected?: boolean;
|
|
isInteractive?: boolean;
|
|
formData?: Record<string, any>;
|
|
originalData?: Record<string, any>; // 부분 업데이트용 원본 데이터
|
|
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<string, ComponentRenderer> = 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<string, any>;
|
|
originalData?: Record<string, any>; // 부분 업데이트용 원본 데이터
|
|
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<void>; // 🆕 EditModal의 handleSave 콜백
|
|
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
|
selectedRows?: any[];
|
|
// 🆕 그룹 데이터 (EditModal → ModalRepeaterTable)
|
|
groupedData?: Record<string, any>[];
|
|
// 🆕 비활성화할 필드 목록 (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<DynamicComponentRendererProps> = ({
|
|
component,
|
|
isSelected = false,
|
|
isPreview = false,
|
|
onClick,
|
|
onDragStart,
|
|
onDragEnd,
|
|
children,
|
|
...props
|
|
}) => {
|
|
// 컬럼 메타데이터 로드 트리거 (테이블명이 있으면 비동기 로드)
|
|
const screenTableName = props.tableName || (component as any).tableName;
|
|
const [, forceUpdate] = React.useState(0);
|
|
React.useEffect(() => {
|
|
if (screenTableName && !columnMetaCache[screenTableName]) {
|
|
loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1));
|
|
}
|
|
}, [screenTableName]);
|
|
|
|
// 컴포넌트 타입 추출 - 새 시스템에서는 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 && props.formData) {
|
|
const { field, operator, value, action } = conditionalConfig;
|
|
const fieldValue = props.formData[field];
|
|
|
|
// 조건 평가
|
|
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;
|
|
}
|
|
|
|
// 액션에 따라 렌더링 결정
|
|
if (action === "show" && !conditionMet) {
|
|
return null;
|
|
}
|
|
if (action === "hide" && conditionMet) {
|
|
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이 있는 경우만
|
|
// ⚠️ 단, 다음 경우는 V2SelectRenderer로 직접 처리 (고급 모드 지원):
|
|
// 1. componentType이 "select-basic" 또는 "v2-select"인 경우
|
|
// 2. config.mode가 dropdown이 아닌 경우 (radio, check, tagbox 등)
|
|
const componentMode = (component as any).componentConfig?.mode || (component as any).config?.mode;
|
|
const isMultipleSelect = (component as any).componentConfig?.multiple;
|
|
const nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap", "combobox"];
|
|
const isNonDropdownMode = componentMode && nonDropdownModes.includes(componentMode);
|
|
const shouldUseV2Select = componentType === "select-basic" || componentType === "v2-select" || isNonDropdownMode || isMultipleSelect;
|
|
|
|
if (
|
|
(inputType === "category" || webType === "category") &&
|
|
tableName &&
|
|
columnName &&
|
|
shouldUseV2Select
|
|
) {
|
|
// V2SelectRenderer로 직접 렌더링 (카테고리 + 고급 모드)
|
|
try {
|
|
const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer");
|
|
const fieldName = columnName || component.id;
|
|
const currentValue = props.formData?.[fieldName] || "";
|
|
|
|
const handleChange = (value: any) => {
|
|
if (props.onFormDataChange) {
|
|
props.onFormDataChange(fieldName, value);
|
|
}
|
|
};
|
|
|
|
// V2SelectRenderer용 컴포넌트 데이터 구성
|
|
const selectComponent = {
|
|
...component,
|
|
componentConfig: {
|
|
...component.componentConfig,
|
|
mode: componentMode || "dropdown",
|
|
source: "category",
|
|
categoryTable: tableName,
|
|
categoryColumn: columnName,
|
|
},
|
|
tableName,
|
|
columnName,
|
|
inputType: "category",
|
|
webType: "category",
|
|
};
|
|
|
|
const rendererProps = {
|
|
component: selectComponent,
|
|
formData: props.formData,
|
|
onFormDataChange: props.onFormDataChange,
|
|
isDesignMode: props.isDesignMode,
|
|
isInteractive: props.isInteractive ?? !props.isDesignMode,
|
|
tableName,
|
|
style: (component as any).style,
|
|
size: (component as any).size,
|
|
};
|
|
|
|
const rendererInstance = new V2SelectRenderer(rendererProps);
|
|
return rendererInstance.render();
|
|
} catch (error) {
|
|
console.error("❌ V2SelectRenderer 로드 실패:", error);
|
|
}
|
|
} 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;
|
|
|
|
// 🔧 높이 계산: component.size에서 height 추출
|
|
const categorySize = (component as any).size;
|
|
const categoryStyle = (component as any).style;
|
|
const categoryLabel = (component as any).label;
|
|
const categoryId = component.id;
|
|
|
|
return (
|
|
<CategorySelectComponent
|
|
tableName={tableName}
|
|
columnName={columnName}
|
|
value={currentValue}
|
|
onChange={handleChange}
|
|
placeholder={component.componentConfig?.placeholder || "선택하세요"}
|
|
required={(component as any).required}
|
|
disabled={isFieldDisabled}
|
|
readonly={isFieldReadonly}
|
|
className="w-full"
|
|
size={categorySize}
|
|
style={categoryStyle}
|
|
label={categoryLabel}
|
|
id={categoryId}
|
|
isDesignMode={props.isDesignMode}
|
|
/>
|
|
);
|
|
} catch (error) {
|
|
console.error("❌ CategorySelectComponent 로드 실패:", error);
|
|
}
|
|
}
|
|
|
|
// 레이아웃 컴포넌트 처리
|
|
if (componentType === "layout") {
|
|
// DOM 안전한 props만 전달
|
|
const safeLayoutProps = filterDOMProps(props);
|
|
|
|
return (
|
|
<DynamicLayoutRenderer
|
|
layout={component as any}
|
|
allComponents={props.allComponents || []}
|
|
isSelected={isSelected}
|
|
onClick={onClick}
|
|
onDragStart={onDragStart}
|
|
onDragEnd={onDragEnd}
|
|
onUpdateLayout={props.onUpdateLayout}
|
|
// onComponentDrop 제거 - 일반 캔버스 드롭만 사용
|
|
onZoneClick={props.onZoneClick}
|
|
isInteractive={props.isInteractive}
|
|
formData={props.formData}
|
|
onFormDataChange={props.onFormDataChange}
|
|
screenId={props.screenId}
|
|
tableName={props.tableName}
|
|
onRefresh={props.onRefresh}
|
|
onClose={props.onClose}
|
|
mode={props.mode}
|
|
isInModal={props.isInModal}
|
|
originalData={props.originalData}
|
|
{...safeLayoutProps}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 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;
|
|
|
|
// 다중 레코드를 다루는 컴포넌트는 배열 데이터로 초기화
|
|
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 if (componentType === "v2-repeater") {
|
|
// V2Repeater는 자체 데이터 관리 (groupedData는 메인 테이블 레코드이므로 사용하지 않음)
|
|
currentValue = 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";
|
|
|
|
// 🆕 v2-input 등의 라벨 표시 로직 (labelDisplay가 true일 때만 라벨 표시)
|
|
const labelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay;
|
|
const effectiveLabel = labelDisplay === true
|
|
? (component.style?.labelText || (component as any).label || component.componentConfig?.label)
|
|
: undefined;
|
|
|
|
// 🔧 순서 중요! component.style 먼저, CSS 크기 속성은 size 기반으로 덮어씀
|
|
const mergedStyle = {
|
|
...component.style, // 원본 style (labelDisplay, labelText 등) - 먼저!
|
|
// CSS 크기 속성은 size에서 계산한 값으로 명시적 덮어쓰기 (우선순위 최고)
|
|
width: finalStyle.width,
|
|
height: finalStyle.height,
|
|
};
|
|
|
|
// 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선)
|
|
const isEntityJoinColumn = fieldName?.includes(".");
|
|
const baseColumnName = isEntityJoinColumn ? undefined : fieldName;
|
|
const mergedComponentConfig = mergeColumnMeta(screenTableName, baseColumnName, component.componentConfig || {});
|
|
|
|
// 엔티티 조인 컬럼은 런타임에서 readonly/disabled 강제 해제
|
|
const effectiveComponent = isEntityJoinColumn
|
|
? { ...component, componentConfig: mergedComponentConfig, readonly: false }
|
|
: { ...component, componentConfig: mergedComponentConfig };
|
|
|
|
const rendererProps = {
|
|
component: effectiveComponent,
|
|
isSelected,
|
|
onClick,
|
|
onDragStart,
|
|
onDragEnd,
|
|
size: component.size || newComponent.defaultSize,
|
|
position: component.position,
|
|
config: mergedComponentConfig,
|
|
componentConfig: mergedComponentConfig,
|
|
// componentConfig의 모든 속성을 props로 spread (tableName, displayField 등)
|
|
...(mergedComponentConfig || {}),
|
|
// 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선)
|
|
style: mergedStyle,
|
|
// 🆕 라벨 표시 (labelDisplay가 true일 때만)
|
|
label: effectiveLabel,
|
|
// 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 (DB 메타데이터 우선)
|
|
inputType: (baseColumnName && columnMetaCache[screenTableName || ""]?.[baseColumnName]?.input_type) || (component as any).inputType || mergedComponentConfig?.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: isEntityJoinColumn ? false : component.readonly,
|
|
disabled: isEntityJoinColumn ? false : (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 <NewComponentRenderer key={refreshKey} {...rendererProps} />;
|
|
}
|
|
}
|
|
} 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 (
|
|
<div className="border-border bg-muted flex h-full w-full items-center justify-center rounded border-2 border-dashed p-4">
|
|
<div className="text-center">
|
|
<div className="text-muted-foreground mb-2 text-sm font-medium">{component.label || component.id}</div>
|
|
<div className="text-muted-foreground/70 text-xs">미구현 컴포넌트: {componentType}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 동적 렌더링 실행
|
|
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 (
|
|
<div className="flex h-full w-full items-center justify-center rounded border-2 border-red-300 bg-red-50 p-4">
|
|
<div className="text-center">
|
|
<div className="mb-2 text-sm font-medium text-red-600">렌더링 오류</div>
|
|
<div className="text-xs text-red-400">
|
|
{componentType}: {error instanceof Error ? error.message : "알 수 없는 오류"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
};
|
|
|
|
export default DynamicComponentRenderer;
|