- Updated EditModal to prioritize screen resolution settings from layout data, ensuring accurate dimension calculations. - Refined modal styling for better responsiveness and consistency with ScreenModal. - Adjusted V2Input and V2Select components to remove unnecessary gap styling, streamlining their layout. - Enhanced DynamicComponentRenderer to handle horizontal labels more effectively with an external wrapper. These changes aim to improve the user experience and visual consistency across the application.
1129 lines
47 KiB
TypeScript
1129 lines
47 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";
|
|
|
|
import { getAdaptiveLabelColor } from "@/lib/utils/darkModeColor";
|
|
|
|
// 컬럼 메타데이터 캐시 (테이블명 → 컬럼 설정 맵)
|
|
export const columnMetaCache: Record<string, Record<string, any>> = {};
|
|
const columnMetaLoading: Record<string, Promise<void>> = {};
|
|
const columnMetaTimestamp: Record<string, number> = {};
|
|
const CACHE_TTL_MS = 5000;
|
|
|
|
export function invalidateColumnMetaCache(tableName?: string): void {
|
|
if (tableName) {
|
|
delete columnMetaCache[tableName];
|
|
delete columnMetaLoading[tableName];
|
|
delete columnMetaTimestamp[tableName];
|
|
} else {
|
|
for (const key of Object.keys(columnMetaCache)) delete columnMetaCache[key];
|
|
for (const key of Object.keys(columnMetaLoading)) delete columnMetaLoading[key];
|
|
for (const key of Object.keys(columnMetaTimestamp)) delete columnMetaTimestamp[key];
|
|
}
|
|
}
|
|
|
|
async function loadColumnMeta(tableName: string, forceReload = false): Promise<void> {
|
|
const now = Date.now();
|
|
const isStale = columnMetaTimestamp[tableName] && (now - columnMetaTimestamp[tableName] > CACHE_TTL_MS);
|
|
|
|
if (!forceReload && !isStale && columnMetaCache[tableName]) return;
|
|
|
|
if (forceReload || isStale) {
|
|
delete columnMetaCache[tableName];
|
|
delete columnMetaLoading[tableName];
|
|
}
|
|
|
|
if (columnMetaLoading[tableName]) {
|
|
await 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;
|
|
columnMetaTimestamp[tableName] = Date.now();
|
|
} catch (e) {
|
|
console.error(`[columnMeta] ${tableName} 로드 실패:`, e);
|
|
columnMetaCache[tableName] = {};
|
|
} finally {
|
|
delete columnMetaLoading[tableName];
|
|
}
|
|
})();
|
|
|
|
await columnMetaLoading[tableName];
|
|
}
|
|
|
|
// 테이블 타입관리 NOT NULL 기반 필수 여부 판단
|
|
export function isColumnRequiredByMeta(tableName?: string, columnName?: string): boolean {
|
|
if (!tableName || !columnName) return false;
|
|
const meta = columnMetaCache[tableName]?.[columnName];
|
|
if (!meta) return false;
|
|
const nullable = meta.is_nullable || meta.isNullable;
|
|
return nullable === "NO" || nullable === "N";
|
|
}
|
|
|
|
// table_type_columns 기반 componentConfig 병합 (DB input_type 우선 적용)
|
|
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 rawType = meta.input_type || meta.inputType;
|
|
const dbInputType = rawType === "direct" || rawType === "auto" ? undefined : rawType;
|
|
if (!dbInputType) return componentConfig;
|
|
|
|
const merged = { ...componentConfig };
|
|
const savedFieldType = merged.fieldType;
|
|
|
|
// savedFieldType이 있고 DB와 같으면 변경 불필요
|
|
if (savedFieldType && savedFieldType === dbInputType) return merged;
|
|
// savedFieldType이 있고 DB와 다르면 — 사용자가 V2FieldConfigPanel에서 설정한 값 존중
|
|
if (savedFieldType) return merged;
|
|
|
|
// savedFieldType이 없으면: DB input_type 기준으로 동기화
|
|
// 기존 overrides의 source/inputType이 DB와 불일치하면 덮어씀
|
|
if (dbInputType === "entity") {
|
|
const refTable = meta.reference_table || meta.referenceTable;
|
|
const refColumn = meta.reference_column || meta.referenceColumn;
|
|
const rawDisplayCol = meta.display_column || meta.displayColumn;
|
|
const displayCol = rawDisplayCol && rawDisplayCol !== "none" && rawDisplayCol !== "" ? rawDisplayCol : undefined;
|
|
if (refTable) {
|
|
merged.source = "entity";
|
|
merged.entityTable = refTable;
|
|
merged.entityValueColumn = refColumn || "id";
|
|
// 화면 설정에 이미 entityLabelColumn이 있으면 유지, 없으면 DB 값 또는 기본값 사용
|
|
if (!merged.entityLabelColumn) {
|
|
merged.entityLabelColumn = displayCol || "name";
|
|
}
|
|
merged.fieldType = "entity";
|
|
merged.inputType = "entity";
|
|
}
|
|
} else if (dbInputType === "category") {
|
|
merged.source = "category";
|
|
merged.fieldType = "category";
|
|
merged.inputType = "category";
|
|
} else if (dbInputType === "select") {
|
|
if (!merged.source || merged.source === "category" || merged.source === "entity") {
|
|
merged.source = "static";
|
|
}
|
|
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;
|
|
}
|
|
merged.fieldType = "select";
|
|
merged.inputType = "select";
|
|
} else {
|
|
// text, number, textarea 등 input 계열 — 카테고리 잔류 속성 제거
|
|
merged.fieldType = dbInputType;
|
|
merged.inputType = dbInputType;
|
|
delete merged.source;
|
|
}
|
|
|
|
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;
|
|
// 중첩된 분할패널 내부 컴포넌트 선택 콜백 (탭 안의 분할패널)
|
|
onNestedPanelSelect?: (splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => void;
|
|
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
|
|
}) => {
|
|
// 컬럼 메타데이터 로드 트리거 (TTL 기반 자동 갱신)
|
|
const screenTableName = props.tableName || (component as any).tableName;
|
|
const [metaVersion, forceUpdate] = React.useState(0);
|
|
React.useEffect(() => {
|
|
if (screenTableName) {
|
|
loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1));
|
|
}
|
|
}, [screenTableName]);
|
|
|
|
// table-columns-refresh 이벤트 수신 시 캐시 무효화 후 최신 메타 다시 로드
|
|
React.useEffect(() => {
|
|
const handler = () => {
|
|
if (screenTableName) {
|
|
invalidateColumnMetaCache(screenTableName);
|
|
loadColumnMeta(screenTableName, true).then(() => forceUpdate((v) => v + 1));
|
|
}
|
|
};
|
|
window.addEventListener("table-columns-refresh", handler);
|
|
return () => window.removeEventListener("table-columns-refresh", handler);
|
|
}, [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 mappedComponentType = mapToV2ComponentType(rawComponentType);
|
|
|
|
// fieldType 기반 동적 컴포넌트 전환 (사용자 설정 > DB input_type > 기본값)
|
|
const componentType = (() => {
|
|
const configFieldType = (component as any).componentConfig?.fieldType;
|
|
const fieldName = (component as any).columnName || (component as any).componentConfig?.fieldKey || (component as any).componentConfig?.columnName;
|
|
const isEntityJoin = fieldName?.includes(".");
|
|
const baseCol = isEntityJoin ? undefined : fieldName;
|
|
const rawDbType = baseCol && screenTableName
|
|
? (columnMetaCache[screenTableName]?.[baseCol]?.input_type || columnMetaCache[screenTableName]?.[baseCol]?.inputType)
|
|
: undefined;
|
|
const dbInputType = rawDbType === "direct" || rawDbType === "auto" ? undefined : rawDbType;
|
|
|
|
// 디버그 (division, unit 필드만) - 문제 확인 후 제거
|
|
if (baseCol && (baseCol === "division" || baseCol === "unit")) {
|
|
const result = configFieldType
|
|
? (["text","number","password","textarea","slider","color","numbering"].includes(configFieldType) ? "v2-input" : "v2-select")
|
|
: dbInputType
|
|
? (["text","number","password","textarea","slider","color","numbering"].includes(dbInputType) ? "v2-input" : "v2-select")
|
|
: mappedComponentType;
|
|
const skipCat = dbInputType && !["category", "entity", "select"].includes(dbInputType);
|
|
console.log(`[DCR] ${baseCol}: dbInputType=${dbInputType}, RESULT=${result}, skipCat=${skipCat}`);
|
|
}
|
|
|
|
// 사용자가 V2FieldConfigPanel에서 명시적으로 설정한 fieldType 최우선
|
|
if (configFieldType) {
|
|
if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(configFieldType)) return "v2-input";
|
|
if (["select", "category", "entity"].includes(configFieldType)) return "v2-select";
|
|
}
|
|
|
|
// componentConfig.fieldType 없으면 DB input_type 참조 (초기 로드 시)
|
|
if (dbInputType) {
|
|
if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(dbInputType)) return "v2-input";
|
|
if (["select", "category", "entity"].includes(dbInputType)) return "v2-select";
|
|
}
|
|
|
|
return mappedComponentType;
|
|
})();
|
|
|
|
// 🆕 조건부 렌더링 체크 (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 확인)
|
|
// DB input_type이 "text" 등 비-카테고리로 변경된 경우 이 분기를 건너뜀
|
|
const savedInputType = (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;
|
|
|
|
// DB input_type 확인: 데이터타입관리에서 변경한 최신 값이 레이아웃 저장값보다 우선
|
|
const dbMetaForField = columnName && screenTableName && !columnName.includes(".")
|
|
? columnMetaCache[screenTableName]?.[columnName]
|
|
: undefined;
|
|
const dbFieldInputType = dbMetaForField
|
|
? (() => { const raw = dbMetaForField.input_type || dbMetaForField.inputType; return raw === "direct" || raw === "auto" ? undefined : raw; })()
|
|
: undefined;
|
|
// DB에서 확인된 타입이 있으면 그걸 사용, 없으면 저장된 값 사용
|
|
const inputType = dbFieldInputType || savedInputType;
|
|
// webType도 DB 값으로 대체 (레이아웃에 webType: "category" 하드코딩되어 있을 수 있음)
|
|
const effectiveWebType = dbFieldInputType || webType;
|
|
|
|
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;
|
|
|
|
// DB input_type이 비-카테고리(text 등)로 확인된 경우, 레이아웃에 category가 남아있어도 카테고리 분기 강제 스킵
|
|
// dbFieldInputType이 있으면(캐시 로드됨) 그 값으로 판단, 없으면 기존 로직 유지
|
|
const isDbConfirmedNonCategory = dbFieldInputType && !["category", "entity", "select"].includes(dbFieldInputType);
|
|
|
|
if (!isDbConfirmedNonCategory && (inputType === "category" || effectiveWebType === "category") && tableName && columnName && shouldUseV2Select) {
|
|
// V2SelectRenderer로 직접 렌더링 (카테고리 + 고급 모드)
|
|
try {
|
|
const { V2SelectRenderer } = require("@/lib/registry/components/v2-select/V2SelectRenderer");
|
|
const fieldName = columnName || component.id;
|
|
|
|
// 수평 라벨 감지
|
|
const catLabelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay;
|
|
const catLabelPosition = component.style?.labelPosition;
|
|
const catLabelText =
|
|
catLabelDisplay === true || catLabelDisplay === "true"
|
|
? component.style?.labelText || (component as any).label || component.componentConfig?.label
|
|
: undefined;
|
|
const catNeedsExternalHorizLabel = !!(
|
|
catLabelText &&
|
|
(catLabelPosition === "left" || catLabelPosition === "right")
|
|
);
|
|
|
|
const selectComponent = {
|
|
...component,
|
|
componentConfig: {
|
|
...component.componentConfig,
|
|
mode: componentMode || "dropdown",
|
|
source: "category",
|
|
categoryTable: tableName,
|
|
categoryColumn: columnName,
|
|
},
|
|
tableName,
|
|
columnName,
|
|
inputType: "category",
|
|
webType: "category",
|
|
};
|
|
|
|
const catStyle = catNeedsExternalHorizLabel
|
|
? {
|
|
...(component as any).style,
|
|
labelDisplay: false,
|
|
labelPosition: "top" as const,
|
|
width: "100%",
|
|
height: "100%",
|
|
borderWidth: undefined,
|
|
borderColor: undefined,
|
|
borderStyle: undefined,
|
|
border: undefined,
|
|
borderRadius: undefined,
|
|
}
|
|
: (component as any).style;
|
|
const catSize = catNeedsExternalHorizLabel
|
|
? { ...(component as any).size, width: undefined }
|
|
: (component as any).size;
|
|
|
|
const rendererProps = {
|
|
component: selectComponent,
|
|
formData: props.formData,
|
|
onFormDataChange: props.onFormDataChange,
|
|
isDesignMode: props.isDesignMode,
|
|
isInteractive: props.isInteractive ?? !props.isDesignMode,
|
|
tableName,
|
|
style: catStyle,
|
|
size: catSize,
|
|
};
|
|
|
|
const rendererInstance = new V2SelectRenderer(rendererProps);
|
|
const renderedCatSelect = rendererInstance.render();
|
|
|
|
if (catNeedsExternalHorizLabel) {
|
|
const labelGap = component.style?.labelGap || "8px";
|
|
const labelFontSize = component.style?.labelFontSize || "14px";
|
|
const labelColor = getAdaptiveLabelColor(component.style?.labelColor);
|
|
const labelFontWeight = component.style?.labelFontWeight || "500";
|
|
const isRequired =
|
|
component.required || (component as any).required || isColumnRequiredByMeta(tableName, columnName);
|
|
const isLeft = catLabelPosition === "left";
|
|
return (
|
|
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
|
<label
|
|
style={{
|
|
position: "absolute",
|
|
top: "50%",
|
|
transform: "translateY(-50%)",
|
|
...(isLeft ? { right: "100%", marginRight: labelGap } : { left: "100%", marginLeft: labelGap }),
|
|
fontSize: labelFontSize,
|
|
color: labelColor,
|
|
fontWeight: labelFontWeight,
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
className="text-sm font-medium"
|
|
>
|
|
{catLabelText}
|
|
{isRequired && <span className="ml-0.5 text-amber-500">*</span>}
|
|
</label>
|
|
<div style={{ width: "100%", height: "100%" }}>{renderedCatSelect}</div>
|
|
</div>
|
|
);
|
|
}
|
|
return renderedCatSelect;
|
|
} catch (error) {
|
|
console.error("❌ V2SelectRenderer 로드 실패:", error);
|
|
}
|
|
} else if (!isDbConfirmedNonCategory && (inputType === "category" || effectiveWebType === "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;
|
|
}
|
|
|
|
const isRuntimeMode = !props.isDesignMode;
|
|
const finalStyle: React.CSSProperties = {
|
|
...component.style,
|
|
width: isRuntimeMode ? "100%" : component.size?.width ? `${component.size.width}px` : component.style?.width,
|
|
height: isRuntimeMode
|
|
? "100%"
|
|
: 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 등의 라벨 표시 로직 (InteractiveScreenViewerDynamic과 동일한 부정형 체크)
|
|
const labelDisplay = component.style?.labelDisplay ?? (component as any).labelDisplay;
|
|
const effectiveLabel =
|
|
labelDisplay !== false && labelDisplay !== "false"
|
|
? component.style?.labelText || (component as any).label || component.componentConfig?.label
|
|
: undefined;
|
|
|
|
// 🔧 수평 라벨(left/right) 감지 → 외부 absolute 래퍼로 라벨 처리 (카테고리 셀렉트와 동일 방식)
|
|
const labelPosition = component.style?.labelPosition;
|
|
const isV2Component = componentType?.startsWith("v2-");
|
|
const needsExternalHorizLabel = !!(
|
|
isV2Component &&
|
|
effectiveLabel &&
|
|
(labelPosition === "left" || labelPosition === "right")
|
|
);
|
|
|
|
const mergedStyle = {
|
|
...component.style,
|
|
width: finalStyle.width,
|
|
height: finalStyle.height,
|
|
...(needsExternalHorizLabel
|
|
? {
|
|
labelDisplay: false,
|
|
labelPosition: "top" as const,
|
|
width: "100%",
|
|
borderWidth: undefined,
|
|
borderColor: undefined,
|
|
borderStyle: undefined,
|
|
border: undefined,
|
|
borderRadius: undefined,
|
|
}
|
|
: {}),
|
|
};
|
|
|
|
// 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선)
|
|
const isEntityJoinColumn = fieldName?.includes(".");
|
|
const baseColumnName = isEntityJoinColumn ? undefined : fieldName;
|
|
const rawMergedConfig = mergeColumnMeta(screenTableName, baseColumnName, component.componentConfig || {});
|
|
|
|
// fieldType이 설정된 경우, source/inputType 보조 속성 자동 보완
|
|
const mergedComponentConfig = (() => {
|
|
const ft = rawMergedConfig?.fieldType;
|
|
if (!ft) return rawMergedConfig;
|
|
const patch: Record<string, any> = {};
|
|
if (["select", "category", "entity"].includes(ft) && !rawMergedConfig.source) {
|
|
patch.source = ft === "category" ? "category" : ft === "entity" ? "entity" : "static";
|
|
}
|
|
if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(ft) && !rawMergedConfig.inputType) {
|
|
patch.inputType = ft;
|
|
}
|
|
return Object.keys(patch).length > 0 ? { ...rawMergedConfig, ...patch } : rawMergedConfig;
|
|
})();
|
|
|
|
// NOT NULL 기반 필수 여부를 component.required에 반영
|
|
const notNullRequired = isColumnRequiredByMeta(screenTableName, baseColumnName);
|
|
const effectiveRequired = component.required || notNullRequired;
|
|
|
|
// 엔티티 조인 컬럼은 런타임에서 readonly/disabled 강제 해제
|
|
const effectiveComponent = isEntityJoinColumn
|
|
? { ...component, componentConfig: mergedComponentConfig, readonly: false, required: effectiveRequired }
|
|
: { ...component, componentConfig: mergedComponentConfig, required: effectiveRequired };
|
|
|
|
const rendererProps = {
|
|
component: effectiveComponent,
|
|
isSelected,
|
|
onClick,
|
|
onDragStart,
|
|
onDragEnd,
|
|
config: mergedComponentConfig,
|
|
componentConfig: mergedComponentConfig,
|
|
// componentConfig spread를 먼저 → 이후 명시적 속성이 override
|
|
...(mergedComponentConfig || {}),
|
|
// size/position/style/label은 componentConfig spread 이후에 설정 (덮어쓰기 방지)
|
|
size: needsExternalHorizLabel
|
|
? { ...(component.size || newComponent.defaultSize), width: undefined }
|
|
: component.size || newComponent.defaultSize,
|
|
position: component.position,
|
|
style: mergedStyle,
|
|
label: needsExternalHorizLabel ? undefined : effectiveLabel,
|
|
// NOT NULL 메타데이터 포함된 필수 여부 (V2Hierarchy 등 직접 props.required 참조하는 컴포넌트용)
|
|
required: effectiveRequired,
|
|
// 🆕 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,
|
|
onNestedPanelSelect: props.onNestedPanelSelect,
|
|
};
|
|
|
|
// 렌더러가 클래스인지 함수인지 확인
|
|
const isClass =
|
|
typeof NewComponentRenderer === "function" &&
|
|
NewComponentRenderer.prototype &&
|
|
NewComponentRenderer.prototype.render;
|
|
|
|
let renderedElement: React.ReactElement;
|
|
if (isClass) {
|
|
const rendererInstance = new NewComponentRenderer(rendererProps);
|
|
renderedElement = rendererInstance.render();
|
|
} else {
|
|
renderedElement = <NewComponentRenderer key={refreshKey} {...rendererProps} />;
|
|
}
|
|
|
|
// 수평 라벨 → 라벨을 컴포넌트 영역 바깥에 absolute 배치, 입력은 100% 채움
|
|
if (needsExternalHorizLabel) {
|
|
const labelGap = component.style?.labelGap || "8px";
|
|
const labelFontSize = component.style?.labelFontSize || "14px";
|
|
const labelColor = getAdaptiveLabelColor(component.style?.labelColor);
|
|
const labelFontWeight = component.style?.labelFontWeight || "500";
|
|
const isRequired = effectiveComponent.required || isColumnRequiredByMeta(screenTableName, baseColumnName);
|
|
const isLeft = labelPosition === "left";
|
|
|
|
return (
|
|
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
|
<label
|
|
style={{
|
|
position: "absolute",
|
|
top: "50%",
|
|
transform: "translateY(-50%)",
|
|
...(isLeft ? { right: "100%", marginRight: labelGap } : { left: "100%", marginLeft: labelGap }),
|
|
fontSize: labelFontSize,
|
|
color: labelColor,
|
|
fontWeight: labelFontWeight,
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
className="text-sm font-medium"
|
|
>
|
|
{effectiveLabel}
|
|
{isRequired && <span className="ml-0.5 text-amber-500">*</span>}
|
|
</label>
|
|
<div style={{ width: "100%", height: "100%" }}>{renderedElement}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return renderedElement;
|
|
}
|
|
} 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="border-destructive/30 bg-destructive/10 flex h-full w-full items-center justify-center rounded border-2 p-4">
|
|
<div className="text-center">
|
|
<div className="text-destructive mb-2 text-sm font-medium">렌더링 오류</div>
|
|
<div className="text-destructive/80 text-xs">
|
|
{componentType}: {error instanceof Error ? error.message : "알 수 없는 오류"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
};
|
|
|
|
export default DynamicComponentRenderer;
|