diff --git a/PLAN.MD b/PLAN.MD index d68ff888..f41ab11b 100644 --- a/PLAN.MD +++ b/PLAN.MD @@ -1,3 +1,45 @@ +# 프로젝트: V2/Unified 컴포넌트 설정 스키마 정비 + +## 개요 +레거시 컴포넌트를 제거하고, V2/Unified 컴포넌트 전용 Zod 스키마와 기본값 레지스트리를 한 곳에서 관리한다. + +## 핵심 기능 +1. [x] 레거시 컴포넌트 스키마 제거 +2. [x] V2 컴포넌트 overrides 스키마 정의 (16개) +3. [x] Unified 컴포넌트 overrides 스키마 정의 (9개) +4. [x] componentConfig.ts 한 파일에서 통합 관리 + +## 정의된 V2 컴포넌트 (18개) +- v2-table-list, v2-button-primary, v2-text-display +- v2-split-panel-layout, v2-section-card, v2-section-paper +- v2-divider-line, v2-repeat-container, v2-rack-structure +- v2-numbering-rule, v2-category-manager, v2-pivot-grid +- v2-location-swap-selector, v2-aggregation-widget +- v2-card-display, v2-table-search-widget, v2-tabs-widget +- v2-unified-repeater + +## 정의된 Unified 컴포넌트 (9개) +- unified-input, unified-select, unified-date +- unified-list, unified-layout, unified-group +- unified-media, unified-biz, unified-hierarchy + +## 테스트 계획 +### 1단계: 기본 기능 +- [x] V2 레이아웃 저장 시 컴포넌트별 overrides 스키마 검증 통과 +- [x] Unified 컴포넌트 기본값과 스키마가 매칭됨 + +### 2단계: 에러 케이스 +- [x] 잘못된 overrides 입력 시 Zod 검증 실패 처리 (safeParse + console.warn + graceful fallback) +- [x] 누락된 기본값 컴포넌트 저장 시 안전한 기본값 적용 (레지스트리 조회 → 빈 객체) + +## 에러 처리 계획 +- 스키마 파싱 실패 시 로그/에러 메시지 표준화 +- 기본값 누락 시 안전한 fallback 적용 + +## 진행 상태 +- [x] 레거시 컴포넌트 제거 완료 +- [x] V2/Unified 스키마 정의 완료 +- [x] 한 파일 통합 관리 완료 # 프로젝트: 화면 복제 기능 개선 (DB 구조 개편 후) ## 개요 diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 23a3c25e..afca0251 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -1586,6 +1586,7 @@ export class ScreenManagementService { /** * 레이아웃 조회 (✅ Raw Query 전환 완료) + * V2 테이블 우선 조회 → 없으면 V1 테이블 조회 */ async getLayout( screenId: number, @@ -1610,6 +1611,76 @@ export class ScreenManagementService { throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다."); } + // 🆕 V2 테이블 우선 조회 (회사별 → 공통(*)) + let v2Layout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2`, + [screenId, companyCode] + ); + + // 회사별 레이아웃 없으면 공통(*) 조회 + if (!v2Layout && companyCode !== "*") { + v2Layout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = '*'`, + [screenId] + ); + } + + // V2 레이아웃이 있으면 V2 형식으로 반환 + if (v2Layout && v2Layout.layout_data) { + console.log(`V2 레이아웃 발견, V2 형식으로 반환`); + const layoutData = v2Layout.layout_data; + + // V2 형식의 components를 LayoutData 형식으로 변환 + const components = (layoutData.components || []).map((comp: any) => ({ + id: comp.id, + type: comp.overrides?.type || "component", + position: comp.position || { x: 0, y: 0, z: 1 }, + size: comp.size || { width: 200, height: 100 }, + componentUrl: comp.url, + componentType: comp.overrides?.type, + componentConfig: comp.overrides || {}, + displayOrder: comp.displayOrder || 0, + ...comp.overrides, + })); + + // screenResolution이 없으면 컴포넌트 위치 기반으로 자동 계산 + let screenResolution = layoutData.screenResolution; + if (!screenResolution && components.length > 0) { + let maxRight = 0; + let maxBottom = 0; + + for (const comp of layoutData.components || []) { + const right = (comp.position?.x || 0) + (comp.size?.width || 200); + const bottom = (comp.position?.y || 0) + (comp.size?.height || 100); + maxRight = Math.max(maxRight, right); + maxBottom = Math.max(maxBottom, bottom); + } + + // 여백 100px 추가, 최소 1200x800 보장 + screenResolution = { + width: Math.max(1200, maxRight + 100), + height: Math.max(800, maxBottom + 100), + }; + console.log(`screenResolution 자동 계산:`, screenResolution); + } + + return { + components, + gridSettings: layoutData.gridSettings || { + columns: 12, + gap: 16, + padding: 16, + snapToGrid: true, + showGrid: true, + }, + screenResolution, + }; + } + + console.log(`V2 레이아웃 없음, V1 테이블 조회`); + const layouts = await query( `SELECT * FROM screen_layouts WHERE screen_id = $1 diff --git a/docker/dev/backend.Dockerfile b/docker/dev/backend.Dockerfile index b3ec78ad..10938378 100644 --- a/docker/dev/backend.Dockerfile +++ b/docker/dev/backend.Dockerfile @@ -3,9 +3,9 @@ FROM node:20-bookworm-slim WORKDIR /app -# 시스템 패키지 설치 +# 시스템 패키지 설치 (curl: 헬스 체크용) RUN apt-get update \ - && apt-get install -y --no-install-recommends openssl ca-certificates \ + && apt-get install -y --no-install-recommends openssl ca-certificates curl \ && rm -rf /var/lib/apt/lists/* # package.json 복사 및 의존성 설치 (개발 의존성 포함) diff --git a/docker/dev/frontend.Dockerfile b/docker/dev/frontend.Dockerfile index fdad92f6..cb2719bc 100644 --- a/docker/dev/frontend.Dockerfile +++ b/docker/dev/frontend.Dockerfile @@ -16,5 +16,5 @@ COPY . . # 포트 노출 EXPOSE 3000 -# 개발 서버 시작 (Docker에서는 포트 3000 사용) -CMD ["npm", "run", "dev", "--", "-p", "3000"] \ No newline at end of file +# 개발 서버 시작 (Docker에서는 Turbopack 비활성화로 CPU 폭주 방지) +CMD ["npm", "run", "dev:docker"] \ No newline at end of file diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index b4f5b3df..102479bc 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -25,6 +25,7 @@ import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; // 활성 탭 관리 import { evaluateConditional } from "@/lib/utils/conditionalEvaluator"; // 조건부 표시 평가 import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어 +import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; // V2 Zod 기반 변환 function ScreenViewPage() { const params = useParams(); @@ -148,10 +149,28 @@ function ScreenViewPage() { const screenData = await screenApi.getScreen(screenId); setScreen(screenData); - // 레이아웃 로드 + // 레이아웃 로드 (V2 우선, Zod 기반 기본값 병합) try { - const layoutData = await screenApi.getLayout(screenId); - setLayout(layoutData); + // V2 API 먼저 시도 + const v2Response = await screenApi.getLayoutV2(screenId); + + if (v2Response && isValidV2Layout(v2Response)) { + // V2 레이아웃: Zod 기반 변환 (기본값 병합) + const convertedLayout = convertV2ToLegacy(v2Response); + if (convertedLayout) { + console.log("📦 V2 레이아웃 로드 (Zod 기반):", v2Response.components?.length || 0, "개 컴포넌트"); + setLayout({ + ...convertedLayout, + screenResolution: v2Response.screenResolution || convertedLayout.screenResolution, + } as LayoutData); + } else { + throw new Error("V2 레이아웃 변환 실패"); + } + } else { + // V1 레이아웃 또는 빈 레이아웃 + const layoutData = await screenApi.getLayout(screenId); + setLayout(layoutData); + } } catch (layoutError) { console.warn("레이아웃 로드 실패, 빈 레이아웃 사용:", layoutError); setLayout({ @@ -490,69 +509,8 @@ function ScreenViewPage() { ); // 🆕 같은 X 영역(섹션)에서 컴포넌트들이 겹치지 않도록 자동 수직 정렬 - const autoLayoutComponents = (() => { - // X 위치 기준으로 섹션 그룹화 (50px 오차 범위) - const X_THRESHOLD = 50; - const GAP = 16; // 컴포넌트 간 간격 - - // 컴포넌트를 X 섹션별로 그룹화 - const sections: Map = new Map(); - - regularComponents.forEach((comp) => { - const x = comp.position.x; - let foundSection = false; - - for (const [sectionX, components] of sections.entries()) { - if (Math.abs(x - sectionX) < X_THRESHOLD) { - components.push(comp); - foundSection = true; - break; - } - } - - if (!foundSection) { - sections.set(x, [comp]); - } - }); - - // 각 섹션 내에서 Y 위치 순으로 정렬 후 자동 배치 - const adjustedMap = new Map(); - - for (const [sectionX, components] of sections.entries()) { - // 섹션 내 2개 이상 컴포넌트가 있을 때만 자동 배치 - if (components.length >= 2) { - // Y 위치 순으로 정렬 - const sorted = [...components].sort((a, b) => a.position.y - b.position.y); - - let currentY = sorted[0].position.y; - - sorted.forEach((comp, index) => { - if (index === 0) { - adjustedMap.set(comp.id, comp); - } else { - // 이전 컴포넌트 아래로 배치 - const prevComp = sorted[index - 1]; - const prevAdjusted = adjustedMap.get(prevComp.id) || prevComp; - const prevBottom = prevAdjusted.position.y + (prevAdjusted.size?.height || 100); - const newY = prevBottom + GAP; - - adjustedMap.set(comp.id, { - ...comp, - position: { - ...comp.position, - y: newY, - }, - }); - } - }); - } else { - // 단일 컴포넌트는 그대로 - components.forEach((comp) => adjustedMap.set(comp.id, comp)); - } - } - - return regularComponents.map((comp) => adjustedMap.get(comp.id) || comp); - })(); + // ⚠️ V2 레이아웃에서는 사용자가 배치한 위치를 존중하므로 자동 정렬 비활성화 + const autoLayoutComponents = regularComponents; // TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 추가 조정 const adjustedComponents = autoLayoutComponents.map((component) => { diff --git a/frontend/components/unified/UnifiedRepeater.tsx b/frontend/components/unified/UnifiedRepeater.tsx index d97c6850..187f6b05 100644 --- a/frontend/components/unified/UnifiedRepeater.tsx +++ b/frontend/components/unified/UnifiedRepeater.tsx @@ -130,8 +130,9 @@ export const UnifiedRepeater: React.FC = ({ mainTableName: config.mainTableName, foreignKeyColumn: config.foreignKeyColumn, masterRecordId, - dataLength: data.length - }); + dataLength: data.length, + }; + console.log("UnifiedRepeater 저장 시작", saveInfo); try { // 테이블 유효 컬럼 조회 diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 0c439ca9..aed8af40 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -882,7 +882,8 @@ export const TableListComponent: React.FC = ({ count: incomingData.length, mode, position: currentSplitPosition, - }); + }; + console.log("분할 패널 데이터 수신", receiveInfo); await dataReceiver.receiveData(incomingData, { targetComponentId: component.id, diff --git a/frontend/lib/schemas/componentConfig.ts b/frontend/lib/schemas/componentConfig.ts index 0816cbf9..f45fc49b 100644 --- a/frontend/lib/schemas/componentConfig.ts +++ b/frontend/lib/schemas/componentConfig.ts @@ -1,45 +1,38 @@ /** - * 컴포넌트 설정 공통 스키마 및 병합 유틸리티 - * - * 모든 컴포넌트가 공통으로 사용 - * - 기본값: 각 컴포넌트의 defaultConfig에서 가져옴 - * - 커스텀: DB custom_config에서 가져옴 - * - 최종 설정 = 기본값 + 커스텀 (깊은 병합) + * V2/Unified 컴포넌트 설정 스키마 및 병합 유틸리티 + * + * V2 컴포넌트와 Unified 컴포넌트의 overrides 스키마 및 기본값을 관리 + * - 저장: component_url + overrides (차이값만) + * - 로드: 코드 기본값 + overrides 병합 (Zod) */ import { z } from "zod"; // ============================================ // 공통 스키마 (모든 구조 허용) // ============================================ -export const customConfigSchema = z.record(z.any()); +export const customConfigSchema = z.record(z.string(), z.any()); export type CustomConfig = z.infer; // ============================================ // 깊은 병합 함수 // ============================================ -export function deepMerge>( - target: T, - source: Record -): T { +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) - ) { + 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; } @@ -57,12 +50,12 @@ function isPlainObject(value: unknown): value is Record { // ============================================ export function mergeComponentConfig( defaultConfig: Record, - customConfig: Record | null | undefined + customConfig: Record | null | undefined, ): Record { if (!customConfig || Object.keys(customConfig).length === 0) { return { ...defaultConfig }; } - + return deepMerge(defaultConfig, customConfig); } @@ -71,20 +64,20 @@ export function mergeComponentConfig( // ============================================ export function extractCustomConfig( fullConfig: Record, - defaultConfig: 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; } @@ -96,7 +89,7 @@ export function isDeepEqual(a: unknown, b: unknown): boolean { 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; @@ -105,19 +98,19 @@ export function isDeepEqual(a: unknown, b: unknown): boolean { } 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; } @@ -132,7 +125,7 @@ export function getComponentUrl(componentType: string): string { // 컴포넌트 타입 추출 함수 (URL에서) // ============================================ export function getComponentTypeFromUrl(componentUrl: string): string { - // "@/lib/registry/components/split-panel-layout" → "split-panel-layout" + // "@/lib/registry/components/v2-table-list" → "v2-table-list" const parts = componentUrl.split("/"); return parts[parts.length - 1]; } @@ -152,84 +145,724 @@ export const componentV2Schema = z.object({ height: z.number().default(100), }), displayOrder: z.number().default(0), - overrides: z.record(z.any()).default({}), + overrides: z.record(z.string(), z.any()).default({}), }); export const layoutV2Schema = z.object({ version: z.string().default("2.0"), 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-unified-repeater +const v2UnifiedRepeaterOverridesSchema = 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(); + +// ============================================ +// Unified 컴포넌트 overrides 스키마 정의 +// ============================================ + +// unified-input +const unifiedInputOverridesSchema = z + .object({ + inputType: z.string().default("text"), + format: z.string().default("none"), + placeholder: z.string().default(""), + }) + .passthrough(); + +// unified-select +const unifiedSelectOverridesSchema = z + .object({ + mode: z.string().default("dropdown"), + source: z.string().default("static"), + options: z.array(z.any()).default([]), + }) + .passthrough(); + +// unified-date +const unifiedDateOverridesSchema = z + .object({ + dateType: z.string().default("date"), + format: z.string().default("YYYY-MM-DD"), + }) + .passthrough(); + +// unified-list +const unifiedListOverridesSchema = 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(); + +// unified-layout +const unifiedLayoutOverridesSchema = z + .object({ + layoutType: z.string().default("grid"), + columns: z.number().default(2), + gap: z.string().default("16"), + use12Column: z.boolean().default(true), + }) + .passthrough(); + +// unified-group +const unifiedGroupOverridesSchema = z + .object({ + groupType: z.string().default("section"), + title: z.string().default(""), + collapsible: z.boolean().default(false), + defaultOpen: z.boolean().default(true), + }) + .passthrough(); + +// unified-media +const unifiedMediaOverridesSchema = z + .object({ + mediaType: z.string().default("image"), + multiple: z.boolean().default(false), + preview: z.boolean().default(true), + }) + .passthrough(); + +// unified-biz +const unifiedBizOverridesSchema = z + .object({ + bizType: z.string().default("flow"), + }) + .passthrough(); + +// unified-hierarchy +const unifiedHierarchyOverridesSchema = z + .object({ + hierarchyType: z.string().default("tree"), + viewMode: z.string().default("tree"), + dataSource: z.string().default("static"), + }) + .passthrough(); + +// unified-repeater +const unifiedRepeaterOverridesSchema = 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-unified-repeater": v2UnifiedRepeaterOverridesSchema, + + // Unified 컴포넌트 (9개) + "unified-input": unifiedInputOverridesSchema, + "unified-select": unifiedSelectOverridesSchema, + "unified-date": unifiedDateOverridesSchema, + "unified-list": unifiedListOverridesSchema, + "unified-layout": unifiedLayoutOverridesSchema, + "unified-group": unifiedGroupOverridesSchema, + "unified-media": unifiedMediaOverridesSchema, + "unified-biz": unifiedBizOverridesSchema, + "unified-hierarchy": unifiedHierarchyOverridesSchema, + "unified-repeater": unifiedRepeaterOverridesSchema, +}; + +// ============================================ +// 컴포넌트별 기본값 레지스트리 (fallback용) // ============================================ const componentDefaultsRegistry: Record> = { - "table-list": { - pagination: true, - pageSize: 20, - selectable: true, + // 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, }, - "button-primary": { - label: "버튼", + "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", - size: "default", + allowCloseable: false, + persistSelection: false, }, - "text-input": { + "v2-unified-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, + }, + }, + + // Unified 컴포넌트 + "unified-input": { + inputType: "text", + format: "none", placeholder: "", - multiline: false, }, - "select-basic": { - placeholder: "선택하세요", + "unified-select": { + mode: "dropdown", + source: "static", options: [], }, - "date-input": { + "unified-date": { + dateType: "date", format: "YYYY-MM-DD", }, - "split-panel-layout": { - splitRatio: 50, - direction: "horizontal", - resizable: true, + "unified-list": { + viewMode: "table", + source: "static", + columns: [], + pagination: true, + sortable: true, }, - "tabs-widget": { - tabs: [], - defaultTab: 0, + "unified-layout": { + layoutType: "grid", + columns: 2, + gap: "16", + use12Column: true, }, - "card-display": { + "unified-group": { + groupType: "section", title: "", - bordered: true, + collapsible: false, + defaultOpen: 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: "*", + "unified-media": { + mediaType: "image", multiple: false, + preview: true, }, - "repeat-container": { - children: [], + "unified-biz": { + bizType: "flow", + }, + "unified-hierarchy": { + hierarchyType: "tree", + viewMode: "tree", + dataSource: "static", + }, + "unified-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, + }, }, }; @@ -237,6 +870,11 @@ const componentDefaultsRegistry: Record> = { // 컴포넌트 기본값 조회 // ============================================ export function getComponentDefaults(componentType: string): Record { + const schema = componentOverridesSchemaRegistry[componentType]; + if (schema) { + return schema.parse({}); + } + return componentDefaultsRegistry[componentType] || {}; } @@ -248,13 +886,42 @@ export function getDefaultsByUrl(url: string): Record { 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 config = mergeComponentConfig(defaults, component.overrides); - + const overrides = parseOverridesByUrl(component.url, component.overrides); + const config = mergeComponentConfig(defaults, overrides); + return { ...component, config, @@ -264,30 +931,33 @@ export function loadComponentV2(component: ComponentV2): ComponentV2 & { config: // ============================================ // V2 컴포넌트 저장 (차이값 추출) // ============================================ -export function saveComponentV2( - component: ComponentV2 & { config?: Record } -): ComponentV2 { +export function saveComponentV2(component: ComponentV2 & { config?: Record }): ComponentV2 { const defaults = getDefaultsByUrl(component.url); - const overrides = component.config - ? extractCustomConfig(component.config, defaults) - : component.overrides; - + 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, + overrides: normalizedOverrides, }; } // ============================================ // V2 레이아웃 로드 (전체 컴포넌트 기본값 병합) // ============================================ -export function loadLayoutV2(layoutData: any): LayoutV2 & { components: Array }> } { +export function loadLayoutV2( + layoutData: any, +): LayoutV2 & { components: Array }> } { const parsed = layoutV2Schema.parse(layoutData || { version: "2.0", components: [] }); - + return { ...parsed, components: parsed.components.map(loadComponentV2), @@ -297,9 +967,7 @@ export function loadLayoutV2(layoutData: any): LayoutV2 & { components: Array }> -): LayoutV2 { +export function saveLayoutV2(components: Array }>): LayoutV2 { return { version: "2.0", components: components.map(saveComponentV2), diff --git a/frontend/lib/schemas/components/button-primary.ts b/frontend/lib/schemas/components/button-primary.ts deleted file mode 100644 index e5705d97..00000000 --- a/frontend/lib/schemas/components/button-primary.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * button-primary 컴포넌트 Zod 스키마 및 기본값 - */ -import { z } from "zod"; - -// 버튼 액션 스키마 -export const buttonActionSchema = z.object({ - type: z.string().default("save"), - targetScreenId: z.number().optional(), - successMessage: z.string().optional(), - errorMessage: z.string().optional(), - modalSize: z.string().optional(), - modalTitle: z.string().optional(), - modalDescription: z.string().optional(), - modalTitleBlocks: z.array(z.any()).optional(), -}); - -// button-primary 설정 스키마 -export const buttonPrimaryConfigSchema = z.object({ - type: z.literal("button-primary").default("button-primary"), - text: z.string().default("저장"), - actionType: z.enum(["button", "submit", "reset"]).default("button"), - variant: z.enum(["primary", "secondary", "danger", "outline", "destructive"]).default("primary"), - webType: z.literal("button").default("button"), - action: buttonActionSchema.optional(), - // 추가 속성들 - label: z.string().optional(), - langKey: z.string().optional(), - langKeyId: z.number().optional(), - size: z.string().optional(), - backgroundColor: z.string().optional(), - textColor: z.string().optional(), - borderRadius: z.string().optional(), -}); - -export type ButtonPrimaryConfig = z.infer; - -// 기본값 (스키마에서 자동 생성) -export const buttonPrimaryDefaults: ButtonPrimaryConfig = buttonPrimaryConfigSchema.parse({}); diff --git a/frontend/lib/utils/layoutV2Converter.ts b/frontend/lib/utils/layoutV2Converter.ts index 45b939d4..767de5af 100644 --- a/frontend/lib/utils/layoutV2Converter.ts +++ b/frontend/lib/utils/layoutV2Converter.ts @@ -65,7 +65,7 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData | return { components, - gridSettings: { + gridSettings: v2Layout.gridSettings || { enabled: true, size: 20, color: "#d1d5db", @@ -75,7 +75,7 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData | gap: 16, padding: 16, }, - screenResolution: { + screenResolution: v2Layout.screenResolution || { width: 1920, height: 1080, }, diff --git a/frontend/package.json b/frontend/package.json index f3b0371d..9a43e4bc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "dev": "next dev --turbopack -p 9771", + "dev:docker": "next dev -p 3000", "build": "next build", "build:no-lint": "DISABLE_ESLINT_PLUGIN=true next build", "start": "next start",