/** * V2/V2 컴포넌트 설정 스키마 및 병합 유틸리티 * * V2 컴포넌트와 V2 컴포넌트의 overrides 스키마 및 기본값을 관리 * - 저장: component_url + overrides (차이값만) * - 로드: 코드 기본값 + overrides 병합 (Zod) */ import { z } from "zod"; // ============================================ // 공통 스키마 (모든 구조 허용) // ============================================ export const customConfigSchema = z.record(z.string(), z.any()); export type CustomConfig = z.infer; // ============================================ // 깊은 병합 함수 // ============================================ export function deepMerge>(target: T, source: Record): T { const result = { ...target }; for (const key of Object.keys(source)) { const sourceValue = source[key]; const targetValue = result[key as keyof T]; // 둘 다 객체이고 배열이 아니면 깊은 병합 if (isPlainObject(sourceValue) && isPlainObject(targetValue)) { result[key as keyof T] = deepMerge(targetValue, sourceValue); } else if (sourceValue !== undefined) { // source 값이 있으면 덮어쓰기 result[key as keyof T] = sourceValue; } } return result; } function isPlainObject(value: unknown): value is Record { return ( typeof value === "object" && value !== null && !Array.isArray(value) && Object.prototype.toString.call(value) === "[object Object]" ); } // ============================================ // 설정 병합 함수 (렌더링 시 사용) // ============================================ export function mergeComponentConfig( defaultConfig: Record, customConfig: Record | null | undefined, ): Record { if (!customConfig || Object.keys(customConfig).length === 0) { return { ...defaultConfig }; } return deepMerge(defaultConfig, customConfig); } // ============================================ // 커스텀 설정 추출 함수 (저장 시 사용) // ============================================ export function extractCustomConfig( fullConfig: Record, defaultConfig: Record, ): Record { const customConfig: Record = {}; for (const key of Object.keys(fullConfig)) { const fullValue = fullConfig[key]; const defaultValue = defaultConfig[key]; // 기본값과 다른 경우만 커스텀으로 추출 if (!isDeepEqual(fullValue, defaultValue)) { customConfig[key] = fullValue; } } return customConfig; } // ============================================ // 깊은 비교 함수 // ============================================ export function isDeepEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (a == null || b == null) return a === b; if (typeof a !== typeof b) return false; if (typeof a !== "object") return a === b; if (Array.isArray(a) !== Array.isArray(b)) return false; if (Array.isArray(a) && Array.isArray(b)) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (!isDeepEqual(a[i], b[i])) return false; } return true; } const objA = a as Record; const objB = b as Record; const keysA = Object.keys(objA); const keysB = Object.keys(objB); if (keysA.length !== keysB.length) return false; for (const key of keysA) { if (!keysB.includes(key)) return false; if (!isDeepEqual(objA[key], objB[key])) return false; } return true; } // ============================================ // 컴포넌트 URL 생성 함수 // ============================================ export function getComponentUrl(componentType: string): string { return `@/lib/registry/components/${componentType}`; } // ============================================ // 컴포넌트 타입 추출 함수 (URL에서) // ============================================ export function getComponentTypeFromUrl(componentUrl: string): string { // "@/lib/registry/components/v2-table-list" → "v2-table-list" const parts = componentUrl.split("/"); return parts[parts.length - 1]; } // ============================================ // V2 레이아웃 스키마 // ============================================ export const componentV2Schema = z.object({ id: z.string(), url: z.string(), position: z.object({ x: z.number().default(0), y: z.number().default(0), }), size: z.object({ width: z.number().default(100), height: z.number().default(100), }), displayOrder: z.number().default(0), overrides: z.record(z.string(), z.any()).default({}), }); // ============================================ // 레이어 스키마 정의 // ============================================ export const layerTypeSchema = z.enum(["base", "conditional", "modal", "drawer"]); // 조건부 레이어 표시 영역 스키마 export const displayRegionSchema = z.object({ x: z.number().default(0), y: z.number().default(0), width: z.number().default(800), height: z.number().default(200), }); export const layerSchema = z.object({ id: z.string(), name: z.string(), type: layerTypeSchema, zIndex: z.number().default(0), isVisible: z.boolean().default(true), // 초기 표시 여부 isLocked: z.boolean().default(false), // 편집 잠금 여부 // 조건부 표시 로직 condition: z .object({ targetComponentId: z.string(), operator: z.enum(["eq", "neq", "in"]), value: z.any(), }) .optional(), // 조건부 레이어 표시 영역 (조건 미충족 시 사라짐) displayRegion: displayRegionSchema.optional(), // 모달/드로어 전용 설정 overlayConfig: z .object({ backdrop: z.boolean().default(true), closeOnBackdropClick: z.boolean().default(true), width: z.union([z.string(), z.number()]).optional(), height: z.union([z.string(), z.number()]).optional(), // 모달/드로어 스타일링 backgroundColor: z.string().optional(), backdropBlur: z.number().optional(), // 드로어 전용 position: z.enum(["left", "right", "top", "bottom"]).optional(), }) .optional(), // 해당 레이어에 속한 컴포넌트들 components: z.array(componentV2Schema).default([]), }); export type Layer = z.infer; export const layoutV2Schema = z.object({ version: z.string().default("2.1"), layers: z.array(layerSchema).default([]), // 신규 필드 components: z.array(componentV2Schema).default([]), // 하위 호환성 유지 updatedAt: z.string().optional(), screenResolution: z .object({ width: z.number().default(1920), height: z.number().default(1080), }) .optional(), gridSettings: z.any().optional(), }); export type ComponentV2 = z.infer; export type LayoutV2 = z.infer; // ============================================ // V2 컴포넌트 overrides 스키마 정의 // ============================================ // v2-table-list const v2TableListOverridesSchema = z .object({ displayMode: z.enum(["table", "card"]).default("table"), showHeader: z.boolean().default(true), showFooter: z.boolean().default(true), height: z.string().default("auto"), checkbox: z .object({ enabled: z.boolean().default(true), multiple: z.boolean().default(true), position: z.string().default("left"), selectAll: z.boolean().default(true), }) .default({ enabled: true, multiple: true, position: "left", selectAll: true }), columns: z.array(z.any()).default([]), autoWidth: z.boolean().default(true), stickyHeader: z.boolean().default(false), pagination: z .object({ enabled: z.boolean().default(true), pageSize: z.number().default(20), showSizeSelector: z.boolean().default(true), showPageInfo: z.boolean().default(true), }) .default({ enabled: true, pageSize: 20, showSizeSelector: true, showPageInfo: true }), autoLoad: z.boolean().default(true), }) .passthrough(); // v2-button-primary const v2ButtonPrimaryOverridesSchema = z .object({ text: z.string().default("저장"), actionType: z.string().default("button"), variant: z.string().default("primary"), action: z .object({ type: z.string().default("save"), successMessage: z.string().optional(), errorMessage: z.string().optional(), }) .optional(), }) .passthrough(); // v2-text-display const v2TextDisplayOverridesSchema = z .object({ text: z.string().default("텍스트를 입력하세요"), fontSize: z.string().default("14px"), fontWeight: z.string().default("normal"), color: z.string().default("#212121"), textAlign: z.string().default("left"), }) .passthrough(); // v2-split-panel-layout const v2SplitPanelLayoutOverridesSchema = z .object({ leftPanel: z .object({ title: z.string().default("마스터"), showSearch: z.boolean().default(false), showAdd: z.boolean().default(false), }) .default({ title: "마스터", showSearch: false, showAdd: false }), rightPanel: z .object({ title: z.string().default("디테일"), showSearch: z.boolean().default(false), showAdd: z.boolean().default(false), }) .default({ title: "디테일", showSearch: false, showAdd: false }), splitRatio: z.number().default(30), resizable: z.boolean().default(true), autoLoad: z.boolean().default(true), syncSelection: z.boolean().default(true), }) .passthrough(); // v2-section-card const v2SectionCardOverridesSchema = z .object({ title: z.string().default("섹션 제목"), description: z.string().default(""), showHeader: z.boolean().default(true), padding: z.string().default("md"), backgroundColor: z.string().default("default"), borderStyle: z.string().default("solid"), collapsible: z.boolean().default(false), defaultOpen: z.boolean().default(true), }) .passthrough(); // v2-section-paper const v2SectionPaperOverridesSchema = z .object({ backgroundColor: z.string().default("default"), padding: z.string().default("md"), roundedCorners: z.string().default("md"), shadow: z.string().default("none"), showBorder: z.boolean().default(false), }) .passthrough(); // v2-divider-line const v2DividerLineOverridesSchema = z .object({ placeholder: z.string().default("텍스트를 입력하세요"), maxLength: z.number().default(255), }) .passthrough(); // v2-repeat-container const v2RepeatContainerOverridesSchema = z .object({ dataSourceType: z.string().default("manual"), layout: z.string().default("vertical"), gridColumns: z.number().default(2), gap: z.string().default("16px"), showBorder: z.boolean().default(true), showShadow: z.boolean().default(false), emptyMessage: z.string().default("데이터가 없습니다"), usePaging: z.boolean().default(false), pageSize: z.number().default(10), clickable: z.boolean().default(false), selectionMode: z.string().default("single"), }) .passthrough(); // v2-rack-structure const v2RackStructureOverridesSchema = z .object({ showPreview: z.boolean().default(true), showTemplate: z.boolean().default(true), }) .passthrough(); // v2-numbering-rule const v2NumberingRuleOverridesSchema = z .object({ showPreview: z.boolean().default(true), }) .passthrough(); // v2-category-manager const v2CategoryManagerOverridesSchema = z .object({ viewMode: z.string().default("tree"), maxDepth: z.number().default(3), showActions: z.boolean().default(true), }) .passthrough(); // v2-pivot-grid const v2PivotGridOverridesSchema = z .object({ fields: z.array(z.any()).default([]), dataSource: z.any().optional(), }) .passthrough(); // v2-location-swap-selector const v2LocationSwapSelectorOverridesSchema = z .object({ dataSource: z .object({ type: z.string().default("static"), tableName: z.string().default(""), valueField: z.string().default("location_code"), labelField: z.string().default("location_name"), }) .default({ type: "static", tableName: "", valueField: "location_code", labelField: "location_name" }), departureField: z.string().default("departure"), destinationField: z.string().default("destination"), departureLabel: z.string().default("출발지"), destinationLabel: z.string().default("도착지"), showSwapButton: z.boolean().default(true), variant: z.string().default("card"), }) .passthrough(); // v2-aggregation-widget const v2AggregationWidgetOverridesSchema = z .object({ dataSourceType: z.string().default("table"), items: z.array(z.any()).default([]), filters: z.array(z.any()).default([]), filterLogic: z.string().default("AND"), layout: z.string().default("horizontal"), showLabels: z.boolean().default(true), showIcons: z.boolean().default(true), gap: z.string().default("16px"), autoRefresh: z.boolean().default(false), refreshOnFormChange: z.boolean().default(true), }) .passthrough(); // v2-card-display const v2CardDisplayOverridesSchema = z .object({ cardsPerRow: z.number().default(3), cardSpacing: z.number().default(16), cardStyle: z .object({ showTitle: z.boolean().default(true), showSubtitle: z.boolean().default(true), showDescription: z.boolean().default(true), showImage: z.boolean().default(false), showActions: z.boolean().default(true), }) .default({ showTitle: true, showSubtitle: true, showDescription: true, showImage: false, showActions: true }), columnMapping: z.record(z.string(), z.any()).default({}), dataSource: z.string().default("table"), staticData: z.array(z.any()).default([]), }) .passthrough(); // v2-table-search-widget const v2TableSearchWidgetOverridesSchema = z .object({ title: z.string().default("테이블 검색"), autoSelectFirstTable: z.boolean().default(true), showTableSelector: z.boolean().default(true), }) .passthrough(); // v2-tabs-widget const v2TabsWidgetOverridesSchema = z .object({ tabs: z .array( z.object({ id: z.string(), label: z.string(), order: z.number().default(0), disabled: z.boolean().default(false), components: z.array(z.any()).default([]), }), ) .default([ { id: "tab-1", label: "탭 1", order: 0, disabled: false, components: [] }, { id: "tab-2", label: "탭 2", order: 1, disabled: false, components: [] }, ]), defaultTab: z.string().default("tab-1"), orientation: z.enum(["horizontal", "vertical"]).default("horizontal"), variant: z.string().default("default"), allowCloseable: z.boolean().default(false), persistSelection: z.boolean().default(false), }) .passthrough(); // v2-repeater const v2V2RepeaterOverridesSchema = z .object({ renderMode: z.enum(["inline", "modal", "button", "mixed"]).default("inline"), dataSource: z .object({ tableName: z.string().default(""), foreignKey: z.string().default(""), referenceKey: z.string().default(""), }) .default({ tableName: "", foreignKey: "", referenceKey: "" }), columns: z.array(z.any()).default([]), modal: z.object({ size: z.string().default("md") }).default({ size: "md" }), button: z .object({ sourceType: z.string().default("manual"), manualButtons: z.array(z.any()).default([]), layout: z.string().default("horizontal"), style: z.string().default("outline"), }) .default({ sourceType: "manual", manualButtons: [], layout: "horizontal", style: "outline" }), features: z .object({ showAddButton: z.boolean().default(true), showDeleteButton: z.boolean().default(true), inlineEdit: z.boolean().default(false), dragSort: z.boolean().default(false), showRowNumber: z.boolean().default(false), selectable: z.boolean().default(false), multiSelect: z.boolean().default(false), }) .default({ showAddButton: true, showDeleteButton: true, inlineEdit: false, dragSort: false, showRowNumber: false, selectable: false, multiSelect: false, }), }) .passthrough(); // ============================================ // V2 컴포넌트 overrides 스키마 정의 // ============================================ // v2-input const v2InputOverridesSchema = z .object({ inputType: z.string().default("text"), format: z.string().default("none"), placeholder: z.string().default(""), }) .passthrough(); // v2-select const v2SelectOverridesSchema = z .object({ mode: z.string().default("dropdown"), source: z.string().default("static"), options: z.array(z.any()).default([]), }) .passthrough(); // v2-date const v2DateOverridesSchema = z .object({ dateType: z.string().default("date"), format: z.string().default("YYYY-MM-DD"), }) .passthrough(); // v2-list const v2ListOverridesSchema = z .object({ viewMode: z.string().default("table"), source: z.string().default("static"), columns: z.array(z.any()).default([]), pagination: z.boolean().default(true), sortable: z.boolean().default(true), }) .passthrough(); // v2-layout const v2LayoutOverridesSchema = z .object({ layoutType: z.string().default("grid"), columns: z.number().default(2), gap: z.string().default("16"), use12Column: z.boolean().default(true), }) .passthrough(); // v2-group const v2GroupOverridesSchema = z .object({ groupType: z.string().default("section"), title: z.string().default(""), collapsible: z.boolean().default(false), defaultOpen: z.boolean().default(true), }) .passthrough(); // v2-media const v2MediaOverridesSchema = z .object({ mediaType: z.string().default("image"), multiple: z.boolean().default(false), preview: z.boolean().default(true), }) .passthrough(); // v2-biz const v2BizOverridesSchema = z .object({ bizType: z.string().default("flow"), }) .passthrough(); // v2-hierarchy const v2HierarchyOverridesSchema = z .object({ hierarchyType: z.string().default("tree"), viewMode: z.string().default("tree"), dataSource: z.string().default("static"), }) .passthrough(); // v2-repeater const v2RepeaterOverridesSchema = z .object({ renderMode: z.enum(["inline", "modal"]).default("inline"), mainTableName: z.string().optional(), useCustomTable: z.boolean().default(false), foreignKeyColumn: z.string().optional(), foreignKeySourceColumn: z.string().optional(), dataSource: z .object({ tableName: z.string().optional(), sourceTable: z.string().optional(), foreignKey: z.string().optional(), referenceKey: z.string().optional(), displayColumn: z.string().optional(), }) .default({}), columns: z.array(z.any()).default([]), columnMappings: z.array(z.any()).default([]), calculationRules: z.array(z.any()).default([]), modal: z .object({ size: z.enum(["sm", "md", "lg", "xl", "full"]).default("lg"), title: z.string().optional(), buttonText: z.string().optional(), sourceDisplayColumns: z.array(z.any()).default([]), searchFields: z.array(z.string()).default([]), }) .default({ size: "lg", sourceDisplayColumns: [], searchFields: [] }), features: z .object({ showAddButton: z.boolean().default(true), showDeleteButton: z.boolean().default(true), inlineEdit: z.boolean().default(true), dragSort: z.boolean().default(false), showRowNumber: z.boolean().default(false), selectable: z.boolean().default(false), multiSelect: z.boolean().default(true), }) .default({ showAddButton: true, showDeleteButton: true, inlineEdit: true, dragSort: false, showRowNumber: false, selectable: false, multiSelect: true, }), style: z .object({ maxHeight: z.string().optional(), minHeight: z.string().optional(), borderless: z.boolean().default(false), compact: z.boolean().default(false), }) .optional(), }) .passthrough(); // ============================================ // 컴포넌트별 overrides 스키마 레지스트리 // ============================================ const componentOverridesSchemaRegistry: Record>> = { // V2 컴포넌트 (16개) "v2-table-list": v2TableListOverridesSchema, "v2-button-primary": v2ButtonPrimaryOverridesSchema, "v2-text-display": v2TextDisplayOverridesSchema, "v2-split-panel-layout": v2SplitPanelLayoutOverridesSchema, "v2-section-card": v2SectionCardOverridesSchema, "v2-section-paper": v2SectionPaperOverridesSchema, "v2-divider-line": v2DividerLineOverridesSchema, "v2-repeat-container": v2RepeatContainerOverridesSchema, "v2-rack-structure": v2RackStructureOverridesSchema, "v2-numbering-rule": v2NumberingRuleOverridesSchema, "v2-category-manager": v2CategoryManagerOverridesSchema, "v2-pivot-grid": v2PivotGridOverridesSchema, "v2-location-swap-selector": v2LocationSwapSelectorOverridesSchema, "v2-aggregation-widget": v2AggregationWidgetOverridesSchema, "v2-card-display": v2CardDisplayOverridesSchema, "v2-table-search-widget": v2TableSearchWidgetOverridesSchema, "v2-tabs-widget": v2TabsWidgetOverridesSchema, "v2-repeater": v2V2RepeaterOverridesSchema, // V2 컴포넌트 (9개) "v2-input": v2InputOverridesSchema, "v2-select": v2SelectOverridesSchema, "v2-date": v2DateOverridesSchema, "v2-list": v2ListOverridesSchema, "v2-layout": v2LayoutOverridesSchema, "v2-group": v2GroupOverridesSchema, "v2-media": v2MediaOverridesSchema, "v2-biz": v2BizOverridesSchema, "v2-hierarchy": v2HierarchyOverridesSchema, }; // ============================================ // 컴포넌트별 기본값 레지스트리 (fallback용) // ============================================ const componentDefaultsRegistry: Record> = { // V2 컴포넌트 "v2-table-list": { displayMode: "table", showHeader: true, showFooter: true, height: "auto", checkbox: { enabled: true, multiple: true, position: "left", selectAll: true }, columns: [], autoWidth: true, stickyHeader: false, pagination: { enabled: true, pageSize: 20, showSizeSelector: true, showPageInfo: true }, autoLoad: true, }, "v2-button-primary": { text: "저장", actionType: "button", variant: "primary", action: { type: "save", successMessage: "저장되었습니다.", errorMessage: "저장 중 오류가 발생했습니다." }, }, "v2-text-display": { text: "텍스트를 입력하세요", fontSize: "14px", fontWeight: "normal", color: "#212121", textAlign: "left", }, "v2-split-panel-layout": { leftPanel: { title: "마스터", showSearch: false, showAdd: false }, rightPanel: { title: "디테일", showSearch: false, showAdd: false }, splitRatio: 30, resizable: true, autoLoad: true, syncSelection: true, }, "v2-section-card": { title: "섹션 제목", description: "", showHeader: true, padding: "md", backgroundColor: "default", borderStyle: "solid", collapsible: false, defaultOpen: true, }, "v2-section-paper": { backgroundColor: "default", padding: "md", roundedCorners: "md", shadow: "none", showBorder: false, }, "v2-divider-line": { placeholder: "텍스트를 입력하세요", maxLength: 255, }, "v2-repeat-container": { dataSourceType: "manual", layout: "vertical", gridColumns: 2, gap: "16px", showBorder: true, showShadow: false, emptyMessage: "데이터가 없습니다", usePaging: false, pageSize: 10, clickable: false, selectionMode: "single", }, "v2-rack-structure": { showPreview: true, showTemplate: true, }, "v2-numbering-rule": { showPreview: true, }, "v2-category-manager": { viewMode: "tree", maxDepth: 3, showActions: true, }, "v2-pivot-grid": { fields: [], }, "v2-location-swap-selector": { dataSource: { type: "static", tableName: "", valueField: "location_code", labelField: "location_name" }, departureField: "departure", destinationField: "destination", departureLabel: "출발지", destinationLabel: "도착지", showSwapButton: true, variant: "card", }, "v2-aggregation-widget": { dataSourceType: "table", items: [], filters: [], filterLogic: "AND", layout: "horizontal", showLabels: true, showIcons: true, gap: "16px", autoRefresh: false, refreshOnFormChange: true, }, "v2-card-display": { cardsPerRow: 3, cardSpacing: 16, cardStyle: { showTitle: true, showSubtitle: true, showDescription: true, showImage: false, showActions: true }, columnMapping: {}, dataSource: "table", staticData: [], }, "v2-table-search-widget": { title: "테이블 검색", autoSelectFirstTable: true, showTableSelector: true, }, "v2-tabs-widget": { tabs: [ { id: "tab-1", label: "탭 1", order: 0, disabled: false, components: [] }, { id: "tab-2", label: "탭 2", order: 1, disabled: false, components: [] }, ], defaultTab: "tab-1", orientation: "horizontal", variant: "default", allowCloseable: false, persistSelection: false, }, "v2-repeater": { renderMode: "inline", dataSource: { tableName: "", foreignKey: "", referenceKey: "" }, columns: [], modal: { size: "md" }, button: { sourceType: "manual", manualButtons: [], layout: "horizontal", style: "outline" }, features: { showAddButton: true, showDeleteButton: true, inlineEdit: false, dragSort: false, showRowNumber: false, selectable: false, multiSelect: false, }, }, // V2 컴포넌트 "v2-input": { inputType: "text", format: "none", placeholder: "", }, "v2-select": { mode: "dropdown", source: "static", options: [], }, "v2-date": { dateType: "date", format: "YYYY-MM-DD", }, "v2-list": { viewMode: "table", source: "static", columns: [], pagination: true, sortable: true, }, "v2-layout": { layoutType: "grid", columns: 2, gap: "16", use12Column: true, }, "v2-group": { groupType: "section", title: "", collapsible: false, defaultOpen: true, }, "v2-media": { mediaType: "image", multiple: false, preview: true, }, "v2-biz": { bizType: "flow", }, "v2-hierarchy": { hierarchyType: "tree", viewMode: "tree", dataSource: "static", }, "v2-repeater": { renderMode: "inline", useCustomTable: false, dataSource: {}, columns: [], columnMappings: [], calculationRules: [], modal: { size: "lg", sourceDisplayColumns: [], searchFields: [], }, features: { showAddButton: true, showDeleteButton: true, inlineEdit: true, dragSort: false, showRowNumber: false, selectable: false, multiSelect: true, }, }, }; // ============================================ // 컴포넌트 기본값 조회 // ============================================ export function getComponentDefaults(componentType: string): Record { const schema = componentOverridesSchemaRegistry[componentType]; if (schema) { return schema.parse({}); } return componentDefaultsRegistry[componentType] || {}; } // ============================================ // URL에서 기본값 조회 // ============================================ export function getDefaultsByUrl(url: string): Record { const componentType = getComponentTypeFromUrl(url); return getComponentDefaults(componentType); } // ============================================ // overrides 스키마 파싱 (유효성 검사) // ============================================ export function parseOverridesByUrl( url: string, overrides: Record | null | undefined, options?: { applyDefaults?: boolean }, ): Record { const componentType = getComponentTypeFromUrl(url); const schema = componentOverridesSchemaRegistry[componentType]; const applyDefaults = options?.applyDefaults ?? false; if (!schema) { return overrides || {}; } const parsed = schema.safeParse(overrides || {}); if (!parsed.success) { console.warn("V2 overrides 스키마 검증 실패", { componentType, errors: parsed.error.issues, }); return overrides || {}; } return applyDefaults ? parsed.data : overrides || {}; } // ============================================ // V2 컴포넌트 로드 (기본값 + overrides 병합) // ============================================ export function loadComponentV2(component: ComponentV2): ComponentV2 & { config: Record } { const defaults = getDefaultsByUrl(component.url); const overrides = parseOverridesByUrl(component.url, component.overrides); const config = mergeComponentConfig(defaults, overrides); return { ...component, config, }; } // ============================================ // V2 컴포넌트 저장 (차이값 추출) // ============================================ export function saveComponentV2(component: ComponentV2 & { config?: Record }): ComponentV2 { const defaults = getDefaultsByUrl(component.url); const normalizedConfig = component.config ? parseOverridesByUrl(component.url, component.config, { applyDefaults: true }) : undefined; const normalizedOverrides = normalizedConfig ? extractCustomConfig(normalizedConfig, defaults) : parseOverridesByUrl(component.url, component.overrides); return { id: component.id, url: component.url, position: component.position, size: component.size, displayOrder: component.displayOrder, overrides: normalizedOverrides, }; } // ============================================ // V2 레이아웃 로드 (전체 컴포넌트 기본값 병합) // ============================================ export function loadLayoutV2(layoutData: any): LayoutV2 & { components: Array }>; layers: Array }> }>; } { const parsed = layoutV2Schema.parse(layoutData || { version: "2.1", components: [], layers: [] }); // 마이그레이션: components만 있고 layers가 없는 경우 Default Layer 생성 if ((!parsed.layers || parsed.layers.length === 0) && parsed.components && parsed.components.length > 0) { const defaultLayer: Layer = { id: "default-layer", name: "기본 레이어", type: "base", zIndex: 0, isVisible: true, isLocked: false, components: parsed.components, }; parsed.layers = [defaultLayer]; } // 모든 레이어의 컴포넌트 로드 const loadedLayers = parsed.layers.map((layer) => ({ ...layer, components: layer.components.map(loadComponentV2), })); // 하위 호환성을 위한 components 배열 (모든 레이어의 컴포넌트 합침) const allComponents = loadedLayers.flatMap((layer) => layer.components); return { ...parsed, layers: loadedLayers, components: allComponents, }; } // ============================================ // V2 레이아웃 저장 (전체 컴포넌트 차이값 추출) // ============================================ export function saveLayoutV2( components: Array }>, layers?: Array }> }>, ): LayoutV2 { // 레이어가 있는 경우 레이어 구조 저장 if (layers && layers.length > 0) { const savedLayers = layers.map((layer) => ({ ...layer, components: layer.components.map(saveComponentV2), })); return { version: "2.1", layers: savedLayers, components: savedLayers.flatMap((l) => l.components), // 하위 호환성 }; } // 레이어가 없는 경우 (기존 방식) - Default Layer로 감싸서 저장 const savedComponents = components.map(saveComponentV2); const defaultLayer: Layer = { id: "default-layer", name: "기본 레이어", type: "base", zIndex: 0, isVisible: true, isLocked: false, components: savedComponents, }; return { version: "2.1", layers: [defaultLayer], components: savedComponents, }; }