/** * 컴포넌트 설정 공통 스키마 및 병합 유틸리티 * * 모든 컴포넌트가 공통으로 사용 * - 기본값: 각 컴포넌트의 defaultConfig에서 가져옴 * - 커스텀: DB custom_config에서 가져옴 * - 최종 설정 = 기본값 + 커스텀 (깊은 병합) */ import { z } from "zod"; // ============================================ // 공통 스키마 (모든 구조 허용) // ============================================ export const customConfigSchema = z.record(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/split-panel-layout" → "split-panel-layout" 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.any()).default({}), }); export const layoutV2Schema = z.object({ version: z.string().default("2.0"), components: z.array(componentV2Schema).default([]), updatedAt: z.string().optional(), }); export type ComponentV2 = z.infer; export type LayoutV2 = z.infer; // ============================================ // 컴포넌트별 기본값 레지스트리 // ============================================ const componentDefaultsRegistry: Record> = { "table-list": { pagination: true, pageSize: 20, selectable: true, showHeader: true, }, "button-primary": { label: "버튼", variant: "default", size: "default", }, "text-input": { placeholder: "", multiline: false, }, "select-basic": { placeholder: "선택하세요", options: [], }, "date-input": { format: "YYYY-MM-DD", }, "split-panel-layout": { splitRatio: 50, direction: "horizontal", resizable: true, }, "tabs-widget": { tabs: [], defaultTab: 0, }, "card-display": { title: "", bordered: true, }, "flow-widget": { flowId: null, }, "category-management": { categoryType: "", }, "pivot-table": { rows: [], columns: [], values: [], }, "unified-grid": { columns: [], }, "checkbox-basic": { label: "", defaultChecked: false, }, "radio-basic": { options: [], }, "file-upload": { accept: "*", multiple: false, }, "repeat-container": { children: [], }, }; // ============================================ // 컴포넌트 기본값 조회 // ============================================ export function getComponentDefaults(componentType: string): Record { return componentDefaultsRegistry[componentType] || {}; } // ============================================ // URL에서 기본값 조회 // ============================================ export function getDefaultsByUrl(url: string): Record { const componentType = getComponentTypeFromUrl(url); return getComponentDefaults(componentType); } // ============================================ // V2 컴포넌트 로드 (기본값 + overrides 병합) // ============================================ export function loadComponentV2(component: ComponentV2): ComponentV2 & { config: Record } { const defaults = getDefaultsByUrl(component.url); const config = mergeComponentConfig(defaults, component.overrides); return { ...component, config, }; } // ============================================ // V2 컴포넌트 저장 (차이값 추출) // ============================================ export function saveComponentV2( component: ComponentV2 & { config?: Record } ): ComponentV2 { const defaults = getDefaultsByUrl(component.url); const overrides = component.config ? extractCustomConfig(component.config, defaults) : component.overrides; return { id: component.id, url: component.url, position: component.position, size: component.size, displayOrder: component.displayOrder, overrides, }; } // ============================================ // V2 레이아웃 로드 (전체 컴포넌트 기본값 병합) // ============================================ export function loadLayoutV2(layoutData: any): LayoutV2 & { components: Array }> } { const parsed = layoutV2Schema.parse(layoutData || { version: "2.0", components: [] }); return { ...parsed, components: parsed.components.map(loadComponentV2), }; } // ============================================ // V2 레이아웃 저장 (전체 컴포넌트 차이값 추출) // ============================================ export function saveLayoutV2( components: Array }> ): LayoutV2 { return { version: "2.0", components: components.map(saveComponentV2), updatedAt: new Date().toISOString(), }; }