/** * 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 = { 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 }; }