ERP-node/frontend/lib/meta-components/transform/metaToV2.ts

596 lines
19 KiB
TypeScript

/**
* V3 메타 컴포넌트 → V2 ComponentData 변환기
*
* V3 메타 컴포넌트는 "설정 추상화 레이어"이며, 실제 렌더링은
* 기존 V2 DynamicComponentRenderer에 위임한다.
* 이 변환기가 메타 설정을 V2가 이해하는 ComponentData로 변환한다.
*/
import type {
FieldComponentConfig,
DataViewComponentConfig,
ActionComponentConfig,
LayoutComponentConfig,
DisplayComponentConfig,
SearchComponentConfig,
ModalComponentConfig,
} from "../api/metaComponent";
interface MetaComponentInput {
id: string;
type: string;
config: any;
position?: { x: number; y: number; width: number; height: number };
}
interface V2ComponentOutput {
id: string;
type: "component" | "widget";
componentType: string;
componentConfig: Record<string, any>;
position: { x: number; y: number; width: number; height: number };
size?: { width: number; height: number };
label?: string;
columnName?: string;
widgetType?: string;
style?: Record<string, any>;
[key: string]: any;
}
// ========================================
// webType → V2 componentType 매핑
// ========================================
const FIELD_WEBTYPE_TO_V2: Record<string, { componentType: string; extraConfig?: Record<string, any> }> = {
text: { componentType: "v2-input", extraConfig: { inputType: "text" } },
number: { componentType: "v2-input", extraConfig: { inputType: "number" } },
email: { componentType: "v2-input", extraConfig: { inputType: "email" } },
tel: { componentType: "v2-input", extraConfig: { inputType: "tel" } },
url: { componentType: "v2-input", extraConfig: { inputType: "url" } },
password: { componentType: "v2-input", extraConfig: { inputType: "password" } },
textarea: { componentType: "v2-input", extraConfig: { inputType: "textarea" } },
date: { componentType: "v2-date", extraConfig: { includeTime: false } },
datetime: { componentType: "v2-date", extraConfig: { includeTime: true } },
select: { componentType: "v2-select" },
entity: { componentType: "entity-search-input" },
checkbox: { componentType: "v2-select", extraConfig: { mode: "checkbox" } },
radio: { componentType: "v2-select", extraConfig: { mode: "radio" } },
toggle: { componentType: "v2-select", extraConfig: { mode: "toggle" } },
file: { componentType: "v2-file-upload" },
slider: { componentType: "v2-input", extraConfig: { inputType: "slider" } },
numbering: { componentType: "v2-numbering-rule" },
code: { componentType: "v2-input", extraConfig: { inputType: "text" } },
color: { componentType: "v2-input", extraConfig: { inputType: "color" } },
};
// ========================================
// 변환 함수들
// ========================================
function transformField(meta: MetaComponentInput): V2ComponentOutput {
const config = meta.config as FieldComponentConfig;
const webType = config.webType || "text";
const mapping = FIELD_WEBTYPE_TO_V2[webType] || FIELD_WEBTYPE_TO_V2["text"];
// 원본 V2 config가 보존되어 있으면 그것을 기반으로 사용 (정보 손실 방지)
const originalConfig = (config as any)._originalConfig;
const originalType = (config as any)._originalType;
// 원본이 있으면 원본 컴포넌트 타입을 그대로 사용
const resolvedComponentType = originalType || mapping.componentType;
const v2Config: Record<string, any> = {
...(originalConfig || {}),
...(mapping.extraConfig || {}),
label: config.label,
placeholder: config.placeholder,
required: config.required,
readonly: config.readonly,
disabled: config.disabled,
defaultValue: config.defaultValue,
columnName: config.binding,
};
// 엔티티 조인 설정
if (config.join && webType === "entity") {
v2Config.tableName = config.join.targetTable;
v2Config.joinColumn = config.join.targetColumn;
v2Config.displayColumn = config.join.displayColumn;
v2Config.additionalColumns = config.join.additionalColumns;
v2Config.searchable = config.join.searchable ?? true;
if (config.join.filterBy) {
v2Config.filterBy = config.join.filterBy;
}
}
// 셀렉트 옵션 설정
if (config.options) {
v2Config.source = config.options.source;
if (config.options.source === "code_table") {
v2Config.codeCategory = config.options.codeCategory;
} else if (config.options.source === "static") {
v2Config.staticOptions = config.options.staticList;
} else if (config.options.source === "api") {
v2Config.apiEndpoint = config.options.apiEndpoint;
}
}
// 검증 규칙
if (config.validation) {
v2Config.validation = config.validation;
}
// 테이블명 (화면 레벨 테이블)
if (config.tableName) {
v2Config.tableName = v2Config.tableName || config.tableName;
}
const position = meta.position || { x: 0, y: 0, width: 400, height: 40 };
return {
id: meta.id,
type: "component",
componentType: resolvedComponentType,
componentConfig: v2Config,
position,
size: { width: position.width, height: position.height },
label: config.label || config.binding || "필드",
columnName: config.binding,
widgetType: webType,
style: {
labelDisplay: !!config.label,
labelText: config.label,
labelPosition: config.display?.labelPosition || "top",
},
};
}
function transformDataView(meta: MetaComponentInput): V2ComponentOutput {
const config = meta.config as DataViewComponentConfig;
const viewMode = config.viewMode || "table";
const originalConfig = (config as any)._originalConfig;
const originalType = (config as any)._originalType;
const componentTypeMap: Record<string, string> = {
table: "v2-table-list",
card: "v2-card-display",
tree: "v2-bom-tree",
pivot: "v2-pivot-grid",
timeline: "v2-timeline-scheduler",
list: "v2-table-list",
kanban: "v2-table-list",
calendar: "v2-table-list",
};
const resolvedComponentType = originalType || componentTypeMap[viewMode] || "v2-table-list";
const v2Config: Record<string, any> = {
...(originalConfig || {}),
tableName: config.tableName,
columns: config.columns,
defaultSort: config.defaultSort,
pageSize: config.pageSize || 20,
};
// CRUD 액션 설정
if (config.actions) {
v2Config.allowCreate = config.actions.create;
v2Config.allowRead = config.actions.read;
v2Config.allowUpdate = config.actions.update;
v2Config.allowDelete = config.actions.delete;
}
// 테이블 뷰 상세 설정
if (config.tableConfig) {
v2Config.showRowNumber = config.tableConfig.showRowNumber;
v2Config.showCheckbox = config.tableConfig.showCheckbox;
v2Config.stickyHeader = config.tableConfig.stickyHeader;
v2Config.groupBy = config.tableConfig.groupBy;
v2Config.summaryColumns = config.tableConfig.summaryColumns;
v2Config.editableColumns = config.tableConfig.editableColumns;
v2Config.frozenColumns = config.tableConfig.frozenColumns;
}
// 카드 뷰 설정
if (config.cardConfig) {
v2Config.titleColumn = config.cardConfig.titleColumn;
v2Config.descriptionColumn = config.cardConfig.descriptionColumn;
v2Config.imageColumn = config.cardConfig.imageColumn;
v2Config.columnsPerRow = config.cardConfig.columnsPerRow;
v2Config.cardStyle = config.cardConfig.cardStyle;
}
// 검색 설정
if (config.searchable) {
v2Config.searchable = true;
v2Config.filterColumns = config.filterColumns;
}
// 마스터-디테일 연결
if (config.dataSource?.masterField) {
v2Config.masterField = config.dataSource.masterField;
v2Config.detailForeignKey = config.dataSource.detailForeignKey;
}
const position = meta.position || { x: 0, y: 0, width: 800, height: 400 };
return {
id: meta.id,
type: "component",
componentType: resolvedComponentType,
componentConfig: v2Config,
position,
size: { width: position.width, height: position.height },
label: config.tableName || "데이터 뷰",
};
}
function transformAction(meta: MetaComponentInput): V2ComponentOutput {
const config = meta.config as ActionComponentConfig;
const originalConfig = (config as any)._originalConfig;
const originalType = (config as any)._originalType;
const v2Config: Record<string, any> = {
...(originalConfig || {}),
label: config.label || "버튼",
variant: config.buttonType || "default",
icon: config.icon,
};
// 액션 스텝들을 V2 버튼 설정으로 변환
if (config.steps && config.steps.length > 0) {
const firstStep = config.steps[0];
switch (firstStep.type) {
case "save":
v2Config.actionType = "save";
v2Config.targetTable = firstStep.target;
break;
case "delete":
v2Config.actionType = "delete";
v2Config.targetTable = firstStep.target;
break;
case "refresh":
v2Config.actionType = "refresh";
break;
case "navigate":
v2Config.actionType = "navigate";
v2Config.targetScreenId = (firstStep as any).screenId;
break;
case "openModal":
v2Config.actionType = "modal";
v2Config.modalId = (firstStep as any).modalId;
break;
case "api":
v2Config.actionType = "api";
v2Config.method = (firstStep as any).method;
v2Config.endpoint = (firstStep as any).endpoint;
v2Config.body = (firstStep as any).body;
break;
case "flowMove":
v2Config.actionType = "flow";
v2Config.flowDefinitionId = (firstStep as any).flowId;
v2Config.targetStepId = (firstStep as any).stepId;
break;
case "export":
v2Config.actionType = "export";
v2Config.exportFormat = (firstStep as any).format;
break;
}
// 멀티 스텝인 경우 전체 스텝도 보존
if (config.steps.length > 1) {
v2Config.actionSteps = config.steps;
}
}
// 실행 조건
if (config.enableCondition) {
v2Config.enableCondition = config.enableCondition;
}
// 확인 다이얼로그
if (config.confirmDialog) {
v2Config.confirmDialog = config.confirmDialog;
}
const position = meta.position || { x: 0, y: 0, width: 120, height: 40 };
return {
id: meta.id,
type: "component",
componentType: originalType || "v2-button-primary",
componentConfig: v2Config,
position,
size: { width: position.width, height: position.height },
label: config.label || "버튼",
};
}
function transformLayout(meta: MetaComponentInput): V2ComponentOutput {
const config = meta.config as LayoutComponentConfig;
const mode = config.mode || "columns";
const originalConfig = (config as any)._originalConfig;
const originalType = (config as any)._originalType;
const componentTypeMap: Record<string, string> = {
columns: "v2-split-panel-layout",
rows: "v2-repeat-container",
tabs: "v2-tabs-widget",
accordion: "v2-section-card",
card: "v2-section-card",
conditional: "conditional-container",
};
const resolvedComponentType = originalType || componentTypeMap[mode] || "v2-section-card";
const v2Config: Record<string, any> = {
...(originalConfig || {}),
gap: config.gap,
padding: config.padding,
bordered: config.bordered,
title: config.title,
};
// 영역 설정
if (config.areas) {
v2Config.areas = config.areas;
v2Config.panels = config.areas;
}
// 탭 설정
if (config.tabs) {
v2Config.tabs = config.tabs;
}
// 조건부 설정
if (config.conditions) {
v2Config.conditions = config.conditions;
}
const position = meta.position || { x: 0, y: 0, width: 800, height: 600 };
return {
id: meta.id,
type: "component",
componentType: resolvedComponentType,
componentConfig: v2Config,
position,
size: { width: position.width, height: position.height },
label: config.title || `레이아웃 (${mode})`,
};
}
function transformDisplay(meta: MetaComponentInput): V2ComponentOutput {
const config = meta.config as DisplayComponentConfig;
const displayType = config.displayType || "text";
const originalConfig = (config as any)._originalConfig;
const originalType = (config as any)._originalType;
const componentTypeMap: Record<string, string> = {
text: "v2-text-display",
heading: "v2-text-display",
divider: "v2-divider-line",
badge: "v2-text-display",
alert: "v2-text-display",
stat: "v2-text-display",
chart: "v2-text-display",
image: "v2-media",
progress: "v2-text-display",
spacer: "v2-divider-line",
html: "v2-text-display",
};
// 원본이 있으면 원본 컴포넌트 타입을 그대로 사용
const resolvedComponentType = originalType || componentTypeMap[displayType] || "v2-text-display";
const v2Config: Record<string, any> = {
...(originalConfig || {}),
displayType,
};
if (config.text) {
v2Config.text = config.text.content;
v2Config.size = config.text.size;
v2Config.weight = config.text.weight;
v2Config.align = config.text.align;
v2Config.color = config.text.color;
}
if (config.dataBinding) {
v2Config.dataBinding = config.dataBinding;
}
const position = meta.position || { x: 0, y: 0, width: 400, height: 40 };
return {
id: meta.id,
type: "component",
componentType: resolvedComponentType,
componentConfig: v2Config,
position,
size: { width: position.width, height: position.height },
label: config.text?.content || `표시 (${displayType})`,
};
}
function transformSearch(meta: MetaComponentInput): V2ComponentOutput {
const config = meta.config as SearchComponentConfig;
const originalConfig = (config as any)._originalConfig;
const originalType = (config as any)._originalType;
// 원본이 있으면 원본 컴포넌트 타입을 그대로 사용
const resolvedComponentType = originalType || "v2-table-search-widget";
const v2Config: Record<string, any> = {
...(originalConfig || {}),
mode: config.mode || "simple",
targetDataView: config.targetDataView,
fields: config.fields,
quickFilters: config.quickFilters,
};
const position = meta.position || { x: 0, y: 0, width: 800, height: 60 };
return {
id: meta.id,
type: "component",
componentType: resolvedComponentType,
componentConfig: v2Config,
position,
size: { width: position.width, height: position.height },
label: "검색",
};
}
function transformModal(meta: MetaComponentInput): V2ComponentOutput {
const config = meta.config as ModalComponentConfig;
const originalConfig = (config as any)._originalConfig;
const originalType = (config as any)._originalType;
// 원본이 있으면 원본 컴포넌트 타입을 그대로 사용
const resolvedComponentType = originalType || "v2-button-primary";
const v2Config: Record<string, any> = {
...(originalConfig || {}),
trigger: config.trigger || "button",
size: config.size || "md",
triggerLabel: config.triggerLabel,
};
if (config.content) {
v2Config.contentType = config.content.type;
if (config.content.formConfig) {
v2Config.formConfig = config.content.formConfig;
}
if (config.content.screenId) {
v2Config.screenId = config.content.screenId;
}
if (config.content.passData) {
v2Config.passData = config.content.passData;
}
}
if (config.onClose) {
v2Config.onCloseActions = config.onClose;
}
const position = meta.position || { x: 0, y: 0, width: 120, height: 40 };
return {
id: meta.id,
type: "component",
componentType: resolvedComponentType,
componentConfig: {
...v2Config,
actionType: "modal",
label: config.triggerLabel || "모달 열기",
variant: "outline",
},
position,
size: { width: position.width, height: position.height },
label: config.triggerLabel || "모달",
};
}
// ========================================
// 메인 변환 함수
// ========================================
/**
* V3 메타 컴포넌트를 V2 ComponentData로 변환
* 이 결과물을 DynamicComponentRenderer에 전달하면 기존 V2 파이프라인으로 렌더링된다.
*/
export function metaToV2(meta: MetaComponentInput): V2ComponentOutput {
switch (meta.type) {
case "meta-field":
return transformField(meta);
case "meta-dataview":
return transformDataView(meta);
case "meta-action":
return transformAction(meta);
case "meta-layout":
return transformLayout(meta);
case "meta-display":
return transformDisplay(meta);
case "meta-search":
return transformSearch(meta);
case "meta-modal":
return transformModal(meta);
default:
return {
id: meta.id,
type: "component",
componentType: "v2-text-display",
componentConfig: { text: `알 수 없는 메타 타입: ${meta.type}` },
position: meta.position || { x: 0, y: 0, width: 200, height: 40 },
label: `Unknown: ${meta.type}`,
};
}
}
/**
* V3 메타 컴포넌트를 V2로 변환하되, 원본 V2 컴포넌트의 속성을 보존
* (position, size, style, layerId 등 레이아웃 관련 속성 유지)
*/
export function metaToV2WithOriginal(
meta: MetaComponentInput,
originalV2?: Record<string, any>
): V2ComponentOutput {
const transformed = metaToV2(meta);
if (!originalV2) return transformed;
return {
...originalV2,
...transformed,
// 원본의 레이아웃 속성 보존
position: meta.position || originalV2.position || transformed.position,
size: originalV2.size || transformed.size,
style: originalV2.style || transformed.style,
layerId: originalV2.layerId,
gridColumnSpan: originalV2.gridColumnSpan,
gridColumnStart: originalV2.gridColumnStart,
gridRowIndex: originalV2.gridRowIndex,
zoneId: originalV2.zoneId,
parentId: originalV2.parentId,
className: originalV2.className,
responsiveConfig: originalV2.responsiveConfig,
conditional: originalV2.conditional,
autoFill: originalV2.autoFill,
};
}
/**
* 레이아웃의 모든 메타 컴포넌트를 V2로 일괄 변환
*/
export function transformAllMetaToV2(
components: any[],
originalComponents?: any[]
): any[] {
return components.map((comp) => {
const compType = comp.componentType || comp.type;
if (typeof compType === "string" && compType.startsWith("meta-")) {
const metaInput: MetaComponentInput = {
id: comp.id,
type: compType,
config: comp.componentConfig || comp.config || {},
position: comp.position,
};
const original = originalComponents?.find((o: any) => o.id === comp.id);
return metaToV2WithOriginal(metaInput, original);
}
return comp;
});
}
/**
* 컴포넌트가 메타 컴포넌트인지 확인
*/
export function isMetaComponent(component: any): boolean {
const type = component?.componentType || component?.type;
return typeof type === "string" && type.startsWith("meta-");
}