ERP-node/frontend/lib/meta-components/migration/migrateTo3_0.ts

632 lines
17 KiB
TypeScript

/**
* V2 레이아웃 → V3 메타 컴포넌트 자동 변환 유틸리티
* - 기존 70개 컴포넌트를 7개 메타 컴포넌트로 통합 변환
* - 변환 불가 컴포넌트는 그대로 유지 (skipped)
*/
import type {
LayoutData,
MetaComponent,
FieldComponentConfig,
DataViewComponentConfig,
ActionComponentConfig,
LayoutComponentConfig,
DisplayComponentConfig,
SearchComponentConfig,
ModalComponentConfig,
ActionStep,
} from "../api/metaComponent";
// ============================================
// 인터페이스 정의
// ============================================
interface MigrationResult {
success: boolean;
convertedCount: number;
skippedCount: number;
skippedComponents: string[]; // 변환 불가 컴포넌트 ID 목록
}
interface V2Component {
id: string;
componentType: string;
componentConfig?: any;
position?: { x: number; y: number; width: number; height: number };
layerId?: number;
style?: any;
[key: string]: any;
}
interface V2LayoutData {
version: string;
screenId: number;
layerId: number;
components: V2Component[];
layers?: any[];
metadata?: any;
}
// ============================================
// 변환 불가 컴포넌트 목록 (별도 유지)
// ============================================
const SKIP_COMPONENTS = new Set([
// 특수 시각화 컴포넌트
"rack-structure",
"v2-rack-structure",
"v2-location-swap-selector",
"location-swap-selector",
"map",
"mail-recipient-selector",
// 복합 특수 컴포넌트 (별도 처리 필요)
"v2-bom-item-editor",
// category-manager는 Field로 변환 가능하므로 제거
"v2-category-manager",
"customer-item-mapping",
"tax-invoice-list",
"v2-process-work-standard",
"v2-item-routing",
]);
// ============================================
// 컴포넌트 타입 판별 함수
// ============================================
const FIELD_TYPES = new Set([
"text-input",
"v2-input",
"number-input",
"date-input",
"v2-date",
"select-basic",
"v2-select",
"checkbox-basic",
"radio-basic",
"textarea-basic",
"file-upload",
"v2-file-upload",
"entity-search-input",
"autocomplete-search-input",
"slider-basic",
"toggle-switch",
"numbering-rule",
"v2-numbering-rule",
"image-widget",
"category-manager", // 카테고리 선택기 추가
]);
const DATAVIEW_TYPES = new Set([
"table-list",
"v2-table-list",
"v2-table-grouped",
"v2-repeater",
"simple-repeater-table",
// modal-repeater-table은 MODAL_TYPES로 이동
"pivot-grid",
"v2-pivot-grid",
"v2-bom-tree",
"aggregation-widget",
"v2-aggregation-widget",
"v2-timeline-scheduler",
]);
const ACTION_TYPES = new Set([
"button-primary",
"v2-button-primary",
"flow-widget",
"related-data-buttons",
]);
const LAYOUT_TYPES = new Set([
"split-panel-layout",
"split-panel-layout2", // 추가
"v2-split-panel-layout",
"screen-split-panel",
"tabs",
"tabs-widget", // 추가
"v2-tabs-widget",
"section-card",
"v2-section-card",
"section-paper",
"v2-section-paper",
"conditional-container",
"accordion-basic",
"repeat-container",
"v2-repeat-container",
]);
const DISPLAY_TYPES = new Set([
"text-display",
"v2-text-display",
"divider-line",
"v2-divider-line",
"v2-split-line",
"badge",
"alert",
"stats-card",
"card-display", // DataView에서 이동
"v2-card-display",
"chart",
"image-display",
"progress-bar",
"v2-media",
]);
const SEARCH_TYPES = new Set([
"table-search-widget",
"v2-table-search-widget",
"search-panel",
]);
const MODAL_TYPES = new Set([
"modal-repeater-table",
"universal-form-modal",
"repeat-screen-modal",
"v2-modal",
]);
// ============================================
// 변환 함수들
// ============================================
/**
* Field로 변환
*/
function convertToField(v2Comp: V2Component): MetaComponent {
const config = v2Comp.componentConfig || {};
const properties = v2Comp.properties || {};
// widgetType → webType 매핑 (V2 properties에서 추출)
const widgetTypeToWebType: Record<string, string> = {
text: "text",
direct: "text",
entity: "entity",
category: "category",
date: "date",
number: "number",
image: "file",
file: "file",
button: "text",
checkbox: "checkbox",
radio: "radio",
toggle: "toggle",
textarea: "textarea",
};
// webType 결정 (우선순위: properties.widgetType → config.widgetType → componentType에서 추론)
let webType = "text";
const propsWidgetType = properties.widgetType || config.widgetType;
if (propsWidgetType && widgetTypeToWebType[propsWidgetType]) {
webType = widgetTypeToWebType[propsWidgetType];
} else {
// componentType에서 추론
switch (v2Comp.componentType) {
case "number-input":
webType = "number";
break;
case "date-input":
case "v2-date":
webType = config.includeTime ? "datetime" : "date";
break;
case "select-basic":
case "v2-select":
webType = "select";
break;
case "checkbox-basic":
webType = "checkbox";
break;
case "radio-basic":
webType = "radio";
break;
case "textarea-basic":
webType = "textarea";
break;
case "file-upload":
case "v2-file-upload":
webType = "file";
break;
case "entity-search-input":
case "autocomplete-search-input":
webType = "entity";
break;
case "slider-basic":
webType = "slider";
break;
case "toggle-switch":
webType = "toggle";
break;
case "numbering-rule":
case "v2-numbering-rule":
webType = "numbering";
break;
case "image-widget":
webType = "file";
break;
case "category-manager":
webType = "category";
break;
case "v2-input":
webType = config.inputType || "text";
break;
}
}
// columnName: 최상위 속성 → config 속성 순서로 탐색
const columnName = v2Comp.columnName || config.columnName || config.dataKey || "";
const fieldConfig: FieldComponentConfig = {
webType,
label: config.label || config.fieldLabel || v2Comp.label || "필드",
binding: columnName,
tableName: config.tableName || v2Comp.tableName,
placeholder: config.placeholder,
required: config.required ?? v2Comp.required ?? false,
defaultValue: config.defaultValue,
validation: config.validation,
join: config.joinConfig || config.join || v2Comp.joinConfig,
options: config.options,
};
// V2 확장 속성 추가 (타입 안전성을 위해 as any로 확장)
const extendedConfig: any = {
...fieldConfig,
autoFill: v2Comp.autoFill || config.autoFill || properties.autoFill,
fileConfig: v2Comp.fileConfig || config.fileConfig,
categoryGroupCode: config.categoryGroupCode || v2Comp.categoryGroupCode || properties.categoryGroupCode,
_originalConfig: config,
_originalType: v2Comp.componentType,
};
return {
id: v2Comp.id,
type: "meta-field",
position: v2Comp.position,
config: extendedConfig,
};
}
/**
* DataView로 변환
*/
function convertToDataView(v2Comp: V2Component): MetaComponent {
const config = v2Comp.componentConfig || {};
// viewMode 결정
let viewMode: "table" | "card" | "list" | "tree" = "table";
if (v2Comp.componentType.includes("card")) {
viewMode = "card";
} else if (v2Comp.componentType.includes("tree")) {
viewMode = "tree";
}
const dataViewConfig: DataViewComponentConfig = {
viewMode,
tableName: config.tableName || config.dataSource || v2Comp.tableName || "",
columns: config.columns || config.visibleColumns || [],
defaultSort: config.defaultSort,
pageSize: config.pageSize || 20,
actions: {
create: config.allowCreate ?? true,
read: config.allowRead ?? true,
update: config.allowUpdate ?? true,
delete: config.allowDelete ?? true,
},
};
return {
id: v2Comp.id,
type: "meta-dataview",
position: v2Comp.position,
config: {
...dataViewConfig,
_originalConfig: config,
_originalType: v2Comp.componentType,
} as any,
};
}
/**
* Action으로 변환
*/
function convertToAction(v2Comp: V2Component): MetaComponent {
const config = v2Comp.componentConfig || {};
const steps: ActionStep[] = [];
// 기존 actionType에 따라 steps 구성
if (config.actionType === "save" || v2Comp.componentType.includes("save")) {
steps.push({
type: "save",
target: config.targetTable || config.tableName || "",
});
}
if (config.actionType === "delete") {
steps.push({
type: "delete",
target: config.targetTable || config.tableName || "",
});
}
// flow-widget인 경우
if (v2Comp.componentType === "flow-widget" && config.flowDefinitionId) {
steps.push({
type: "api",
method: "POST",
endpoint: `/api/flow/move/${config.flowDefinitionId}`,
body: { stepId: config.targetStepId },
});
}
// related-data-buttons인 경우
if (v2Comp.componentType === "related-data-buttons" && config.targetScreenId) {
steps.push({
type: "navigate",
screenId: config.targetScreenId,
});
}
// 기본 저장 액션이 없으면 추가
if (steps.length === 0) {
steps.push({
type: "save",
target: config.tableName || "",
});
}
const actionConfig: ActionComponentConfig = {
label: config.label || config.buttonLabel || "버튼",
buttonType: config.variant || "primary",
icon: config.icon,
steps,
confirmDialog: config.confirmDialog,
};
return {
id: v2Comp.id,
type: "meta-action",
position: v2Comp.position,
config: {
...actionConfig,
_originalConfig: config,
_originalType: v2Comp.componentType,
} as any,
};
}
/**
* Layout으로 변환
*/
function convertToLayout(v2Comp: V2Component): MetaComponent {
const config = v2Comp.componentConfig || {};
// mode 결정
let mode: "columns" | "rows" | "tabs" | "accordion" | "card" = "columns";
if (v2Comp.componentType.includes("tab")) {
mode = "tabs";
} else if (v2Comp.componentType.includes("accordion")) {
mode = "accordion";
} else if (v2Comp.componentType.includes("card") || v2Comp.componentType.includes("paper")) {
mode = "card";
} else if (v2Comp.componentType.includes("split-panel")) {
mode = "columns";
}
const layoutConfig: LayoutComponentConfig = {
mode,
areas: config.areas || config.panels,
tabs: config.tabs, // tabs-widget의 탭 정보 보존
gap: config.gap,
padding: config.padding,
bordered: config.bordered,
title: config.title || config.sectionTitle,
};
return {
id: v2Comp.id,
type: "meta-layout",
position: v2Comp.position,
config: {
...layoutConfig,
children: config.children, // 자식 컴포넌트 ID 배열
_originalConfig: config,
_originalType: v2Comp.componentType,
} as any,
};
}
/**
* Display로 변환
*/
function convertToDisplay(v2Comp: V2Component): MetaComponent {
const config = v2Comp.componentConfig || {};
// displayType 결정
let displayType: "text" | "heading" | "divider" | "badge" | "alert" | "stat" | "spacer" | "progress" = "text";
if (v2Comp.componentType.includes("divider") || v2Comp.componentType.includes("split-line")) {
displayType = "divider";
} else if (v2Comp.componentType === "badge") {
displayType = "badge";
} else if (v2Comp.componentType === "alert") {
displayType = "alert";
} else if (v2Comp.componentType.includes("stats") || v2Comp.componentType.includes("card-display")) {
displayType = "stat";
} else if (v2Comp.componentType.includes("progress")) {
displayType = "progress";
}
const displayConfig: DisplayComponentConfig = {
displayType,
text: config.text
? {
content: config.text,
size: config.size,
weight: config.weight,
align: config.align,
}
: undefined,
dataBinding: config.dataBinding || config.binding,
};
return {
id: v2Comp.id,
type: "meta-display",
position: v2Comp.position,
config: {
...displayConfig,
_originalConfig: config,
_originalType: v2Comp.componentType,
} as any,
};
}
/**
* Search로 변환 (table-search-widget 등)
*/
function convertToSearch(v2Comp: V2Component): MetaComponent {
const config = v2Comp.componentConfig || {};
// V2 검색 위젯에서 필드 추출
const searchFields = (config.searchFields || config.fields || []).map((f: any) => ({
columnName: f.columnName || f.field || f.key,
label: f.label || f.title || f.columnName,
searchType: f.type === "select" ? "select" : f.type === "date" ? "date_range" : "text",
options: f.options,
}));
return {
id: v2Comp.id,
type: "meta-search",
position: v2Comp.position,
config: {
targetDataView: config.targetTableId || config.linkedTableId || "",
mode: "simple",
fields: searchFields,
_originalConfig: config,
_originalType: v2Comp.componentType,
} as any, // SearchComponentConfig + 확장 속성
};
}
/**
* Modal로 변환 (modal-repeater-table, universal-form-modal 등)
*/
function convertToModal(v2Comp: V2Component): MetaComponent {
const config = v2Comp.componentConfig || {};
return {
id: v2Comp.id,
type: "meta-modal",
position: v2Comp.position,
config: {
trigger: "button",
triggerLabel: config.triggerButton || config.buttonLabel || "모달 열기",
content: {
type: config.screenId ? "screen" : "form",
formConfig: config.tableName ? {
tableName: config.tableName,
mode: "edit",
columns: config.columns || [],
layout: "single",
} : undefined,
screenId: config.screenId,
},
size: config.size || "md",
_originalConfig: config,
_originalType: v2Comp.componentType,
} as any, // ModalComponentConfig + 확장 속성
};
}
// ============================================
// 메인 마이그레이션 함수
// ============================================
/**
* V2 레이아웃을 V3 메타 컴포넌트 형태로 변환
* @param layoutData V2 레이아웃 데이터
* @returns 변환된 V3 레이아웃 + 변환 결과
*/
export function migrateTo3_0(layoutData: V2LayoutData): {
newLayoutData: LayoutData;
result: MigrationResult;
} {
const convertedComponents: MetaComponent[] = [];
const skippedComponents: string[] = [];
let convertedCount = 0;
let skippedCount = 0;
for (const comp of layoutData.components) {
const compType = comp.componentType;
// 변환 불가 컴포넌트는 그대로 유지
if (SKIP_COMPONENTS.has(compType)) {
convertedComponents.push(comp as any); // 그대로 추가 (V2 그대로)
skippedComponents.push(comp.id);
skippedCount++;
continue;
}
try {
// 컴포넌트 타입에 따라 변환
if (FIELD_TYPES.has(compType)) {
convertedComponents.push(convertToField(comp));
convertedCount++;
} else if (DATAVIEW_TYPES.has(compType)) {
convertedComponents.push(convertToDataView(comp));
convertedCount++;
} else if (ACTION_TYPES.has(compType)) {
convertedComponents.push(convertToAction(comp));
convertedCount++;
} else if (LAYOUT_TYPES.has(compType)) {
convertedComponents.push(convertToLayout(comp));
convertedCount++;
} else if (DISPLAY_TYPES.has(compType)) {
convertedComponents.push(convertToDisplay(comp));
convertedCount++;
} else if (SEARCH_TYPES.has(compType)) {
convertedComponents.push(convertToSearch(comp));
convertedCount++;
} else if (MODAL_TYPES.has(compType)) {
convertedComponents.push(convertToModal(comp));
convertedCount++;
} else {
// 알 수 없는 컴포넌트는 그대로 유지
convertedComponents.push(comp as any);
skippedComponents.push(comp.id);
skippedCount++;
}
} catch (error) {
// 변환 실패 시 원본 유지
console.error(`컴포넌트 변환 실패 (${comp.id}):`, error);
convertedComponents.push(comp as any);
skippedComponents.push(comp.id);
skippedCount++;
}
}
const newLayoutData: LayoutData = {
version: "3.0",
screenId: layoutData.screenId,
layerId: layoutData.layerId,
components: convertedComponents,
layers: layoutData.layers,
metadata: {
...layoutData.metadata,
lastModified: new Date().toISOString(),
description: `V2에서 V3로 자동 마이그레이션됨 (${convertedCount}개 변환, ${skippedCount}개 유지)`,
},
};
const result: MigrationResult = {
success: true,
convertedCount,
skippedCount,
skippedComponents,
};
return { newLayoutData, result };
}