From 5b5a0d1a23f4aa95ed1c50b3eeed04274a7475e5 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 30 Jan 2026 10:51:33 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20V2=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=B6=94=EC=B6=9C=20=EB=B0=8F=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EC=9A=B4=20V2=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V2 레이아웃에서 URL을 기반으로 컴포넌트 타입을 추출하는 헬퍼 함수를 추가하였습니다. - DynamicComponentRenderer에서 V2 레이아웃의 URL에서 컴포넌트 타입을 추출하도록 수정하였습니다. - 새로운 V2 통합 입력, 선택, 날짜 컴포넌트를 등록하여 컴포넌트 목록을 업데이트하였습니다. - 이를 통해 V2 컴포넌트의 일관성을 높이고, 레거시 타입과의 매핑을 개선하였습니다. --- .../src/services/screenManagementService.ts | 32 ++++++--- .../lib/registry/DynamicComponentRenderer.tsx | 10 ++- frontend/lib/registry/components/index.ts | 3 + .../components/v2-date/V2DateRenderer.tsx | 64 +++++++++++++++++ .../components/v2-input/V2InputRenderer.tsx | 72 +++++++++++++++++++ .../components/v2-select/V2SelectRenderer.tsx | 71 ++++++++++++++++++ 6 files changed, 240 insertions(+), 12 deletions(-) create mode 100644 frontend/lib/registry/components/v2-date/V2DateRenderer.tsx create mode 100644 frontend/lib/registry/components/v2-input/V2InputRenderer.tsx create mode 100644 frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 52ed357b..05e3afe9 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -1665,18 +1665,28 @@ export class ScreenManagementService { console.log(`V2 레이아웃 발견, V2 형식으로 반환`); const layoutData = v2Layout.layout_data; + // URL에서 컴포넌트 타입 추출하는 헬퍼 함수 + const getTypeFromUrl = (url: string | undefined): string => { + if (!url) return "component"; + const parts = url.split("/"); + return parts[parts.length - 1] || "component"; + }; + // 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, - })); + const components = (layoutData.components || []).map((comp: any) => { + const componentType = getTypeFromUrl(comp.url); + return { + id: comp.id, + type: componentType, + position: comp.position || { x: 0, y: 0, z: 1 }, + size: comp.size || { width: 200, height: 100 }, + componentUrl: comp.url, + componentType: componentType, + componentConfig: comp.overrides || {}, + displayOrder: comp.displayOrder || 0, + ...comp.overrides, + }; + }); // screenResolution이 없으면 컴포넌트 위치 기반으로 자동 계산 let screenResolution = layoutData.screenResolution; diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 23b684ac..d3c911ef 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -173,7 +173,15 @@ export const DynamicComponentRenderer: React.FC = ...props }) => { // 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용 - const rawComponentType = (component as any).componentType || component.type; + // 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input") + const extractTypeFromUrl = (url: string | undefined): string | undefined => { + if (!url) return undefined; + // url의 마지막 세그먼트를 컴포넌트 타입으로 사용 + const segments = url.split("/"); + return segments[segments.length - 1]; + }; + + const rawComponentType = (component as any).componentType || component.type || extractTypeFromUrl((component as any).url); // 레거시 타입을 v2 컴포넌트로 매핑 (v2 컴포넌트가 없으면 원본 유지) const mapToV2ComponentType = (type: string | undefined): string | undefined => { diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 8a2ac932..19f33cd1 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -106,6 +106,9 @@ import "./v2-table-search-widget"; import "./v2-tabs-widget/tabs-component"; import "./v2-category-manager/V2CategoryManagerRenderer"; import "./v2-media"; // 통합 미디어 컴포넌트 +import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트 +import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트 +import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트 /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx b/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx new file mode 100644 index 00000000..dfbbceb1 --- /dev/null +++ b/frontend/lib/registry/components/v2-date/V2DateRenderer.tsx @@ -0,0 +1,64 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { V2DateDefinition } from "./index"; +import { V2Date } from "@/components/v2/V2Date"; + +/** + * V2Date 렌더러 + * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + */ +export class V2DateRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = V2DateDefinition; + + render(): React.ReactElement { + const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props; + + // 컴포넌트 설정 추출 + const config = component.componentConfig || component.config || {}; + const columnName = component.columnName; + + // formData에서 현재 값 가져오기 + const currentValue = formData?.[columnName] ?? component.value ?? ""; + + // 값 변경 핸들러 + const handleChange = (value: any) => { + if (isInteractive && onFormDataChange && columnName) { + onFormDataChange(columnName, value); + } + }; + + return ( + + ); + } +} + +// 자동 등록 실행 +V2DateRenderer.registerSelf(); + +// Hot Reload 지원 (개발 모드) +if (process.env.NODE_ENV === "development") { + V2DateRenderer.enableHotReload(); +} diff --git a/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx new file mode 100644 index 00000000..1afc2075 --- /dev/null +++ b/frontend/lib/registry/components/v2-input/V2InputRenderer.tsx @@ -0,0 +1,72 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { V2InputDefinition } from "./index"; +import { V2Input } from "@/components/v2/V2Input"; + +/** + * V2Input 렌더러 + * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + */ +export class V2InputRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = V2InputDefinition; + + render(): React.ReactElement { + const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props; + + // 컴포넌트 설정 추출 + const config = component.componentConfig || component.config || {}; + const columnName = component.columnName; + const tableName = component.tableName || this.props.tableName; + + // formData에서 현재 값 가져오기 + const currentValue = formData?.[columnName] ?? component.value ?? ""; + + // 값 변경 핸들러 + const handleChange = (value: any) => { + if (isInteractive && onFormDataChange && columnName) { + onFormDataChange(columnName, value); + } + }; + + return ( + + ); + } +} + +// 자동 등록 실행 +V2InputRenderer.registerSelf(); + +// Hot Reload 지원 (개발 모드) +if (process.env.NODE_ENV === "development") { + V2InputRenderer.enableHotReload(); +} diff --git a/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx b/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx new file mode 100644 index 00000000..5fbdfcf7 --- /dev/null +++ b/frontend/lib/registry/components/v2-select/V2SelectRenderer.tsx @@ -0,0 +1,71 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { V2SelectDefinition } from "./index"; +import { V2Select } from "@/components/v2/V2Select"; + +/** + * V2Select 렌더러 + * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + */ +export class V2SelectRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = V2SelectDefinition; + + render(): React.ReactElement { + const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props; + + // 컴포넌트 설정 추출 + const config = component.componentConfig || component.config || {}; + const columnName = component.columnName; + const tableName = component.tableName || this.props.tableName; + + // formData에서 현재 값 가져오기 + const currentValue = formData?.[columnName] ?? component.value ?? ""; + + // 값 변경 핸들러 + const handleChange = (value: any) => { + if (isInteractive && onFormDataChange && columnName) { + onFormDataChange(columnName, value); + } + }; + + return ( + + ); + } +} + +// 자동 등록 실행 +V2SelectRenderer.registerSelf(); + +// Hot Reload 지원 (개발 모드) +if (process.env.NODE_ENV === "development") { + V2SelectRenderer.enableHotReload(); +} From 852de0fb0e61316f32057d66c7968eeb9fd0a369 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Fri, 30 Jan 2026 13:38:07 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20V2=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20=EC=83=88=EB=A1=9C=EC=9A=B4=20V2=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 레이아웃 처리 로직을 V2 레이아웃에 맞게 수정하였습니다. - V2 레이아웃에서 layout_data를 조회하고, 변경 여부를 확인하는 로직을 추가하였습니다. - 레이아웃 데이터의 참조 ID 업데이트 및 flowId, numberingRuleId 수집 기능을 구현하였습니다. - V2Media 컴포넌트를 통합하여 미디어 관련 기능을 강화하였습니다. - 레이아웃 처리 시 V2 레이아웃의 컴포넌트 매핑 및 데이터 복사를 효율적으로 처리하도록 개선하였습니다. --- backend-node/src/services/menuCopyService.ts | 211 +++--- .../src/services/screenManagementService.ts | 499 +++++++++++--- .../RESPONSIVE_GRID_SYSTEM_ARCHITECTURE.md | 644 ++++++++++++++++++ docs/SCREEN_COPY_V2_MIGRATION_PLAN.md | 524 ++++++++++++++ docs/V2_COMPONENT_MIGRATION_ANALYSIS.md | 356 ++++++++++ frontend/lib/registry/components/index.ts | 2 +- .../components/v2-media/V2MediaRenderer.tsx | 109 +++ 7 files changed, 2176 insertions(+), 169 deletions(-) create mode 100644 docs/DDD1542/RESPONSIVE_GRID_SYSTEM_ARCHITECTURE.md create mode 100644 docs/SCREEN_COPY_V2_MIGRATION_PLAN.md create mode 100644 docs/V2_COMPONENT_MIGRATION_ANALYSIS.md create mode 100644 frontend/lib/registry/components/v2-media/V2MediaRenderer.tsx diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 1980a82c..439ccaae 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -1556,22 +1556,22 @@ export class MenuCopyService { // === 기존 복사본이 있는 경우: 업데이트 === const existingScreenId = existingCopy.screen_id; - // 원본 레이아웃 조회 - const sourceLayoutsResult = await client.query( - `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, + // 원본 V2 레이아웃 조회 + const sourceLayoutV2Result = await client.query<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`, [originalScreenId] ); - // 대상 레이아웃 조회 - const targetLayoutsResult = await client.query( - `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, + // 대상 V2 레이아웃 조회 + const targetLayoutV2Result = await client.query<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`, [existingScreenId] ); - // 변경 여부 확인 (레이아웃 개수 또는 내용 비교) - const hasChanges = this.hasLayoutChanges( - sourceLayoutsResult.rows, - targetLayoutsResult.rows + // 변경 여부 확인 (V2 레이아웃 비교) + const hasChanges = this.hasLayoutChangesV2( + sourceLayoutV2Result.rows[0]?.layout_data, + targetLayoutV2Result.rows[0]?.layout_data ); if (hasChanges) { @@ -1673,9 +1673,9 @@ export class MenuCopyService { } } - // === 2단계: screen_layouts 처리 (이제 screenIdMap이 완성됨) === + // === 2단계: screen_layouts_v2 처리 (이제 screenIdMap이 완성됨) === logger.info( - `\n📐 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)` + `\n📐 V2 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)` ); for (const { @@ -1685,91 +1685,51 @@ export class MenuCopyService { isUpdate, } of screenDefsToProcess) { try { - // 원본 레이아웃 조회 - const layoutsResult = await client.query( - `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, + // 원본 V2 레이아웃 조회 + const layoutV2Result = await client.query<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = $1`, [originalScreenId] ); - if (isUpdate) { - // 업데이트: 기존 레이아웃 삭제 후 새로 삽입 - await client.query( - `DELETE FROM screen_layouts WHERE screen_id = $1`, - [targetScreenId] + const layoutData = layoutV2Result.rows[0]?.layout_data; + const components = layoutData?.components || []; + + if (layoutData && components.length > 0) { + // component_id 매핑 생성 (원본 → 새 ID) + const componentIdMap = new Map(); + const timestamp = Date.now(); + components.forEach((comp: any, idx: number) => { + const newComponentId = `comp_${timestamp}_${idx}_${Math.random().toString(36).substr(2, 5)}`; + componentIdMap.set(comp.id, newComponentId); + }); + + // V2 레이아웃 데이터 복사 및 참조 업데이트 + const updatedLayoutData = this.updateReferencesInLayoutDataV2( + layoutData, + componentIdMap, + screenIdMap, + flowIdMap, + numberingRuleIdMap, + menuIdMap ); - logger.info(` ↳ 기존 레이아웃 삭제 (업데이트 준비)`); - } - // component_id 매핑 생성 (원본 → 새 ID) - const componentIdMap = new Map(); - const timestamp = Date.now(); - layoutsResult.rows.forEach((layout, idx) => { - const newComponentId = `comp_${timestamp}_${idx}_${Math.random().toString(36).substr(2, 5)}`; - componentIdMap.set(layout.component_id, newComponentId); - }); - - // 레이아웃 배치 삽입 준비 - if (layoutsResult.rows.length > 0) { - const layoutValues: string[] = []; - const layoutParams: any[] = []; - let paramIdx = 1; - - for (const layout of layoutsResult.rows) { - const newComponentId = componentIdMap.get(layout.component_id)!; - - const newParentId = layout.parent_id - ? componentIdMap.get(layout.parent_id) || layout.parent_id - : null; - const newZoneId = layout.zone_id - ? componentIdMap.get(layout.zone_id) || layout.zone_id - : null; - - const updatedProperties = this.updateReferencesInProperties( - layout.properties, - screenIdMap, - flowIdMap, - numberingRuleIdMap, - menuIdMap - ); - - layoutValues.push( - `($${paramIdx}, $${paramIdx + 1}, $${paramIdx + 2}, $${paramIdx + 3}, $${paramIdx + 4}, $${paramIdx + 5}, $${paramIdx + 6}, $${paramIdx + 7}, $${paramIdx + 8}, $${paramIdx + 9}, $${paramIdx + 10}, $${paramIdx + 11}, $${paramIdx + 12}, $${paramIdx + 13})` - ); - layoutParams.push( - targetScreenId, - layout.component_type, - newComponentId, - newParentId, - layout.position_x, - layout.position_y, - layout.width, - layout.height, - updatedProperties, - layout.display_order, - layout.layout_type, - layout.layout_config, - layout.zones_config, - newZoneId - ); - paramIdx += 14; - } - - // 배치 INSERT + // V2 레이아웃 저장 (UPSERT) await client.query( - `INSERT INTO screen_layouts ( - screen_id, component_type, component_id, parent_id, - position_x, position_y, width, height, properties, - display_order, layout_type, layout_config, zones_config, zone_id - ) VALUES ${layoutValues.join(", ")}`, - layoutParams + `INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at) + VALUES ($1, $2, $3, NOW(), NOW()) + ON CONFLICT (screen_id, company_code) + DO UPDATE SET layout_data = $3, updated_at = NOW()`, + [targetScreenId, targetCompanyCode, JSON.stringify(updatedLayoutData)] ); - } - const action = isUpdate ? "업데이트" : "복사"; - logger.info(` ↳ 레이아웃 ${action}: ${layoutsResult.rows.length}개`); + const action = isUpdate ? "업데이트" : "복사"; + logger.info(` ↳ V2 레이아웃 ${action}: ${components.length}개 컴포넌트`); + } else { + logger.info(` ↳ V2 레이아웃 없음 (스킵): screen_id=${originalScreenId}`); + } } catch (error: any) { logger.error( - `❌ 레이아웃 처리 실패: screen_id=${originalScreenId}`, + `❌ V2 레이아웃 처리 실패: screen_id=${originalScreenId}`, error ); throw error; @@ -1835,6 +1795,83 @@ export class MenuCopyService { return false; } + /** + * V2 레이아웃 변경 여부 확인 (screen_layouts_v2용) + */ + private hasLayoutChangesV2( + sourceLayoutData: any, + targetLayoutData: any + ): boolean { + // 1. 둘 다 없으면 변경 없음 + if (!sourceLayoutData && !targetLayoutData) return false; + + // 2. 하나만 있으면 변경됨 + if (!sourceLayoutData || !targetLayoutData) return true; + + // 3. components 배열 비교 + const sourceComps = sourceLayoutData.components || []; + const targetComps = targetLayoutData.components || []; + + if (sourceComps.length !== targetComps.length) return true; + + // 4. 각 컴포넌트 비교 (url, position, size, overrides) + for (let i = 0; i < sourceComps.length; i++) { + const s = sourceComps[i]; + const t = targetComps[i]; + + if (s.url !== t.url) return true; + if (JSON.stringify(s.position) !== JSON.stringify(t.position)) return true; + if (JSON.stringify(s.size) !== JSON.stringify(t.size)) return true; + if (JSON.stringify(s.overrides) !== JSON.stringify(t.overrides)) return true; + } + + return false; + } + + /** + * V2 레이아웃 데이터의 참조 ID들을 업데이트 (componentId, flowId, ruleId, screenId, menuId) + */ + private updateReferencesInLayoutDataV2( + layoutData: any, + componentIdMap: Map, + screenIdMap: Map, + flowIdMap: Map, + numberingRuleIdMap?: Map, + menuIdMap?: Map + ): any { + if (!layoutData?.components) return layoutData; + + const updatedComponents = layoutData.components.map((comp: any) => { + // 1. componentId 매핑 + const newId = componentIdMap.get(comp.id) || comp.id; + + // 2. overrides 복사 및 재귀적 참조 업데이트 + let overrides = JSON.parse(JSON.stringify(comp.overrides || {})); + + // 재귀적으로 모든 참조 업데이트 + this.recursiveUpdateReferences( + overrides, + screenIdMap, + flowIdMap, + "", + numberingRuleIdMap, + menuIdMap + ); + + return { + ...comp, + id: newId, + overrides, + }; + }); + + return { + ...layoutData, + components: updatedComponents, + updatedAt: new Date().toISOString(), + }; + } + /** * 메뉴 위상 정렬 (부모 먼저) */ diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 05e3afe9..f69c133b 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -3481,6 +3481,371 @@ export class ScreenManagementService { return flowIds; } + /** + * V2 레이아웃에서 flowId 수집 (screen_layouts_v2용) + * - overrides.flowId (flow-widget) + * - overrides.webTypeConfig.dataflowConfig.flowConfig.flowId (버튼) + * - overrides.webTypeConfig.dataflowConfig.flowControls[].flowId + * - overrides.action.excelAfterUploadFlows[].flowId + */ + private collectFlowIdsFromLayoutData(layoutData: any): Set { + const flowIds = new Set(); + if (!layoutData?.components) return flowIds; + + for (const comp of layoutData.components) { + const overrides = comp.overrides || {}; + + // 1. overrides.flowId (flow-widget 등) + if (overrides.flowId && !isNaN(parseInt(overrides.flowId))) { + flowIds.add(parseInt(overrides.flowId)); + } + + // 2. webTypeConfig.dataflowConfig.flowConfig.flowId (버튼) + const flowConfigId = overrides?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; + if (flowConfigId && !isNaN(parseInt(flowConfigId))) { + flowIds.add(parseInt(flowConfigId)); + } + + // 3. webTypeConfig.dataflowConfig.selectedDiagramId + const diagramId = overrides?.webTypeConfig?.dataflowConfig?.selectedDiagramId; + if (diagramId && !isNaN(parseInt(diagramId))) { + flowIds.add(parseInt(diagramId)); + } + + // 4. webTypeConfig.dataflowConfig.flowControls[].flowId + const flowControls = overrides?.webTypeConfig?.dataflowConfig?.flowControls; + if (Array.isArray(flowControls)) { + for (const control of flowControls) { + if (control?.flowId && !isNaN(parseInt(control.flowId))) { + flowIds.add(parseInt(control.flowId)); + } + } + } + + // 5. action.excelAfterUploadFlows[].flowId + const excelFlows = overrides?.action?.excelAfterUploadFlows; + if (Array.isArray(excelFlows)) { + for (const flow of excelFlows) { + if (flow?.flowId && !isNaN(parseInt(flow.flowId))) { + flowIds.add(parseInt(flow.flowId)); + } + } + } + } + + return flowIds; + } + + /** + * V2 레이아웃에서 numberingRuleId 수집 (screen_layouts_v2용) + * - overrides.autoGeneration.options.numberingRuleId + * - overrides.sections[].fields[].numberingRule.ruleId + * - overrides.action.excelNumberingRuleId + * - overrides.action.numberingRuleId + */ + private collectNumberingRuleIdsFromLayoutData(layoutData: any): Set { + const ruleIds = new Set(); + if (!layoutData?.components) return ruleIds; + + for (const comp of layoutData.components) { + const overrides = comp.overrides || {}; + + // 1. autoGeneration.options.numberingRuleId + const autoGenRuleId = overrides?.autoGeneration?.options?.numberingRuleId; + if (autoGenRuleId && typeof autoGenRuleId === "string" && autoGenRuleId.startsWith("rule-")) { + ruleIds.add(autoGenRuleId); + } + + // 2. sections[].fields[].numberingRule.ruleId + const sections = overrides?.sections; + if (Array.isArray(sections)) { + for (const section of sections) { + const fields = section?.fields; + if (Array.isArray(fields)) { + for (const field of fields) { + const ruleId = field?.numberingRule?.ruleId; + if (ruleId && typeof ruleId === "string" && ruleId.startsWith("rule-")) { + ruleIds.add(ruleId); + } + } + } + // optionalFieldGroups 내부 + const optGroups = section?.optionalFieldGroups; + if (Array.isArray(optGroups)) { + for (const optGroup of optGroups) { + const optFields = optGroup?.fields; + if (Array.isArray(optFields)) { + for (const field of optFields) { + const ruleId = field?.numberingRule?.ruleId; + if (ruleId && typeof ruleId === "string" && ruleId.startsWith("rule-")) { + ruleIds.add(ruleId); + } + } + } + } + } + } + } + + // 3. action.excelNumberingRuleId + const excelRuleId = overrides?.action?.excelNumberingRuleId; + if (excelRuleId && typeof excelRuleId === "string" && excelRuleId.startsWith("rule-")) { + ruleIds.add(excelRuleId); + } + + // 4. action.numberingRuleId + const actionRuleId = overrides?.action?.numberingRuleId; + if (actionRuleId && typeof actionRuleId === "string" && actionRuleId.startsWith("rule-")) { + ruleIds.add(actionRuleId); + } + } + + return ruleIds; + } + + /** + * V2 레이아웃 데이터의 참조 ID들을 업데이트 + * - componentId, flowId, numberingRuleId, screenId 매핑 적용 + */ + private updateReferencesInLayoutData( + layoutData: any, + mappings: { + componentIdMap: Map; + flowIdMap?: Map; + ruleIdMap?: Map; + screenIdMap?: Map; + }, + ): any { + if (!layoutData?.components) return layoutData; + + const updatedComponents = layoutData.components.map((comp: any) => { + // 1. componentId 매핑 + const newId = mappings.componentIdMap.get(comp.id) || comp.id; + + // 2. overrides 복사 및 참조 업데이트 + let overrides = JSON.parse(JSON.stringify(comp.overrides || {})); + + // flowId 매핑 + if (mappings.flowIdMap && mappings.flowIdMap.size > 0) { + overrides = this.updateFlowIdsInOverrides(overrides, mappings.flowIdMap); + } + + // numberingRuleId 매핑 + if (mappings.ruleIdMap && mappings.ruleIdMap.size > 0) { + overrides = this.updateNumberingRuleIdsInOverrides(overrides, mappings.ruleIdMap); + } + + // screenId 매핑 (탭, 버튼 등) + if (mappings.screenIdMap && mappings.screenIdMap.size > 0) { + overrides = this.updateScreenIdsInOverrides(overrides, mappings.screenIdMap); + } + + return { + ...comp, + id: newId, + overrides, + }; + }); + + return { + ...layoutData, + components: updatedComponents, + updatedAt: new Date().toISOString(), + }; + } + + /** + * V2 overrides 내의 flowId 업데이트 + */ + private updateFlowIdsInOverrides( + overrides: any, + flowIdMap: Map, + ): any { + if (!overrides || flowIdMap.size === 0) return overrides; + + // 1. overrides.flowId (flow-widget) + if (overrides.flowId) { + const oldId = parseInt(overrides.flowId); + const newId = flowIdMap.get(oldId); + if (newId) { + overrides.flowId = newId; + console.log(` 🔗 flowId: ${oldId} → ${newId}`); + } + } + + // 2. webTypeConfig.dataflowConfig.flowConfig.flowId + if (overrides?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId) { + const oldId = parseInt(overrides.webTypeConfig.dataflowConfig.flowConfig.flowId); + const newId = flowIdMap.get(oldId); + if (newId) { + overrides.webTypeConfig.dataflowConfig.flowConfig.flowId = newId; + console.log(` 🔗 flowConfig.flowId: ${oldId} → ${newId}`); + } + } + + // 3. webTypeConfig.dataflowConfig.selectedDiagramId + if (overrides?.webTypeConfig?.dataflowConfig?.selectedDiagramId) { + const oldId = parseInt(overrides.webTypeConfig.dataflowConfig.selectedDiagramId); + const newId = flowIdMap.get(oldId); + if (newId) { + overrides.webTypeConfig.dataflowConfig.selectedDiagramId = newId; + console.log(` 🔗 selectedDiagramId: ${oldId} → ${newId}`); + } + } + + // 4. webTypeConfig.dataflowConfig.flowControls[] + if (Array.isArray(overrides?.webTypeConfig?.dataflowConfig?.flowControls)) { + for (const control of overrides.webTypeConfig.dataflowConfig.flowControls) { + if (control?.flowId) { + const oldId = parseInt(control.flowId); + const newId = flowIdMap.get(oldId); + if (newId) { + control.flowId = newId; + console.log(` 🔗 flowControls.flowId: ${oldId} → ${newId}`); + } + } + } + } + + // 5. action.excelAfterUploadFlows[] + if (Array.isArray(overrides?.action?.excelAfterUploadFlows)) { + for (const flow of overrides.action.excelAfterUploadFlows) { + if (flow?.flowId) { + const oldId = parseInt(flow.flowId); + const newId = flowIdMap.get(oldId); + if (newId) { + flow.flowId = newId; + console.log(` 🔗 excelAfterUploadFlows.flowId: ${oldId} → ${newId}`); + } + } + } + } + + return overrides; + } + + /** + * V2 overrides 내의 numberingRuleId 업데이트 + */ + private updateNumberingRuleIdsInOverrides( + overrides: any, + ruleIdMap: Map, + ): any { + if (!overrides || ruleIdMap.size === 0) return overrides; + + // 1. autoGeneration.options.numberingRuleId + if (overrides?.autoGeneration?.options?.numberingRuleId) { + const oldId = overrides.autoGeneration.options.numberingRuleId; + const newId = ruleIdMap.get(oldId); + if (newId) { + overrides.autoGeneration.options.numberingRuleId = newId; + console.log(` 🔗 autoGeneration.numberingRuleId: ${oldId} → ${newId}`); + } + } + + // 2. sections[].fields[].numberingRule.ruleId + if (Array.isArray(overrides?.sections)) { + for (const section of overrides.sections) { + if (Array.isArray(section?.fields)) { + for (const field of section.fields) { + if (field?.numberingRule?.ruleId) { + const oldId = field.numberingRule.ruleId; + const newId = ruleIdMap.get(oldId); + if (newId) { + field.numberingRule.ruleId = newId; + console.log(` 🔗 field.numberingRule.ruleId: ${oldId} → ${newId}`); + } + } + } + } + if (Array.isArray(section?.optionalFieldGroups)) { + for (const optGroup of section.optionalFieldGroups) { + if (Array.isArray(optGroup?.fields)) { + for (const field of optGroup.fields) { + if (field?.numberingRule?.ruleId) { + const oldId = field.numberingRule.ruleId; + const newId = ruleIdMap.get(oldId); + if (newId) { + field.numberingRule.ruleId = newId; + console.log(` 🔗 optField.numberingRule.ruleId: ${oldId} → ${newId}`); + } + } + } + } + } + } + } + } + + // 3. action.excelNumberingRuleId + if (overrides?.action?.excelNumberingRuleId) { + const oldId = overrides.action.excelNumberingRuleId; + const newId = ruleIdMap.get(oldId); + if (newId) { + overrides.action.excelNumberingRuleId = newId; + console.log(` 🔗 excelNumberingRuleId: ${oldId} → ${newId}`); + } + } + + // 4. action.numberingRuleId + if (overrides?.action?.numberingRuleId) { + const oldId = overrides.action.numberingRuleId; + const newId = ruleIdMap.get(oldId); + if (newId) { + overrides.action.numberingRuleId = newId; + console.log(` 🔗 action.numberingRuleId: ${oldId} → ${newId}`); + } + } + + return overrides; + } + + /** + * V2 overrides 내의 screenId 업데이트 (탭, 버튼 등) + */ + private updateScreenIdsInOverrides( + overrides: any, + screenIdMap: Map, + ): any { + if (!overrides || screenIdMap.size === 0) return overrides; + + // 1. tabs[].screenId (탭 위젯) + if (Array.isArray(overrides?.tabs)) { + for (const tab of overrides.tabs) { + if (tab?.screenId) { + const oldId = parseInt(tab.screenId); + const newId = screenIdMap.get(oldId); + if (newId) { + tab.screenId = newId; + console.log(` 🔗 tab.screenId: ${oldId} → ${newId}`); + } + } + } + } + + // 2. action.targetScreenId (버튼) + if (overrides?.action?.targetScreenId) { + const oldId = parseInt(overrides.action.targetScreenId); + const newId = screenIdMap.get(oldId); + if (newId) { + overrides.action.targetScreenId = newId; + console.log(` 🔗 action.targetScreenId: ${oldId} → ${newId}`); + } + } + + // 3. action.modalScreenId + if (overrides?.action?.modalScreenId) { + const oldId = parseInt(overrides.action.modalScreenId); + const newId = screenIdMap.get(oldId); + if (newId) { + overrides.action.modalScreenId = newId; + console.log(` 🔗 action.modalScreenId: ${oldId} → ${newId}`); + } + } + + return overrides; + } + /** * 노드 플로우 복사 및 ID 매핑 반환 * - 원본 회사의 플로우를 대상 회사로 복사 @@ -3719,24 +4084,34 @@ export class ScreenManagementService { const newScreen = newScreenResult.rows[0]; - // 4. 원본 화면의 레이아웃 정보 조회 - const sourceLayoutsResult = await client.query( - `SELECT * FROM screen_layouts - WHERE screen_id = $1 - ORDER BY display_order ASC NULLS LAST`, - [sourceScreenId], + // 4. 원본 화면의 V2 레이아웃 조회 + let sourceLayoutV2Result = await client.query<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2`, + [sourceScreenId, sourceScreen.company_code], ); - const sourceLayouts = sourceLayoutsResult.rows; + // 없으면 공통(*) 레이아웃 조회 + let layoutData = sourceLayoutV2Result.rows[0]?.layout_data; + if (!layoutData && sourceScreen.company_code !== "*") { + const fallbackResult = await client.query<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = '*'`, + [sourceScreenId], + ); + layoutData = fallbackResult.rows[0]?.layout_data; + } + + const components = layoutData?.components || []; // 5. 노드 플로우 복사 (회사가 다른 경우) let flowIdMap = new Map(); if ( - sourceLayouts.length > 0 && + components.length > 0 && sourceScreen.company_code !== targetCompanyCode ) { - // 레이아웃에서 사용하는 flowId 수집 - const flowIds = this.collectFlowIdsFromLayouts(sourceLayouts); + // V2 레이아웃에서 flowId 수집 + const flowIds = this.collectFlowIdsFromLayoutData(layoutData); if (flowIds.size > 0) { console.log(`🔍 화면 복사 - flowId 수집: ${flowIds.size}개`); @@ -3754,11 +4129,11 @@ export class ScreenManagementService { // 5.1. 채번 규칙 복사 (회사가 다른 경우) let ruleIdMap = new Map(); if ( - sourceLayouts.length > 0 && + components.length > 0 && sourceScreen.company_code !== targetCompanyCode ) { - // 레이아웃에서 사용하는 채번 규칙 ID 수집 - const ruleIds = this.collectNumberingRuleIdsFromLayouts(sourceLayouts); + // V2 레이아웃에서 채번 규칙 ID 수집 + const ruleIds = this.collectNumberingRuleIdsFromLayoutData(layoutData); if (ruleIds.size > 0) { console.log(`🔍 화면 복사 - 채번 규칙 ID 수집: ${ruleIds.size}개`); @@ -3773,81 +4148,43 @@ export class ScreenManagementService { } } - // 6. 레이아웃이 있다면 복사 - if (sourceLayouts.length > 0) { + // 6. V2 레이아웃이 있다면 복사 + if (layoutData && components.length > 0) { try { - // ID 매핑 맵 생성 - const idMapping: { [oldId: string]: string } = {}; - - // 새로운 컴포넌트 ID 미리 생성 - sourceLayouts.forEach((layout: any) => { - idMapping[layout.component_id] = generateId(); - }); - - // 각 레이아웃 컴포넌트 복사 - for (const sourceLayout of sourceLayouts) { - const newComponentId = idMapping[sourceLayout.component_id]; - const newParentId = sourceLayout.parent_id - ? idMapping[sourceLayout.parent_id] - : null; - - // properties 파싱 - let properties = sourceLayout.properties; - if (typeof properties === "string") { - try { - properties = JSON.parse(properties); - } catch (e) { - // 파싱 실패 시 그대로 사용 - } - } - - // flowId 매핑 적용 (회사가 다른 경우) - if (flowIdMap.size > 0) { - properties = this.updateFlowIdsInProperties( - properties, - flowIdMap, - ); - } - - // 채번 규칙 ID 매핑 적용 (회사가 다른 경우) - if (ruleIdMap.size > 0) { - properties = this.updateNumberingRuleIdsInProperties( - properties, - ruleIdMap, - ); - } - - // 탭 컴포넌트의 screenId는 개별 복제 시점에 업데이트하지 않음 - // 모든 화면 복제 완료 후 updateTabScreenReferences에서 screenIdMap 기반으로 일괄 업데이트 - - await client.query( - `INSERT INTO screen_layouts ( - screen_id, component_type, component_id, parent_id, - position_x, position_y, width, height, properties, - display_order, created_date - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, - [ - newScreen.screen_id, - sourceLayout.component_type, - newComponentId, - newParentId, - Math.round(sourceLayout.position_x), // 정수로 반올림 - Math.round(sourceLayout.position_y), // 정수로 반올림 - Math.round(sourceLayout.width), // 정수로 반올림 - Math.round(sourceLayout.height), // 정수로 반올림 - JSON.stringify(properties), - sourceLayout.display_order, - new Date(), - ], - ); + // componentId 매핑 생성 + const componentIdMap = new Map(); + for (const comp of components) { + componentIdMap.set(comp.id, generateId()); } + + // V2 레이아웃 데이터 복사 및 참조 업데이트 + const updatedLayoutData = this.updateReferencesInLayoutData( + layoutData, + { + componentIdMap, + flowIdMap: flowIdMap.size > 0 ? flowIdMap : undefined, + ruleIdMap: ruleIdMap.size > 0 ? ruleIdMap : undefined, + // screenIdMap은 모든 화면 복제 완료 후 updateTabScreenReferences에서 일괄 처리 + }, + ); + + // V2 레이아웃 저장 (UPSERT) + await client.query( + `INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at) + VALUES ($1, $2, $3, NOW(), NOW()) + ON CONFLICT (screen_id, company_code) + DO UPDATE SET layout_data = $3, updated_at = NOW()`, + [newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)], + ); + + console.log(` ✅ V2 레이아웃 복사 완료: ${components.length}개 컴포넌트`); } catch (error) { - console.error("레이아웃 복사 중 오류:", error); + console.error("V2 레이아웃 복사 중 오류:", error); // 레이아웃 복사 실패해도 화면 생성은 유지 } } - // 6. 생성된 화면 정보 반환 + // 7. 생성된 화면 정보 반환 return { screenId: newScreen.screen_id, screenCode: newScreen.screen_code, diff --git a/docs/DDD1542/RESPONSIVE_GRID_SYSTEM_ARCHITECTURE.md b/docs/DDD1542/RESPONSIVE_GRID_SYSTEM_ARCHITECTURE.md new file mode 100644 index 00000000..9c461046 --- /dev/null +++ b/docs/DDD1542/RESPONSIVE_GRID_SYSTEM_ARCHITECTURE.md @@ -0,0 +1,644 @@ +# 반응형 그리드 시스템 아키텍처 + +> 최종 업데이트: 2026-01-30 + +## 1. 개요 + +### 1.1 문제 정의 + +**현재 상황**: 컴포넌트 위치/크기가 픽셀 단위로 고정되어 반응형 미지원 + +```json +// 현재 저장 방식 (screen_layouts_v2.layout_data) +{ + "position": { "x": 1753, "y": 88 }, + "size": { "width": 158, "height": 40 } +} +``` + +**발생 문제**: +- 1920px 기준 설계 → 1280px 화면에서 버튼이 화면 밖으로 나감 +- 모바일/태블릿에서 레이아웃 완전히 깨짐 +- 화면 축소해도 컴포넌트 위치/크기 그대로 + +### 1.2 목표 + +| 목표 | 설명 | +|------|------| +| **PC 대응** | 1280px ~ 1920px 화면에서 정상 동작 | +| **태블릿 대응** | 768px ~ 1024px 화면에서 레이아웃 재배치 | +| **모바일 대응** | 320px ~ 767px 화면에서 세로 스택 | +| **shadcn/Tailwind 활용** | 반응형 브레이크포인트 시스템 사용 | + +### 1.3 핵심 원칙 + +``` +현재: 픽셀 좌표 → position: absolute → 고정 레이아웃 +변경: 그리드 셀 번호 → CSS Grid + Tailwind → 반응형 레이아웃 +``` + +--- + +## 2. 현재 시스템 분석 + +### 2.1 기존 그리드 설정 (이미 존재) + +```typescript +// frontend/components/screen/ScreenDesigner.tsx +gridSettings: { + columns: 12, // ✅ 이미 12컬럼 그리드 있음 + gap: 16, // ✅ 간격 설정 있음 + padding: 0, + snapToGrid: true, // ✅ 스냅 기능 있음 + showGrid: false, + gridColor: "#d1d5db", + gridOpacity: 0.5, +} +``` + +### 2.2 현재 저장 방식 + +```typescript +// 드래그 후 저장되는 데이터 +{ + "id": "comp_1896", + "url": "@/lib/registry/components/v2-button-primary", + "position": { "x": 1753.33, "y": 88, "z": 1 }, // 픽셀 좌표 + "size": { "width": 158.67, "height": 40 }, // 픽셀 크기 + "overrides": { ... } +} +``` + +### 2.3 현재 렌더링 방식 + +```tsx +// frontend/lib/registry/layouts/flexbox/FlexboxLayout.tsx (라인 234-248) +
+``` + +### 2.4 문제점 요약 + +| 현재 | 문제 | +|------|------| +| 12컬럼 그리드 있음 | 스냅용으로만 사용, 저장은 픽셀 | +| position: 픽셀 좌표 | 화면 크기 변해도 위치 고정 | +| size: 픽셀 크기 | 화면 작아지면 넘침 | +| absolute 포지션 | 반응형 불가 | + +--- + +## 3. 신규 데이터 구조 + +### 3.1 layout_data 구조 변경 + +**현재 구조**: +```json +{ + "version": "2.0", + "components": [{ + "id": "comp_xxx", + "url": "@/lib/registry/components/v2-button-primary", + "position": { "x": 1753, "y": 88, "z": 1 }, + "size": { "width": 158, "height": 40 }, + "overrides": { ... } + }] +} +``` + +**변경 후 구조**: +```json +{ + "version": "3.0", + "layoutMode": "grid", + "components": [{ + "id": "comp_xxx", + "url": "@/lib/registry/components/v2-button-primary", + "grid": { + "col": 11, + "row": 2, + "colSpan": 2, + "rowSpan": 1 + }, + "responsive": { + "sm": { "col": 1, "colSpan": 12 }, + "md": { "col": 7, "colSpan": 6 }, + "lg": { "col": 11, "colSpan": 2 } + }, + "overrides": { ... } + }], + "gridSettings": { + "columns": 12, + "rowHeight": 80, + "gap": 16 + } +} +``` + +### 3.2 필드 설명 + +| 필드 | 타입 | 설명 | +|------|------|------| +| `version` | string | "3.0" (반응형 그리드 버전) | +| `layoutMode` | string | "grid" (그리드 레이아웃 사용) | +| `grid.col` | number | 시작 컬럼 (1-12) | +| `grid.row` | number | 시작 행 (1부터) | +| `grid.colSpan` | number | 차지하는 컬럼 수 | +| `grid.rowSpan` | number | 차지하는 행 수 | +| `responsive.sm` | object | 모바일 (< 768px) 설정 | +| `responsive.md` | object | 태블릿 (768px ~ 1024px) 설정 | +| `responsive.lg` | object | 데스크톱 (> 1024px) 설정 | + +### 3.3 반응형 브레이크포인트 + +| 브레이크포인트 | 화면 크기 | 기본 동작 | +|----------------|-----------|-----------| +| `sm` | < 768px | 모든 컴포넌트 12컬럼 (세로 스택) | +| `md` | 768px ~ 1024px | 컬럼 수 2배로 확장 | +| `lg` | > 1024px | 원본 그리드 위치 유지 | + +--- + +## 4. 변환 로직 + +### 4.1 픽셀 → 그리드 변환 함수 + +```typescript +// frontend/lib/utils/gridConverter.ts + +const DESIGN_WIDTH = 1920; +const COLUMNS = 12; +const COLUMN_WIDTH = DESIGN_WIDTH / COLUMNS; // 160px +const ROW_HEIGHT = 80; + +interface PixelPosition { + x: number; + y: number; +} + +interface PixelSize { + width: number; + height: number; +} + +interface GridPosition { + col: number; + row: number; + colSpan: number; + rowSpan: number; +} + +interface ResponsiveConfig { + sm: { col: number; colSpan: number }; + md: { col: number; colSpan: number }; + lg: { col: number; colSpan: number }; +} + +/** + * 픽셀 좌표를 그리드 셀 번호로 변환 + */ +export function pixelToGrid( + position: PixelPosition, + size: PixelSize +): GridPosition { + // 컬럼 계산 (1-based) + const col = Math.max(1, Math.min(12, Math.round(position.x / COLUMN_WIDTH) + 1)); + + // 행 계산 (1-based) + const row = Math.max(1, Math.round(position.y / ROW_HEIGHT) + 1); + + // 컬럼 스팬 계산 + const colSpan = Math.max(1, Math.min(12 - col + 1, Math.round(size.width / COLUMN_WIDTH))); + + // 행 스팬 계산 + const rowSpan = Math.max(1, Math.round(size.height / ROW_HEIGHT)); + + return { col, row, colSpan, rowSpan }; +} + +/** + * 그리드 셀 번호를 픽셀 좌표로 변환 (디자인 모드용) + */ +export function gridToPixel( + grid: GridPosition +): { position: PixelPosition; size: PixelSize } { + return { + position: { + x: (grid.col - 1) * COLUMN_WIDTH, + y: (grid.row - 1) * ROW_HEIGHT, + }, + size: { + width: grid.colSpan * COLUMN_WIDTH, + height: grid.rowSpan * ROW_HEIGHT, + }, + }; +} + +/** + * 기본 반응형 설정 생성 + */ +export function getDefaultResponsive( + grid: GridPosition +): ResponsiveConfig { + return { + // 모바일: 전체 너비, 원래 순서대로 스택 + sm: { + col: 1, + colSpan: 12 + }, + // 태블릿: 컬럼 스팬 2배 (최대 12) + md: { + col: Math.max(1, Math.round((grid.col - 1) / 2) + 1), + colSpan: Math.min(grid.colSpan * 2, 12) + }, + // 데스크톱: 원본 유지 + lg: { + col: grid.col, + colSpan: grid.colSpan + }, + }; +} +``` + +### 4.2 Tailwind 클래스 생성 함수 + +```typescript +// frontend/lib/utils/gridClassGenerator.ts + +/** + * 그리드 위치/크기를 Tailwind 클래스로 변환 + */ +export function generateGridClasses( + grid: GridPosition, + responsive: ResponsiveConfig +): string { + const classes: string[] = []; + + // 모바일 (기본) + classes.push(`col-start-${responsive.sm.col}`); + classes.push(`col-span-${responsive.sm.colSpan}`); + + // 태블릿 + classes.push(`md:col-start-${responsive.md.col}`); + classes.push(`md:col-span-${responsive.md.colSpan}`); + + // 데스크톱 + classes.push(`lg:col-start-${responsive.lg.col}`); + classes.push(`lg:col-span-${responsive.lg.colSpan}`); + + return classes.join(' '); +} +``` + +**주의**: Tailwind는 빌드 타임에 클래스를 결정하므로, 동적 클래스 생성 시 safelist 설정 필요 + +```javascript +// tailwind.config.js +module.exports = { + safelist: [ + // 그리드 컬럼 시작 + { pattern: /col-start-(1|2|3|4|5|6|7|8|9|10|11|12)/ }, + { pattern: /md:col-start-(1|2|3|4|5|6|7|8|9|10|11|12)/ }, + { pattern: /lg:col-start-(1|2|3|4|5|6|7|8|9|10|11|12)/ }, + // 그리드 컬럼 스팬 + { pattern: /col-span-(1|2|3|4|5|6|7|8|9|10|11|12)/ }, + { pattern: /md:col-span-(1|2|3|4|5|6|7|8|9|10|11|12)/ }, + { pattern: /lg:col-span-(1|2|3|4|5|6|7|8|9|10|11|12)/ }, + ], +} +``` + +--- + +## 5. 렌더링 컴포넌트 수정 + +### 5.1 ResponsiveGridLayout 컴포넌트 + +```tsx +// frontend/lib/registry/layouts/responsive-grid/ResponsiveGridLayout.tsx + +import { cn } from "@/lib/utils"; +import { generateGridClasses } from "@/lib/utils/gridClassGenerator"; + +interface ResponsiveGridLayoutProps { + layout: LayoutData; + isDesignMode: boolean; + renderer: ComponentRenderer; +} + +export function ResponsiveGridLayout({ + layout, + isDesignMode, + renderer, +}: ResponsiveGridLayoutProps) { + const { gridSettings, components } = layout; + + return ( +
+ {components + .sort((a, b) => (a.grid?.row || 0) - (b.grid?.row || 0)) + .map((component) => { + const gridClasses = generateGridClasses( + component.grid, + component.responsive + ); + + return ( +
+ {renderer.renderChild(component)} +
+ ); + })} +
+ ); +} +``` + +### 5.2 렌더링 결과 예시 + +**데스크톱 (lg: 1024px+)**: +``` +┌─────────────────────────────────────────────────────────┐ +│ [분리] [저장] [수정] [삭제] │ ← 버튼들 오른쪽 정렬 +├─────────────────────────────────────────────────────────┤ +│ │ +│ 테이블 컴포넌트 │ +│ │ +└─────────────────────────────────────────────────────────┘ +``` + +**태블릿 (md: 768px ~ 1024px)**: +``` +┌───────────────────────────────┐ +│ [분리] [저장] [수정] [삭제] │ ← 버튼들 2개씩 +├───────────────────────────────┤ +│ │ +│ 테이블 컴포넌트 │ +│ │ +└───────────────────────────────┘ +``` + +**모바일 (sm: < 768px)**: +``` +┌─────────────────┐ +│ [분리] │ +│ [저장] │ +│ [수정] │ ← 세로 스택 +│ [삭제] │ +├─────────────────┤ +│ 테이블 컴포넌트 │ +│ (스크롤) │ +└─────────────────┘ +``` + +--- + +## 6. 마이그레이션 계획 + +### 6.1 데이터 마이그레이션 스크립트 + +```sql +-- 기존 데이터를 V3 구조로 변환하는 함수 +CREATE OR REPLACE FUNCTION migrate_layout_to_v3(layout_data JSONB) +RETURNS JSONB AS $$ +DECLARE + result JSONB; + component JSONB; + new_components JSONB := '[]'::JSONB; + grid_col INT; + grid_row INT; + col_span INT; + row_span INT; +BEGIN + -- 각 컴포넌트 변환 + FOR component IN SELECT * FROM jsonb_array_elements(layout_data->'components') + LOOP + -- 픽셀 → 그리드 변환 (160px = 1컬럼, 80px = 1행) + grid_col := GREATEST(1, LEAST(12, ROUND((component->'position'->>'x')::NUMERIC / 160) + 1)); + grid_row := GREATEST(1, ROUND((component->'position'->>'y')::NUMERIC / 80) + 1); + col_span := GREATEST(1, LEAST(13 - grid_col, ROUND((component->'size'->>'width')::NUMERIC / 160))); + row_span := GREATEST(1, ROUND((component->'size'->>'height')::NUMERIC / 80)); + + -- 새 컴포넌트 구조 생성 + component := component || jsonb_build_object( + 'grid', jsonb_build_object( + 'col', grid_col, + 'row', grid_row, + 'colSpan', col_span, + 'rowSpan', row_span + ), + 'responsive', jsonb_build_object( + 'sm', jsonb_build_object('col', 1, 'colSpan', 12), + 'md', jsonb_build_object('col', GREATEST(1, ROUND(grid_col / 2.0)), 'colSpan', LEAST(col_span * 2, 12)), + 'lg', jsonb_build_object('col', grid_col, 'colSpan', col_span) + ) + ); + + -- position, size 필드 제거 (선택사항 - 호환성 위해 유지 가능) + -- component := component - 'position' - 'size'; + + new_components := new_components || component; + END LOOP; + + -- 결과 생성 + result := jsonb_build_object( + 'version', '3.0', + 'layoutMode', 'grid', + 'components', new_components, + 'gridSettings', COALESCE(layout_data->'gridSettings', '{"columns": 12, "rowHeight": 80, "gap": 16}'::JSONB) + ); + + RETURN result; +END; +$$ LANGUAGE plpgsql; + +-- 마이그레이션 실행 +UPDATE screen_layouts_v2 +SET layout_data = migrate_layout_to_v3(layout_data) +WHERE (layout_data->>'version') = '2.0'; +``` + +### 6.2 백워드 호환성 + +V2 ↔ V3 호환을 위한 변환 레이어: + +```typescript +// frontend/lib/utils/layoutVersionConverter.ts + +export function normalizeLayout(layout: any): NormalizedLayout { + const version = layout.version || "2.0"; + + if (version === "2.0") { + // V2 → V3 변환 (렌더링 시) + return { + ...layout, + version: "3.0", + layoutMode: "grid", + components: layout.components.map((comp: any) => ({ + ...comp, + grid: pixelToGrid(comp.position, comp.size), + responsive: getDefaultResponsive(pixelToGrid(comp.position, comp.size)), + })), + }; + } + + return layout; // V3는 그대로 +} +``` + +--- + +## 7. 디자인 모드 수정 + +### 7.1 그리드 편집 UI + +디자인 모드에서 그리드 셀 선택 방식 추가: + +```tsx +// 기존: 픽셀 좌표 입력 + updatePosition({ x })} +/> + +// 변경: 그리드 셀 선택 +
+ {Array.from({ length: 12 }).map((_, col) => ( +
setGridCol(col + 1)} + /> + ))} +
+ +
+ +
+``` + +### 7.2 반응형 미리보기 + +```tsx +// 화면 크기 미리보기 버튼 +
+ + + +
+ +// 미리보기 컨테이너 +
+ +
+``` + +--- + +## 8. 작업 목록 + +### Phase 1: 핵심 유틸리티 (1일) + +| 작업 | 파일 | 상태 | +|------|------|------| +| 그리드 변환 함수 | `lib/utils/gridConverter.ts` | ⬜ | +| 클래스 생성 함수 | `lib/utils/gridClassGenerator.ts` | ⬜ | +| Tailwind safelist 설정 | `tailwind.config.js` | ⬜ | + +### Phase 2: 렌더링 수정 (1일) + +| 작업 | 파일 | 상태 | +|------|------|------| +| ResponsiveGridLayout 생성 | `lib/registry/layouts/responsive-grid/` | ⬜ | +| 레이아웃 버전 분기 처리 | `lib/registry/DynamicComponentRenderer.tsx` | ⬜ | + +### Phase 3: 저장 로직 수정 (1일) + +| 작업 | 파일 | 상태 | +|------|------|------| +| 저장 시 그리드 변환 | `components/screen/ScreenDesigner.tsx` | ⬜ | +| V3 레이아웃 변환기 | `lib/utils/layoutV3Converter.ts` | ⬜ | + +### Phase 4: 디자인 모드 UI (1일) + +| 작업 | 파일 | 상태 | +|------|------|------| +| 그리드 셀 편집 UI | `components/screen/panels/V2PropertiesPanel.tsx` | ⬜ | +| 반응형 미리보기 | `components/screen/ScreenDesigner.tsx` | ⬜ | + +### Phase 5: 마이그레이션 (0.5일) + +| 작업 | 파일 | 상태 | +|------|------|------| +| 마이그레이션 스크립트 | `db/migrations/xxx_migrate_to_v3.sql` | ⬜ | +| 백워드 호환성 테스트 | - | ⬜ | + +--- + +## 9. 예상 일정 + +| 단계 | 기간 | 완료 기준 | +|------|------|-----------| +| Phase 1 | 1일 | 유틸리티 함수 테스트 통과 | +| Phase 2 | 1일 | 그리드 렌더링 정상 동작 | +| Phase 3 | 1일 | 저장/로드 정상 동작 | +| Phase 4 | 1일 | 디자인 모드 UI 완성 | +| Phase 5 | 0.5일 | 기존 데이터 마이그레이션 완료 | +| 테스트 | 0.5일 | 모든 화면 반응형 테스트 | +| **합계** | **5일** | | + +--- + +## 10. 리스크 및 대응 + +| 리스크 | 영향 | 대응 방안 | +|--------|------|-----------| +| 기존 레이아웃 깨짐 | 높음 | position/size 필드 유지하여 폴백 | +| Tailwind 동적 클래스 | 중간 | safelist로 모든 클래스 사전 정의 | +| 디자인 모드 혼란 | 낮음 | 그리드 가이드라인 시각화 | + +--- + +## 11. 참고 자료 + +- [COMPONENT_LAYOUT_V2_ARCHITECTURE.md](./COMPONENT_LAYOUT_V2_ARCHITECTURE.md) - V2 아키텍처 +- [Tailwind CSS Grid](https://tailwindcss.com/docs/grid-template-columns) - 그리드 시스템 +- [shadcn/ui](https://ui.shadcn.com/) - 컴포넌트 라이브러리 diff --git a/docs/SCREEN_COPY_V2_MIGRATION_PLAN.md b/docs/SCREEN_COPY_V2_MIGRATION_PLAN.md new file mode 100644 index 00000000..7e1afcba --- /dev/null +++ b/docs/SCREEN_COPY_V2_MIGRATION_PLAN.md @@ -0,0 +1,524 @@ +# 화면 복제 로직 V2 마이그레이션 계획서 + +> 작성일: 2026-01-28 + +## 1. 현황 분석 + +### 1.1 현재 복제 방식 (Legacy) + +``` +테이블: screen_layouts (다중 레코드) +방식: 화면당 N개 레코드 (컴포넌트 수만큼) +저장: properties에 전체 설정 "박제" +``` + +**데이터 구조:** +```sql +-- 화면당 여러 레코드 +SELECT * FROM screen_layouts WHERE screen_id = 123; +-- layout_id | screen_id | component_type | component_id | properties (전체 설정) +-- 1 | 123 | table-list | comp_001 | {"tableName": "user", "columns": [...], ...} +-- 2 | 123 | button | comp_002 | {"label": "저장", "variant": "default", ...} +``` + +### 1.2 V2 방식 + +``` +테이블: screen_layouts_v2 (1개 레코드) +방식: 화면당 1개 레코드 (JSONB) +저장: url + overrides (차이값만) +``` + +**데이터 구조:** +```sql +-- 화면당 1개 레코드 +SELECT layout_data FROM screen_layouts_v2 WHERE screen_id = 123; +-- { +-- "version": "2.0", +-- "components": [ +-- { "id": "comp_001", "url": "@/lib/registry/components/table-list", "overrides": {...} }, +-- { "id": "comp_002", "url": "@/lib/registry/components/button-primary", "overrides": {...} } +-- ] +-- } +``` + +--- + +## 2. 현재 복제 로직 분석 + +### 2.1 복제 진입점 (2곳) + +| 경로 | 파일 | 함수 | 용도 | +|-----|------|------|-----| +| 단일 화면 복제 | `screenManagementService.ts` | `copyScreen()` | 화면 관리에서 개별 화면 복제 | +| 메뉴 일괄 복제 | `menuCopyService.ts` | `copyScreens()` | 메뉴 복제 시 연결된 화면들 복제 | + +### 2.2 screenManagementService.copyScreen() 흐름 + +``` +1. screen_definitions 조회 (원본) +2. screen_definitions INSERT (대상) +3. screen_layouts 조회 (원본) ← Legacy +4. flowId 수집 및 복제 (회사 간 복제 시) +5. numberingRuleId 수집 및 복제 (회사 간 복제 시) +6. componentId 재생성 (idMapping) +7. properties 내 참조 업데이트 (flowId, ruleId) +8. screen_layouts INSERT (대상) ← Legacy +``` + +**V2 처리: ❌ 없음** + +### 2.3 menuCopyService.copyScreens() 흐름 + +``` +1단계: screen_definitions 처리 + - 기존 복사본 존재 시: 업데이트 + - 없으면: 신규 생성 + - screenIdMap 생성 + +2단계: screen_layouts 처리 + - 원본 조회 + - componentIdMap 생성 + - properties 내 참조 업데이트 (screenId, flowId, ruleId, menuId) + - 배치 INSERT +``` + +**V2 처리: ❌ 없음** + +### 2.4 복제 시 처리되는 참조 ID들 + +| 참조 ID | 설명 | 매핑 방식 | +|--------|-----|----------| +| `componentId` | 컴포넌트 고유 ID | 새로 생성 (`comp_xxx`) | +| `parentId` | 부모 컴포넌트 ID | componentIdMap으로 매핑 | +| `flowId` | 노드 플로우 ID | flowIdMap으로 매핑 (회사 간 복제 시) | +| `numberingRuleId` | 채번 규칙 ID | ruleIdMap으로 매핑 (회사 간 복제 시) | +| `screenId` (탭) | 탭에서 참조하는 화면 ID | screenIdMap으로 매핑 | +| `menuObjid` | 메뉴 ID | menuIdMap으로 매핑 | + +--- + +## 3. V2 마이그레이션 시 변경 필요 사항 + +### 3.1 핵심 변경점 + +| 항목 | Legacy | V2 | +|-----|--------|-----| +| 읽기 테이블 | `screen_layouts` | `screen_layouts_v2` | +| 쓰기 테이블 | `screen_layouts` | `screen_layouts_v2` | +| 데이터 형태 | N개 레코드 | 1개 JSONB | +| ID 매핑 위치 | 각 레코드의 컬럼 | JSONB 내부 순회 | +| 참조 업데이트 | `properties` JSON | `overrides` JSON | + +### 3.2 수정해야 할 함수들 + +#### screenManagementService.ts + +| 함수 | 변경 내용 | +|-----|----------| +| `copyScreen()` | screen_layouts_v2 복제 로직 추가 | +| `collectFlowIdsFromLayouts()` | V2 JSONB 구조에서 flowId 수집 | +| `collectNumberingRuleIdsFromLayouts()` | V2 JSONB 구조에서 ruleId 수집 | +| `updateFlowIdsInProperties()` | V2 overrides 내 flowId 업데이트 | +| `updateNumberingRuleIdsInProperties()` | V2 overrides 내 ruleId 업데이트 | + +#### menuCopyService.ts + +| 함수 | 변경 내용 | +|-----|----------| +| `copyScreens()` | screen_layouts_v2 복제 로직 추가 | +| `hasLayoutChanges()` | V2 JSONB 비교 로직 | +| `updateReferencesInProperties()` | V2 overrides 내 참조 업데이트 | + +### 3.3 새로 추가할 함수들 + +```typescript +// V2 레이아웃 복제 (공통) +async copyLayoutV2( + sourceScreenId: number, + targetScreenId: number, + targetCompanyCode: string, + mappings: { + componentIdMap: Map; + flowIdMap: Map; + ruleIdMap: Map; + screenIdMap: Map; + menuIdMap?: Map; + }, + client: PoolClient +): Promise + +// V2 JSONB에서 참조 ID 수집 +collectReferencesFromLayoutV2(layoutData: any): { + flowIds: Set; + ruleIds: Set; + screenIds: Set; +} + +// V2 JSONB 내 참조 업데이트 +updateReferencesInLayoutV2( + layoutData: any, + mappings: { ... } +): any +``` + +--- + +## 4. 마이그레이션 전략 + +### 4.1 전략: V2 완전 전환 + +``` +결정: V2만 복제 (Legacy 복제 제거) +이유: 깔끔한 코드, 유지보수 용이, V2 아키텍처 일관성 +전제: 기존 화면들은 이미 screen_layouts_v2로 마이그레이션 완료 (1,347개 100%) +``` + +### 4.2 단계별 계획 + +#### Phase 1: V2 복제 로직 구현 및 전환 + +``` +목표: Legacy 복제를 V2 복제로 완전 교체 +영향: 복제 시 screen_layouts_v2 테이블만 사용 + +작업: +1. copyLayoutV2() 공통 함수 구현 +2. screenManagementService.copyScreen() - Legacy → V2 교체 +3. menuCopyService.copyScreens() - Legacy → V2 교체 +4. 테스트 및 검증 +``` + +#### Phase 2: Legacy 코드 정리 + +``` +목표: 불필요한 Legacy 복제 코드 제거 +영향: 코드 간소화 + +작업: +1. screen_layouts 관련 복제 코드 제거 +2. 관련 헬퍼 함수 정리 (collectFlowIdsFromLayouts 등) +3. 코드 리뷰 및 정리 +``` + +#### Phase 3: Legacy 테이블 정리 (선택, 추후) + +``` +목표: 불필요한 테이블 제거 +영향: 데이터 정리 + +작업: +1. screen_layouts 테이블 데이터 백업 +2. screen_layouts 테이블 삭제 (또는 보관) +3. 관련 코드 정리 +``` + +--- + +## 5. 상세 구현 계획 + +### 5.1 Phase 1 작업 목록 + +| # | 작업 | 파일 | 예상 공수 | +|---|-----|------|---------| +| 1 | `copyLayoutV2()` 공통 함수 구현 | screenManagementService.ts | 2시간 | +| 2 | `collectReferencesFromLayoutV2()` 구현 | screenManagementService.ts | 1시간 | +| 3 | `updateReferencesInLayoutV2()` 구현 | screenManagementService.ts | 2시간 | +| 4 | `copyScreen()` - Legacy 제거, V2로 교체 | screenManagementService.ts | 2시간 | +| 5 | `copyScreens()` - Legacy 제거, V2로 교체 | menuCopyService.ts | 3시간 | +| 6 | 단위 테스트 | - | 2시간 | +| 7 | 통합 테스트 | - | 2시간 | + +**총 예상 공수: 14시간 (약 2일)** + +### 5.2 주요 변경 포인트 + +#### copyScreen() 변경 전후 + +**Before (Legacy):** +```typescript +// 4. 원본 화면의 레이아웃 정보 조회 +const sourceLayoutsResult = await client.query( + `SELECT * FROM screen_layouts WHERE screen_id = $1`, + [sourceScreenId] +); +// ... N개 레코드 순회하며 INSERT +``` + +**After (V2):** +```typescript +// 4. 원본 V2 레이아웃 조회 +const sourceLayoutV2 = await client.query( + `SELECT layout_data FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2`, + [sourceScreenId, sourceCompanyCode] +); +// ... JSONB 변환 후 1개 레코드 INSERT +``` + +#### copyScreens() 변경 전후 + +**Before (Legacy):** +```typescript +// 레이아웃 배치 INSERT +await client.query( + `INSERT INTO screen_layouts (...) VALUES ${layoutValues.join(", ")}`, + layoutParams +); +``` + +**After (V2):** +```typescript +// V2 레이아웃 UPSERT +await this.copyLayoutV2( + originalScreenId, targetScreenId, sourceCompanyCode, targetCompanyCode, + { componentIdMap, flowIdMap, ruleIdMap, screenIdMap, menuIdMap }, + client +); +``` + +### 5.2 copyLayoutV2() 구현 방안 + +```typescript +private async copyLayoutV2( + sourceScreenId: number, + targetScreenId: number, + sourceCompanyCode: string, + targetCompanyCode: string, + mappings: { + componentIdMap: Map; + flowIdMap?: Map; + ruleIdMap?: Map; + screenIdMap?: Map; + menuIdMap?: Map; + }, + client: PoolClient +): Promise { + // 1. 원본 V2 레이아웃 조회 + const sourceResult = await client.query( + `SELECT layout_data FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2`, + [sourceScreenId, sourceCompanyCode] + ); + + if (sourceResult.rows.length === 0) { + // V2 레이아웃 없으면 스킵 (Legacy만 있는 경우) + return; + } + + const layoutData = sourceResult.rows[0].layout_data; + + // 2. components 배열 순회하며 ID 매핑 + const updatedComponents = layoutData.components.map((comp: any) => { + const newId = mappings.componentIdMap.get(comp.id) || comp.id; + + // overrides 내 참조 업데이트 + let updatedOverrides = { ...comp.overrides }; + + // flowId 매핑 + if (mappings.flowIdMap && updatedOverrides.flowId) { + const newFlowId = mappings.flowIdMap.get(updatedOverrides.flowId); + if (newFlowId) updatedOverrides.flowId = newFlowId; + } + + // numberingRuleId 매핑 + if (mappings.ruleIdMap && updatedOverrides.numberingRuleId) { + const newRuleId = mappings.ruleIdMap.get(updatedOverrides.numberingRuleId); + if (newRuleId) updatedOverrides.numberingRuleId = newRuleId; + } + + // screenId 매핑 (탭 컴포넌트 등) + if (mappings.screenIdMap && updatedOverrides.screenId) { + const newScreenId = mappings.screenIdMap.get(updatedOverrides.screenId); + if (newScreenId) updatedOverrides.screenId = newScreenId; + } + + // tabs 배열 내 screenId 매핑 + if (mappings.screenIdMap && Array.isArray(updatedOverrides.tabs)) { + updatedOverrides.tabs = updatedOverrides.tabs.map((tab: any) => ({ + ...tab, + screenId: mappings.screenIdMap.get(tab.screenId) || tab.screenId + })); + } + + return { + ...comp, + id: newId, + overrides: updatedOverrides + }; + }); + + const newLayoutData = { + ...layoutData, + components: updatedComponents, + updatedAt: new Date().toISOString() + }; + + // 3. 대상 V2 레이아웃 저장 (UPSERT) + await client.query( + `INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at) + VALUES ($1, $2, $3, NOW(), NOW()) + ON CONFLICT (screen_id, company_code) + DO UPDATE SET layout_data = $3, updated_at = NOW()`, + [targetScreenId, targetCompanyCode, JSON.stringify(newLayoutData)] + ); +} +``` + +--- + +## 6. 테스트 계획 + +### 6.1 단위 테스트 + +| 테스트 케이스 | 설명 | +|-------------|------| +| V2 레이아웃 복제 - 기본 | 단순 컴포넌트 복제 | +| V2 레이아웃 복제 - flowId 매핑 | 회사 간 복제 시 flowId 변경 확인 | +| V2 레이아웃 복제 - ruleId 매핑 | 회사 간 복제 시 ruleId 변경 확인 | +| V2 레이아웃 복제 - 탭 screenId 매핑 | 탭 컴포넌트의 screenId 변경 확인 | +| V2 레이아웃 없는 경우 | Legacy만 있는 화면 복제 시 스킵 확인 | + +### 6.2 통합 테스트 + +| 테스트 케이스 | 설명 | +|-------------|------| +| 단일 화면 복제 (같은 회사) | copyScreen() - 동일 회사 내 복제 | +| 단일 화면 복제 (다른 회사) | copyScreen() - 회사 간 복제 | +| 메뉴 일괄 복제 | copyScreens() - 여러 화면 동시 복제 | +| 모달 포함 복제 | copyScreenWithModals() - 메인 + 모달 복제 | + +### 6.3 검증 항목 + +``` +복제 후 확인: +- [ ] screen_layouts_v2에 레코드 생성됨 +- [ ] componentId가 새로 생성됨 +- [ ] flowId가 정확히 매핑됨 +- [ ] numberingRuleId가 정확히 매핑됨 +- [ ] 탭 컴포넌트의 screenId가 정확히 매핑됨 +- [ ] screen_layouts(Legacy)는 복제되지 않음 +- [ ] 복제된 화면이 프론트엔드에서 정상 로드됨 +- [ ] 복제된 화면 편집/저장 정상 동작 +``` + +--- + +## 7. 영향 분석 + +### 7.1 영향 받는 기능 + +| 기능 | 영향 | 비고 | +|-----|-----|-----| +| 화면 관리 - 화면 복제 | 직접 영향 | copyScreen() | +| 화면 관리 - 그룹 복제 | 직접 영향 | copyScreenWithModals() | +| 메뉴 복제 | 직접 영향 | menuCopyService.copyScreens() | +| 화면 디자이너 | 간접 영향 | 복제된 화면 로드 시 V2 사용 | + +### 7.2 롤백 계획 + +``` +V2 전환 롤백 (필요시): +1. Git에서 이전 버전 복원 (copyScreen, copyScreens) +2. Legacy 복제 코드 복원 +3. 테스트 후 배포 + +주의사항: +- V2로 복제된 화면들은 screen_layouts_v2에만 데이터 존재 +- 롤백 시 해당 화면들은 screen_layouts에 데이터 없음 +- 필요시 V2 → Legacy 역변환 스크립트 실행 +``` + +--- + +## 8. 관련 파일 + +### 8.1 수정 대상 + +| 파일 | 변경 내용 | +|-----|----------| +| `backend-node/src/services/screenManagementService.ts` | copyLayoutV2(), copyScreen() 수정 | +| `backend-node/src/services/menuCopyService.ts` | copyScreens() 수정 | + +### 8.2 참고 파일 + +| 파일 | 설명 | +|-----|-----| +| `docs/COMPONENT_LAYOUT_V2_ARCHITECTURE.md` | V2 아키텍처 문서 | +| `frontend/lib/api/screen.ts` | getLayoutV2, saveLayoutV2 | +| `frontend/lib/utils/layoutV2Converter.ts` | V2 변환 유틸리티 | + +--- + +## 9. 체크리스트 + +### 9.1 개발 전 + +- [ ] V2 아키텍처 문서 숙지 +- [ ] 현재 복제 로직 코드 리뷰 +- [ ] 테스트 데이터 준비 (V2 레이아웃이 있는 화면) + +### 9.2 Phase 1 완료 조건 + +- [x] copyLayoutV2() 함수 구현 ✅ 2026-01-28 +- [x] collectReferencesFromLayoutV2() 함수 구현 ✅ 2026-01-28 +- [x] updateReferencesInLayoutV2() 함수 구현 ✅ 2026-01-28 +- [x] copyScreen() - Legacy 제거, V2로 교체 ✅ 2026-01-28 +- [x] copyScreens() - Legacy 제거, V2로 교체 ✅ 2026-01-28 +- [x] hasLayoutChangesV2() 함수 추가 ✅ 2026-01-28 +- [x] updateTabScreenReferences() V2 지원 추가 ✅ 2026-01-28 +- [ ] 단위 테스트 통과 +- [ ] 통합 테스트 통과 +- [ ] V2 전용 복제 동작 확인 + +### 9.3 Phase 2 완료 조건 + +- [ ] Legacy 관련 헬퍼 함수 정리 +- [ ] 불필요한 코드 제거 +- [ ] 코드 리뷰 완료 +- [ ] 회귀 테스트 통과 + +--- + +## 10. 시뮬레이션 검증 결과 + +### 10.1 검증된 시나리오 + +| 시나리오 | 결과 | 비고 | +|---------|------|------| +| 같은 회사 내 복제 | ✅ 정상 | componentId만 새로 생성 | +| 회사 간 복제 (flowId 매핑) | ✅ 정상 | flowIdMap 적용됨 | +| 회사 간 복제 (ruleId 매핑) | ✅ 정상 | ruleIdMap 적용됨 | +| 탭 컴포넌트 screenId 매핑 | ✅ 정상 | updateTabScreenReferences V2 지원 추가 | +| V2 레이아웃 없는 화면 | ✅ 정상 | 스킵 처리 | + +### 10.2 발견 및 수정된 문제 + +| 문제 | 해결 | +|-----|------| +| updateTabScreenReferences가 V2 미지원 | V2 처리 로직 추가 완료 | + +### 10.3 Zod 활용 가능성 + +프론트엔드에 이미 훌륭한 Zod 유틸리티 존재: +- `deepMerge()` - 깊은 병합 +- `extractCustomConfig()` - 차이값 추출 +- `loadComponentV2()` / `saveComponentV2()` - V2 로드/저장 + +향후 백엔드에도 Zod 추가 시: +- 타입 안전성 향상 +- 프론트/백엔드 스키마 공유 가능 +- 범용 참조 탐색 로직으로 하드코딩 제거 가능 + +--- + +## 11. 변경 이력 + +| 날짜 | 변경 내용 | 작성자 | +|-----|----------|-------| +| 2026-01-28 | 초안 작성 | Claude | +| 2026-01-28 | V2 완전 전환 전략으로 변경 (병행 운영 → V2 전용) | Claude | +| 2026-01-28 | Phase 1 구현 완료 - V2 복제 함수들 구현 및 Legacy 교체 | Claude | +| 2026-01-28 | 시뮬레이션 검증 - updateTabScreenReferences V2 지원 추가 | Claude | +| 2026-01-28 | V2 경로 지원 추가 - action/sections 직접 경로 (componentConfig 없이) | Claude | +| 2026-01-30 | **실제 코드 구현 완료** - copyScreen(), copyScreens() V2 전환 | Claude | diff --git a/docs/V2_COMPONENT_MIGRATION_ANALYSIS.md b/docs/V2_COMPONENT_MIGRATION_ANALYSIS.md new file mode 100644 index 00000000..591e243b --- /dev/null +++ b/docs/V2_COMPONENT_MIGRATION_ANALYSIS.md @@ -0,0 +1,356 @@ +# V2 컴포넌트 마이그레이션 분석 보고서 + +> 작성일: 2026-01-27 +> 목적: 미구현 V1 컴포넌트들의 V2 마이그레이션 가능성 분석 + +--- + +## 1. 현황 요약 + +| 구분 | 개수 | 비율 | +|------|------|------| +| V1 총 컴포넌트 | 7,170개 | 100% | +| V2 마이그레이션 완료 | 5,212개 | 72.7% | +| **미구현 (분석 대상)** | **~520개** | **7.3%** | + +--- + +## 2. 미구현 컴포넌트 상세 분석 + +### 2.1 ✅ 통합 가능 (기존 V2 컴포넌트로 대체) + +#### 2.1.1 `unified-list` (97개) → `v2-table-list` + +**분석 결과**: ✅ **통합 가능** + +| 항목 | unified-list | v2-table-list | +|------|-------------|---------------| +| 테이블 뷰 | ✅ | ✅ | +| 카드 뷰 | ✅ | ❌ (추가 필요) | +| 검색 | ✅ | ✅ | +| 페이지네이션 | ✅ | ✅ | +| 편집 가능 | ✅ | ✅ | + +**결론**: `v2-table-list`에 `cardView` 모드만 추가하면 통합 가능. 또는 DB 마이그레이션으로 `v2-table-list`로 변환. + +**작업량**: 중간 (v2-table-list 확장 또는 DB 마이그레이션) + +--- + +#### 2.1.2 `autocomplete-search-input` (50개) → `v2-select` + +**분석 결과**: ✅ **통합 가능** + +| 항목 | autocomplete-search-input | v2-select | +|------|--------------------------|-----------| +| 자동완성 드롭다운 | ✅ | ✅ (mode: autocomplete) | +| 테이블 데이터 검색 | ✅ | ✅ (dataSource 설정) | +| 표시/값 필드 분리 | ✅ | ✅ | + +**결론**: `v2-select`의 `mode: "autocomplete"` 또는 `mode: "combobox"`로 대체 가능. + +**작업량**: 낮음 (DB 마이그레이션만) + +--- + +#### 2.1.3 `repeater-field-group` (24개) → `v2-repeater` + +**분석 결과**: ✅ **통합 가능** + +`v2-repeater`가 이미 다음을 지원: +- 인라인 테이블 모드 +- 모달 선택 모드 +- 버튼 모드 + +**결론**: `v2-repeater`의 `renderMode: "inline"`으로 대체. + +**작업량**: 낮음 (DB 마이그레이션만) + +--- + +#### 2.1.4 `simple-repeater-table` (1개) → `v2-repeater` + +**분석 결과**: ✅ **통합 가능** + +**결론**: `v2-repeater`로 대체. + +**작업량**: 매우 낮음 + +--- + +### 2.2 ⚠️ Renderer 추가만 필요 (코드 구조 있음) + +#### 2.2.1 `split-panel-layout2` (8개) + +**분석 결과**: ⚠️ **Renderer 추가 필요** + +- V1 Renderer: `SplitPanelLayout2Renderer.tsx` ✅ 존재 +- V2 Renderer: ❌ 없음 +- Component: `SplitPanelLayout2Component.tsx` ✅ 존재 + +**결론**: V2 형식으로 DB 마이그레이션만 하면 됨 (기존 Renderer가 `split-panel-layout2` ID로 등록됨). + +**작업량**: 매우 낮음 (DB 마이그레이션만) + +--- + +#### 2.2.2 `repeat-screen-modal` (7개) + +**분석 결과**: ⚠️ **Renderer 추가 필요** + +- V1 Renderer: `RepeatScreenModalRenderer.tsx` ✅ 존재 +- 정의: `hidden: true` (v2-repeat-screen-modal 사용으로 패널에서 숨김) + +**결론**: 기존 Renderer 사용 가능, DB 마이그레이션만. + +**작업량**: 매우 낮음 + +--- + +#### 2.2.3 `related-data-buttons` (5개) + +**분석 결과**: ⚠️ **Renderer 추가 필요** + +- V1 Renderer: `RelatedDataButtonsRenderer.tsx` ✅ 존재 +- Component: `RelatedDataButtonsComponent.tsx` ✅ 존재 + +**결론**: 기존 Renderer 사용 가능, DB 마이그레이션만. + +**작업량**: 매우 낮음 + +--- + +### 2.3 ❌ 별도 V2 개발 필요 (복잡한 구조) + +#### 2.3.1 `entity-search-input` (99개) + +**분석 결과**: ❌ **별도 개발 필요** + +**특징**: +```typescript +// 모달 기반 엔티티 검색 +- 테이블 선택 (tableName) +- 검색 필드 설정 (searchFields) +- 모달 팝업 (modalTitle, modalColumns) +- 값/표시 필드 분리 (valueField, displayField) +- 추가 정보 표시 (additionalFields) +``` + +**복잡도 요인**: +1. 모달 검색 UI가 필요 +2. 다양한 테이블 연동 +3. 추가 필드 연계 로직 + +**권장 방안**: +- `v2-entity-search` 새로 개발 +- 또는 `v2-select`에 `mode: "entity"` 추가 + +**작업량**: 높음 (1-2일) + +--- + +#### 2.3.2 `modal-repeater-table` (68개) + +**분석 결과**: ❌ **별도 개발 필요** + +**특징**: +```typescript +// 모달에서 항목 검색 + 동적 테이블 +- 소스 테이블 (sourceTable, sourceColumns) +- 모달 검색 (modalTitle, modalButtonText, multiSelect) +- 동적 컬럼 추가 (columns) +- 계산 규칙 (calculationRules) +- 고유 필드 (uniqueField) +``` + +**복잡도 요인**: +1. 모달 검색 + 선택 +2. 동적 테이블 행 추가/삭제 +3. 계산 규칙 (단가 × 수량 = 금액) +4. 중복 방지 로직 + +**권장 방안**: +- `v2-repeater`의 `modal` 모드 확장 +- `ItemSelectionModal` + `RepeaterTable` 재사용 + +**작업량**: 중간 (v2-repeater가 이미 기반 제공) + +--- + +#### 2.3.3 `selected-items-detail-input` (83개) + +**분석 결과**: ❌ **별도 개발 필요** + +**특징**: +```typescript +// 선택된 항목들의 상세 입력 +- 데이터 소스 (dataSourceId) +- 표시 컬럼 (displayColumns) +- 추가 입력 필드 (additionalFields) +- 타겟 테이블 (targetTable) +- 레이아웃 (grid/table) +``` + +**복잡도 요인**: +1. 부모 컴포넌트에서 데이터 수신 +2. 동적 필드 생성 +3. 다중 테이블 저장 + +**권장 방안**: +- `v2-selected-items-detail` 새로 개발 +- 또는 `v2-repeater`에 `mode: "detail-input"` 추가 + +**작업량**: 중간~높음 + +--- + +#### 2.3.4 `conditional-container` (53개) + +**분석 결과**: ❌ **별도 개발 필요** + +**특징**: +```typescript +// 조건부 UI 분기 +- 제어 필드 (controlField, controlLabel) +- 조건별 섹션 (sections: [{condition, label, screenId}]) +- 기본값 (defaultValue) +``` + +**복잡도 요인**: +1. 셀렉트박스 값에 따른 동적 UI 변경 +2. 화면 임베딩 (screenId) +3. 상태 관리 복잡 + +**권장 방안**: +- `v2-conditional-container` 새로 개발 +- 조건부 렌더링 + 화면 임베딩 로직 + +**작업량**: 높음 + +--- + +#### 2.3.5 `universal-form-modal` (26개) + +**분석 결과**: ❌ **별도 개발 필요** + +**특징**: +```typescript +// 범용 폼 모달 +- 섹션 기반 레이아웃 +- 반복 섹션 +- 채번규칙 연동 +- 다중 테이블 저장 +``` + +**복잡도 요인**: +1. 동적 섹션 구성 +2. 채번규칙 연동 +3. 다중 테이블 저장 +4. 반복 필드 그룹 + +**권장 방안**: +- `v2-universal-form` 새로 개발 +- 또는 기존 컴포넌트 유지 (특수 목적) + +**작업량**: 매우 높음 (3일 이상) + +--- + +### 2.4 🟢 V1 유지 권장 (특수 목적) + +| 컴포넌트 | 개수 | 이유 | +|----------|------|------| +| `tax-invoice-list` | 1 | 세금계산서 전용, 재사용 낮음 | +| `mail-recipient-selector` | 1 | 메일 전용, 재사용 낮음 | +| `unified-select` | 5 | → v2-select로 이미 마이그레이션 | +| `unified-date` | 2 | → v2-date로 이미 마이그레이션 | +| `unified-repeater` | 2 | → v2-repeater로 이미 마이그레이션 | + +--- + +## 3. 마이그레이션 우선순위 권장 + +### 3.1 즉시 처리 (1일 이내) + +| 순위 | 컴포넌트 | 개수 | 작업 | +|------|----------|------|------| +| 1 | `split-panel-layout2` | 8 | DB 마이그레이션만 | +| 2 | `repeat-screen-modal` | 7 | DB 마이그레이션만 | +| 3 | `related-data-buttons` | 5 | DB 마이그레이션만 | +| 4 | `autocomplete-search-input` | 50 | → v2-select 변환 | +| 5 | `repeater-field-group` | 24 | → v2-repeater 변환 | + +**총: 94개 컴포넌트** + +--- + +### 3.2 단기 처리 (1주 이내) + +| 순위 | 컴포넌트 | 개수 | 작업 | +|------|----------|------|------| +| 1 | `unified-list` | 97 | → v2-table-list 확장 또는 변환 | +| 2 | `modal-repeater-table` | 68 | v2-repeater modal 모드 확장 | + +**총: 165개 컴포넌트** + +--- + +### 3.3 중기 처리 (2주 이상) + +| 순위 | 컴포넌트 | 개수 | 작업 | +|------|----------|------|------| +| 1 | `entity-search-input` | 99 | v2-entity-search 신규 개발 | +| 2 | `selected-items-detail-input` | 83 | v2-selected-items-detail 개발 | +| 3 | `conditional-container` | 53 | v2-conditional-container 개발 | +| 4 | `universal-form-modal` | 26 | v2-universal-form 개발 | + +**총: 261개 컴포넌트** + +--- + +## 4. 권장 아키텍처 + +### 4.1 V2 컴포넌트 통합 계획 + +``` +v2-input ← text-input, number-input, textarea, unified-input ✅ 완료 +v2-select ← select-basic, checkbox, radio, autocomplete ⚠️ 진행중 +v2-date ← date-input, unified-date ✅ 완료 +v2-media ← file-upload, image-widget ✅ 완료 +v2-table-list ← table-list, unified-list ⚠️ 확장 필요 +v2-repeater ← repeater-field-group, modal-repeater-table, + simple-repeater-table, related-data-buttons ⚠️ 진행중 +v2-entity-search ← entity-search-input (신규 개발 필요) +v2-conditional ← conditional-container (신규 개발 필요) +``` + +--- + +## 5. 결론 + +### 즉시 처리 가능 (Renderer/DB만) +- `split-panel-layout2`, `repeat-screen-modal`, `related-data-buttons`: **20개** +- `autocomplete-search-input` → `v2-select`: **50개** +- `repeater-field-group` → `v2-repeater`: **24개** + +### 통합 검토 필요 +- `unified-list` → `v2-table-list` 확장: **97개** +- `modal-repeater-table` → `v2-repeater` 확장: **68개** + +### 신규 개발 필요 +- `entity-search-input`: **99개** (복잡도 높음) +- `selected-items-detail-input`: **83개** +- `conditional-container`: **53개** +- `universal-form-modal`: **26개** + +### 유지 +- 특수 목적 컴포넌트: **3개** (tax-invoice-list, mail-recipient-selector) + +--- + +## 6. 다음 단계 + +1. **즉시**: `split-panel-layout2`, `repeat-screen-modal`, `related-data-buttons` DB 마이그레이션 +2. **이번 주**: `autocomplete-search-input` → `v2-select`, `repeater-field-group` → `v2-repeater` 변환 +3. **다음 주**: `unified-list`, `modal-repeater-table` 통합 설계 +4. **이후**: `entity-search-input`, `conditional-container` 신규 개발 계획 수립 diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 19f33cd1..593c3e8b 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -105,7 +105,7 @@ import "./v2-location-swap-selector/LocationSwapSelectorRenderer"; import "./v2-table-search-widget"; import "./v2-tabs-widget/tabs-component"; import "./v2-category-manager/V2CategoryManagerRenderer"; -import "./v2-media"; // 통합 미디어 컴포넌트 +import "./v2-media/V2MediaRenderer"; // V2 통합 미디어 컴포넌트 import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트 import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트 import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트 diff --git a/frontend/lib/registry/components/v2-media/V2MediaRenderer.tsx b/frontend/lib/registry/components/v2-media/V2MediaRenderer.tsx new file mode 100644 index 00000000..99bbc5e6 --- /dev/null +++ b/frontend/lib/registry/components/v2-media/V2MediaRenderer.tsx @@ -0,0 +1,109 @@ +"use client"; + +import React from "react"; +import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; +import { V2MediaDefinition } from "./index"; +import { V2Media } from "@/components/v2/V2Media"; + +/** + * V2Media 렌더러 + * 파일, 이미지, 비디오, 오디오 등 다양한 미디어 타입을 지원 + * 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록 + */ +export class V2MediaRenderer extends AutoRegisteringComponentRenderer { + static componentDefinition = V2MediaDefinition; + + render(): React.ReactElement { + const { component, formData, onFormDataChange, isDesignMode, isSelected, isInteractive, ...restProps } = this.props; + + // 컴포넌트 설정 추출 + const config = component.componentConfig || component.config || {}; + const columnName = component.columnName; + const tableName = component.tableName || this.props.tableName; + + // formData에서 현재 값 가져오기 + const currentValue = formData?.[columnName] ?? component.value ?? ""; + + // 값 변경 핸들러 + const handleChange = (value: any) => { + if (isInteractive && onFormDataChange && columnName) { + onFormDataChange(columnName, value); + } + }; + + // V1 file-upload, image-widget에서 넘어온 설정 매핑 + const mediaType = config.mediaType || config.type || this.getMediaTypeFromWebType(component.webType); + + // maxSize: MB → bytes 변환 (V1은 bytes, V2는 MB 단위 사용) + const maxSizeBytes = config.maxSize + ? (config.maxSize > 1000 ? config.maxSize : config.maxSize * 1024 * 1024) + : 10 * 1024 * 1024; // 기본 10MB + + return ( + + ); + } + + /** + * webType에서 미디어 타입 추출 + */ + private getMediaTypeFromWebType(webType?: string): "file" | "image" | "video" | "audio" { + switch (webType) { + case "image": + return "image"; + case "video": + return "video"; + case "audio": + return "audio"; + case "file": + default: + return "file"; + } + } + + /** + * 미디어 타입에 따른 기본 accept 값 + */ + private getDefaultAccept(mediaType: string): string { + switch (mediaType) { + case "image": + return "image/*"; + case "video": + return "video/*"; + case "audio": + return "audio/*"; + default: + return "*/*"; + } + } +} + +// 자동 등록 실행 +V2MediaRenderer.registerSelf(); + +// Hot Reload 지원 (개발 모드) +if (process.env.NODE_ENV === "development") { + V2MediaRenderer.enableHotReload(); +} From 51492a8911e5f192dc0ec310d03ffad8f4e6735c Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 2 Feb 2026 09:22:34 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=EB=AF=B8?= =?UTF-8?q?=EB=93=A4=EC=9B=A8=EC=96=B4=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B7=B8=EB=A3=B9=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모든 라우트에 인증 미들웨어를 적용하여 보안을 강화하였습니다. - 화면 그룹 삭제 시 회사 코드 확인 및 권한 체크 로직을 추가하여, 다른 회사의 그룹 삭제를 방지하였습니다. - 채번 규칙, 카테고리 값, 테이블 타입 컬럼 복제 시 같은 회사로 복제하는 경우 경고 메시지를 추가하였습니다. - 메뉴 URL 업데이트 기능을 추가하여 복제된 화면 ID에 맞게 URL을 재매핑하도록 하였습니다. --- .../src/controllers/categoryTreeController.ts | 4 + .../src/controllers/screenGroupController.ts | 53 +- backend-node/src/services/menuCopyService.ts | 76 ++ .../src/services/numberingRuleService.ts | 56 +- .../src/services/screenManagementService.ts | 38 +- .../RESPONSIVE_GRID_SYSTEM_ARCHITECTURE.md | 882 +++++++++--------- .../components/screen/CopyScreenModal.tsx | 108 ++- .../components/screen/ScreenGroupTreeView.tsx | 74 +- frontend/lib/api/categoryTree.ts | 1 + 9 files changed, 784 insertions(+), 508 deletions(-) diff --git a/backend-node/src/controllers/categoryTreeController.ts b/backend-node/src/controllers/categoryTreeController.ts index de6a8e2a..ec7ef92b 100644 --- a/backend-node/src/controllers/categoryTreeController.ts +++ b/backend-node/src/controllers/categoryTreeController.ts @@ -5,9 +5,13 @@ import { Router, Request, Response } from "express"; import { categoryTreeService, CreateCategoryValueInput, UpdateCategoryValueInput } from "../services/categoryTreeService"; import { logger } from "../utils/logger"; +import { authenticateToken } from "../middleware/authMiddleware"; const router = Router(); +// 모든 라우트에 인증 미들웨어 적용 +router.use(authenticateToken); + // 인증된 사용자 타입 interface AuthenticatedRequest extends Request { user?: { diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index 69a63491..ba690aa5 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -308,18 +308,42 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response await client.query('BEGIN'); - // 1. 삭제할 그룹과 하위 그룹 ID 수집 (CASCADE 삭제 대상) + // 0. 삭제할 그룹의 company_code 확인 + const targetGroupResult = await client.query( + `SELECT company_code FROM screen_groups WHERE id = $1`, + [id] + ); + if (targetGroupResult.rows.length === 0) { + await client.query('ROLLBACK'); + return res.status(404).json({ success: false, message: "화면 그룹을 찾을 수 없습니다." }); + } + const targetCompanyCode = targetGroupResult.rows[0].company_code; + + // 권한 체크: 최고관리자가 아닌 경우 자신의 회사 그룹만 삭제 가능 + if (companyCode !== "*" && targetCompanyCode !== companyCode) { + await client.query('ROLLBACK'); + return res.status(403).json({ success: false, message: "권한이 없습니다." }); + } + + // 1. 삭제할 그룹과 하위 그룹 ID 수집 (같은 회사만 - CASCADE 삭제 대상) const childGroupsResult = await client.query(` WITH RECURSIVE child_groups AS ( - SELECT id FROM screen_groups WHERE id = $1 + SELECT id, company_code FROM screen_groups WHERE id = $1 AND company_code = $2 UNION ALL - SELECT sg.id FROM screen_groups sg - JOIN child_groups cg ON sg.parent_group_id = cg.id + SELECT sg.id, sg.company_code FROM screen_groups sg + JOIN child_groups cg ON sg.parent_group_id = cg.id AND sg.company_code = cg.company_code ) SELECT id FROM child_groups - `, [id]); + `, [id, targetCompanyCode]); const groupIdsToDelete = childGroupsResult.rows.map((r: any) => r.id); + logger.info("화면 그룹 삭제 대상", { + companyCode, + targetCompanyCode, + groupId: id, + childGroupIds: groupIdsToDelete + }); + // 2. menu_info에서 삭제될 screen_group 참조를 NULL로 정리 if (groupIdsToDelete.length > 0) { await client.query(` @@ -329,18 +353,11 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response `, [groupIdsToDelete]); } - // 3. screen_groups 삭제 - let query = `DELETE FROM screen_groups WHERE id = $1`; - const params: any[] = [id]; - - if (companyCode !== "*") { - query += ` AND company_code = $2`; - params.push(companyCode); - } - - query += " RETURNING id"; - - const result = await client.query(query, params); + // 3. screen_groups 삭제 (해당 그룹만 - 하위 그룹은 프론트엔드에서 순차 삭제) + const result = await client.query( + `DELETE FROM screen_groups WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, targetCompanyCode] + ); if (result.rows.length === 0) { await client.query('ROLLBACK'); @@ -349,7 +366,7 @@ export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response await client.query('COMMIT'); - logger.info("화면 그룹 삭제", { companyCode, groupId: id, cleanedRefs: groupIdsToDelete.length }); + logger.info("화면 그룹 삭제 완료", { companyCode, targetCompanyCode, groupId: id, cleanedRefs: groupIdsToDelete.length }); res.json({ success: true, message: "화면 그룹이 삭제되었습니다." }); } catch (error: any) { diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 439ccaae..ac049799 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -961,6 +961,16 @@ export class MenuCopyService { const menus = await this.collectMenuTree(sourceMenuObjid, client); const sourceCompanyCode = menus[0].company_code!; + // 같은 회사로 복제하는 경우 경고 (자기 자신의 데이터 손상 위험) + if (sourceCompanyCode === targetCompanyCode) { + logger.warn( + `⚠️ 같은 회사로 메뉴 복제 시도: ${sourceCompanyCode} → ${targetCompanyCode}` + ); + warnings.push( + "같은 회사로 복제하면 추가 데이터(카테고리, 채번 등)가 복제되지 않습니다." + ); + } + const screenIds = await this.collectScreens( menus.map((m) => m.objid), sourceCompanyCode, @@ -1116,6 +1126,10 @@ export class MenuCopyService { client ); + // === 6.5단계: 메뉴 URL 업데이트 (화면 ID 재매핑) === + logger.info("\n🔄 [6.5단계] 메뉴 URL 화면 ID 재매핑"); + await this.updateMenuUrls(menuIdMap, screenIdMap, client); + // === 7단계: 테이블 타입 설정 복사 === if (additionalCopyOptions?.copyTableTypeColumns) { logger.info("\n📦 [7단계] 테이블 타입 설정 복사"); @@ -2268,6 +2282,68 @@ export class MenuCopyService { } } + /** + * 메뉴 URL 업데이트 (화면 ID 재매핑) + * menu_url에 포함된 /screens/{screenId} 형식의 화면 ID를 복제된 화면 ID로 교체 + */ + private async updateMenuUrls( + menuIdMap: Map, + screenIdMap: Map, + client: PoolClient + ): Promise { + if (menuIdMap.size === 0 || screenIdMap.size === 0) { + logger.info("📭 메뉴 URL 업데이트 대상 없음"); + return; + } + + const newMenuObjids = Array.from(menuIdMap.values()); + + // 복제된 메뉴 중 menu_url이 있는 것 조회 + const menusWithUrl = await client.query<{ + objid: number; + menu_url: string; + }>( + `SELECT objid, menu_url FROM menu_info + WHERE objid = ANY($1) AND menu_url IS NOT NULL AND menu_url != ''`, + [newMenuObjids] + ); + + if (menusWithUrl.rows.length === 0) { + logger.info("📭 menu_url 업데이트 대상 없음"); + return; + } + + let updatedCount = 0; + const screenIdPattern = /\/screens\/(\d+)/; + + for (const menu of menusWithUrl.rows) { + const match = menu.menu_url.match(screenIdPattern); + if (!match) continue; + + const originalScreenId = parseInt(match[1], 10); + const newScreenId = screenIdMap.get(originalScreenId); + + if (newScreenId && newScreenId !== originalScreenId) { + const newMenuUrl = menu.menu_url.replace( + `/screens/${originalScreenId}`, + `/screens/${newScreenId}` + ); + + await client.query( + `UPDATE menu_info SET menu_url = $1 WHERE objid = $2`, + [newMenuUrl, menu.objid] + ); + + logger.info( + ` 🔗 메뉴 URL 업데이트: ${menu.menu_url} → ${newMenuUrl}` + ); + updatedCount++; + } + } + + logger.info(`✅ 메뉴 URL 업데이트 완료: ${updatedCount}개`); + } + /** * 코드 카테고리 + 코드 복사 (최적화: 배치 조회/삽입) */ diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 83e9b705..b5d8fb62 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -1782,8 +1782,8 @@ class NumberingRuleService { } /** - * 회사별 채번규칙 복제 (메뉴 동기화 완료 후 호출) - * 메뉴 이름을 기준으로 채번규칙을 대상 회사의 메뉴에 연결 + * 회사별 채번규칙 복제 (테이블 기반) + * numbering_rules_test, numbering_rule_parts_test 테이블 사용 * 복제 후 화면 레이아웃의 numberingRuleId 참조도 업데이트 */ async copyRulesForCompany( @@ -1798,12 +1798,9 @@ class NumberingRuleService { try { await client.query("BEGIN"); - // 1. 원본 회사의 채번규칙 조회 (menu + table 스코프 모두) + // 1. 원본 회사의 채번규칙 조회 - numbering_rules_test 사용 const sourceRulesResult = await client.query( - `SELECT nr.*, mi.menu_name_kor as source_menu_name - FROM numbering_rules nr - LEFT JOIN menu_info mi ON nr.menu_objid = mi.objid - WHERE nr.company_code = $1 AND nr.scope_type IN ('menu', 'table')`, + `SELECT * FROM numbering_rules_test WHERE company_code = $1`, [sourceCompanyCode] ); @@ -1817,9 +1814,9 @@ class NumberingRuleService { // 새 rule_id 생성 const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - // 이미 존재하는지 확인 (이름 기반) + // 이미 존재하는지 확인 (이름 기반) - numbering_rules_test 사용 const existsCheck = await client.query( - `SELECT rule_id FROM numbering_rules + `SELECT rule_id FROM numbering_rules_test WHERE company_code = $1 AND rule_name = $2`, [targetCompanyCode, rule.rule_name] ); @@ -1832,32 +1829,12 @@ class NumberingRuleService { continue; } - let targetMenuObjid = null; - - // menu 스코프인 경우 대상 메뉴 찾기 - if (rule.scope_type === 'menu' && rule.source_menu_name) { - const targetMenuResult = await client.query( - `SELECT objid FROM menu_info - WHERE company_code = $1 AND menu_name_kor = $2 - LIMIT 1`, - [targetCompanyCode, rule.source_menu_name] - ); - - if (targetMenuResult.rows.length === 0) { - result.skippedCount++; - result.details.push(`건너뜀 (메뉴 없음): ${rule.rule_name} - 메뉴: ${rule.source_menu_name}`); - continue; - } - - targetMenuObjid = targetMenuResult.rows[0].objid; - } - - // 채번규칙 복제 + // 채번규칙 복제 - numbering_rules_test 사용 await client.query( - `INSERT INTO numbering_rules ( + `INSERT INTO numbering_rules_test ( rule_id, rule_name, description, separator, reset_period, current_sequence, table_name, column_name, company_code, - created_at, updated_at, created_by, scope_type, menu_objid + created_at, updated_at, created_by, category_column, category_value_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW(), $10, $11, $12)`, [ newRuleId, @@ -1870,20 +1847,20 @@ class NumberingRuleService { rule.column_name, targetCompanyCode, rule.created_by, - rule.scope_type, - targetMenuObjid, + rule.category_column, + rule.category_value_id, ] ); - // 채번규칙 파트 복제 + // 채번규칙 파트 복제 - numbering_rule_parts_test 사용 const partsResult = await client.query( - `SELECT * FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order`, + `SELECT * FROM numbering_rule_parts_test WHERE rule_id = $1 ORDER BY part_order`, [rule.rule_id] ); for (const part of partsResult.rows) { await client.query( - `INSERT INTO numbering_rule_parts ( + `INSERT INTO numbering_rule_parts_test ( rule_id, part_order, part_type, generation_method, auto_config, manual_config, company_code, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`, @@ -1902,12 +1879,11 @@ class NumberingRuleService { // 매핑 추가 result.ruleIdMap[rule.rule_id] = newRuleId; result.copiedCount++; - result.details.push(`복제 완료: ${rule.rule_name} (${rule.scope_type})`); + result.details.push(`복제 완료: ${rule.rule_name}`); logger.info("채번규칙 복제 완료", { ruleName: rule.rule_name, oldRuleId: rule.rule_id, - newRuleId, - targetMenuObjid + newRuleId }); } diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index f69c133b..8cd6d4e0 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -4595,6 +4595,15 @@ export class ScreenManagementService { details: [] as string[], }; + // 같은 회사로 복제하는 경우 스킵 (자기 자신의 데이터 삭제 방지) + if (sourceCompanyCode === targetCompanyCode) { + logger.warn( + `⚠️ 같은 회사로 코드 카테고리/코드 복제 시도 - 스킵: ${sourceCompanyCode}`, + ); + result.details.push("같은 회사로는 복제할 수 없습니다."); + return result; + } + return transaction(async (client) => { logger.info( `📦 코드 카테고리/코드 복제: ${sourceCompanyCode} → ${targetCompanyCode}`, @@ -4716,12 +4725,21 @@ export class ScreenManagementService { details: [] as string[], }; + // 같은 회사로 복제하는 경우 스킵 (자기 자신의 데이터 삭제 방지) + if (sourceCompanyCode === targetCompanyCode) { + logger.warn( + `⚠️ 같은 회사로 카테고리 값 복제 시도 - 스킵: ${sourceCompanyCode}`, + ); + result.details.push("같은 회사로는 복제할 수 없습니다."); + return result; + } + return transaction(async (client) => { logger.info( `📦 카테고리 값 복제: ${sourceCompanyCode} → ${targetCompanyCode}`, ); - // 1. 기존 대상 회사 데이터 삭제 + // 1. 기존 대상 회사 데이터 삭제 (다른 회사로 복제 시에만) await client.query( `DELETE FROM category_values_test WHERE company_code = $1`, [targetCompanyCode], @@ -4798,6 +4816,15 @@ export class ScreenManagementService { details: [] as string[], }; + // 같은 회사로 복제하는 경우 스킵 (자기 자신의 데이터 삭제 방지) + if (sourceCompanyCode === targetCompanyCode) { + logger.warn( + `⚠️ 같은 회사로 테이블 타입 컬럼 복제 시도 - 스킵: ${sourceCompanyCode}`, + ); + result.details.push("같은 회사로는 복제할 수 없습니다."); + return result; + } + return transaction(async (client) => { logger.info( `📦 테이블 타입 컬럼 복제: ${sourceCompanyCode} → ${targetCompanyCode}`, @@ -4861,6 +4888,15 @@ export class ScreenManagementService { details: [] as string[], }; + // 같은 회사로 복제하는 경우 스킵 (자기 자신의 데이터 삭제 방지) + if (sourceCompanyCode === targetCompanyCode) { + logger.warn( + `⚠️ 같은 회사로 연쇄관계 설정 복제 시도 - 스킵: ${sourceCompanyCode}`, + ); + result.details.push("같은 회사로는 복제할 수 없습니다."); + return result; + } + return transaction(async (client) => { logger.info( `📦 연쇄관계 설정 복제: ${sourceCompanyCode} → ${targetCompanyCode}`, diff --git a/docs/DDD1542/RESPONSIVE_GRID_SYSTEM_ARCHITECTURE.md b/docs/DDD1542/RESPONSIVE_GRID_SYSTEM_ARCHITECTURE.md index 9c461046..42cd872b 100644 --- a/docs/DDD1542/RESPONSIVE_GRID_SYSTEM_ARCHITECTURE.md +++ b/docs/DDD1542/RESPONSIVE_GRID_SYSTEM_ARCHITECTURE.md @@ -2,103 +2,166 @@ > 최종 업데이트: 2026-01-30 +--- + ## 1. 개요 -### 1.1 문제 정의 +### 1.1 현재 문제 -**현재 상황**: 컴포넌트 위치/크기가 픽셀 단위로 고정되어 반응형 미지원 +**컴포넌트 위치/크기가 픽셀 단위로 고정되어 반응형 미지원** ```json -// 현재 저장 방식 (screen_layouts_v2.layout_data) +// 현재 DB 저장 방식 (screen_layouts_v2.layout_data) { "position": { "x": 1753, "y": 88 }, "size": { "width": 158, "height": 40 } } ``` -**발생 문제**: -- 1920px 기준 설계 → 1280px 화면에서 버튼이 화면 밖으로 나감 -- 모바일/태블릿에서 레이아웃 완전히 깨짐 -- 화면 축소해도 컴포넌트 위치/크기 그대로 +| 화면 크기 | 결과 | +|-----------|------| +| 1920px (디자인 기준) | 정상 | +| 1280px (노트북) | 오른쪽 버튼 잘림 | +| 768px (태블릿) | 레이아웃 완전히 깨짐 | +| 375px (모바일) | 사용 불가 | ### 1.2 목표 | 목표 | 설명 | |------|------| -| **PC 대응** | 1280px ~ 1920px 화면에서 정상 동작 | -| **태블릿 대응** | 768px ~ 1024px 화면에서 레이아웃 재배치 | -| **모바일 대응** | 320px ~ 767px 화면에서 세로 스택 | -| **shadcn/Tailwind 활용** | 반응형 브레이크포인트 시스템 사용 | +| PC 대응 | 1280px ~ 1920px | +| 태블릿 대응 | 768px ~ 1024px | +| 모바일 대응 | 320px ~ 767px | -### 1.3 핵심 원칙 +### 1.3 해결 방향 ``` 현재: 픽셀 좌표 → position: absolute → 고정 레이아웃 -변경: 그리드 셀 번호 → CSS Grid + Tailwind → 반응형 레이아웃 +변경: 그리드 셀 번호 → CSS Grid + ResizeObserver → 반응형 레이아웃 ``` --- ## 2. 현재 시스템 분석 -### 2.1 기존 그리드 설정 (이미 존재) +### 2.1 데이터 현황 -```typescript -// frontend/components/screen/ScreenDesigner.tsx -gridSettings: { - columns: 12, // ✅ 이미 12컬럼 그리드 있음 - gap: 16, // ✅ 간격 설정 있음 - padding: 0, - snapToGrid: true, // ✅ 스냅 기능 있음 - showGrid: false, - gridColor: "#d1d5db", - gridOpacity: 0.5, -} +``` +총 레이아웃: 1,250개 +총 컴포넌트: 5,236개 +회사 수: 14개 +테이블 크기: 약 3MB ``` -### 2.2 현재 저장 방식 +### 2.2 컴포넌트 타입별 분포 -```typescript -// 드래그 후 저장되는 데이터 -{ - "id": "comp_1896", - "url": "@/lib/registry/components/v2-button-primary", - "position": { "x": 1753.33, "y": 88, "z": 1 }, // 픽셀 좌표 - "size": { "width": 158.67, "height": 40 }, // 픽셀 크기 - "overrides": { ... } -} -``` +| 컴포넌트 | 수량 | shadcn 사용 | +|----------|------|-------------| +| v2-input | 1,914 | ✅ `@/components/ui/input` | +| v2-button-primary | 1,549 | ✅ `@/components/ui/button` | +| v2-table-search-widget | 355 | ✅ shadcn 기반 | +| v2-select | 327 | ✅ `@/components/ui/select` | +| v2-table-list | 285 | ✅ `@/components/ui/table` | +| v2-media | 181 | ✅ shadcn 기반 | +| v2-date | 132 | ✅ `@/components/ui/calendar` | +| **v2-split-panel-layout** | **131** | ✅ shadcn 기반 (**반응형 필요**) | +| v2-tabs-widget | 75 | ✅ shadcn 기반 | +| 기타 | 287 | ✅ shadcn 기반 | +| **합계** | **5,236** | **전부 shadcn** | ### 2.3 현재 렌더링 방식 ```tsx // frontend/lib/registry/layouts/flexbox/FlexboxLayout.tsx (라인 234-248) -
+{components.map((child) => ( +
+ {renderer.renderChild(child)} +
+))} ``` -### 2.4 문제점 요약 +### 2.4 핵심 발견 -| 현재 | 문제 | -|------|------| -| 12컬럼 그리드 있음 | 스냅용으로만 사용, 저장은 픽셀 | -| position: 픽셀 좌표 | 화면 크기 변해도 위치 고정 | -| size: 픽셀 크기 | 화면 작아지면 넘침 | -| absolute 포지션 | 반응형 불가 | +``` +✅ 이미 있는 것: +- 12컬럼 그리드 설정 (gridSettings.columns: 12) +- 그리드 스냅 기능 (snapToGrid: true) +- shadcn/ui 기반 컴포넌트 (전체) + +❌ 없는 것: +- 그리드 셀 번호 저장 (현재 픽셀 저장) +- 반응형 브레이크포인트 설정 +- CSS Grid 기반 렌더링 +- 분할 패널 반응형 처리 +``` --- -## 3. 신규 데이터 구조 +## 3. 기술 결정 -### 3.1 layout_data 구조 변경 +### 3.1 왜 Tailwind 동적 클래스가 아닌 CSS Grid + Inline Style인가? + +**Tailwind 동적 클래스의 한계**: +```tsx +// ❌ 이건 안 됨 - Tailwind가 빌드 타임에 인식 못함 +className={`col-start-${col} md:col-start-${mdCol}`} + +// ✅ 이것만 됨 - 정적 클래스 +className="col-start-1 md:col-start-3" +``` + +Tailwind는 **빌드 타임**에 클래스를 스캔하므로, 런타임에 동적으로 생성되는 클래스는 인식하지 못합니다. + +**해결책: CSS Grid + Inline Style + ResizeObserver**: +```tsx +// ✅ 올바른 방법 +
+
+ {component} +
+
+``` + +### 3.2 역할 분담 + +| 영역 | 기술 | 설명 | +|------|------|------| +| **UI 컴포넌트** | shadcn/ui | 버튼, 인풋, 테이블 등 (이미 적용됨) | +| **레이아웃 배치** | CSS Grid + Inline Style | 컴포넌트 위치, 크기, 반응형 | +| **반응형 감지** | ResizeObserver | 화면 크기 감지 및 브레이크포인트 변경 | + +``` +┌─────────────────────────────────────────────────────────┐ +│ ResponsiveGridLayout (CSS Grid) │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ shadcn │ │ shadcn │ │ shadcn │ │ +│ │ Button │ │ Input │ │ Select │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ ┌─────────────────────────────────────────────┐ │ +│ │ shadcn Table │ │ +│ └─────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. 데이터 구조 변경 + +### 4.1 현재 구조 (V2) -**현재 구조**: ```json { "version": "2.0", @@ -112,24 +175,27 @@ gridSettings: { } ``` -**변경 후 구조**: +### 4.2 변경 후 구조 (V2 + 그리드) + ```json { - "version": "3.0", + "version": "2.0", "layoutMode": "grid", "components": [{ "id": "comp_xxx", "url": "@/lib/registry/components/v2-button-primary", + "position": { "x": 1753, "y": 88, "z": 1 }, + "size": { "width": 158, "height": 40 }, "grid": { "col": 11, "row": 2, - "colSpan": 2, + "colSpan": 1, "rowSpan": 1 }, "responsive": { "sm": { "col": 1, "colSpan": 12 }, "md": { "col": 7, "colSpan": 6 }, - "lg": { "col": 11, "colSpan": 2 } + "lg": { "col": 11, "colSpan": 1 } }, "overrides": { ... } }], @@ -141,12 +207,11 @@ gridSettings: { } ``` -### 3.2 필드 설명 +### 4.3 필드 설명 | 필드 | 타입 | 설명 | |------|------|------| -| `version` | string | "3.0" (반응형 그리드 버전) | -| `layoutMode` | string | "grid" (그리드 레이아웃 사용) | +| `layoutMode` | string | "grid" (반응형 그리드 사용) | | `grid.col` | number | 시작 컬럼 (1-12) | | `grid.row` | number | 시작 행 (1부터) | | `grid.colSpan` | number | 차지하는 컬럼 수 | @@ -155,19 +220,17 @@ gridSettings: { | `responsive.md` | object | 태블릿 (768px ~ 1024px) 설정 | | `responsive.lg` | object | 데스크톱 (> 1024px) 설정 | -### 3.3 반응형 브레이크포인트 +### 4.4 호환성 -| 브레이크포인트 | 화면 크기 | 기본 동작 | -|----------------|-----------|-----------| -| `sm` | < 768px | 모든 컴포넌트 12컬럼 (세로 스택) | -| `md` | 768px ~ 1024px | 컬럼 수 2배로 확장 | -| `lg` | > 1024px | 원본 그리드 위치 유지 | +- `position`, `size` 필드는 유지 (디자인 모드 + 폴백용) +- `layoutMode`가 없으면 기존 방식(absolute) 사용 +- 마이그레이션 후에도 기존 화면 정상 동작 --- -## 4. 변환 로직 +## 5. 구현 상세 -### 4.1 픽셀 → 그리드 변환 함수 +### 5.1 그리드 변환 유틸리티 ```typescript // frontend/lib/utils/gridConverter.ts @@ -177,154 +240,44 @@ const COLUMNS = 12; const COLUMN_WIDTH = DESIGN_WIDTH / COLUMNS; // 160px const ROW_HEIGHT = 80; -interface PixelPosition { - x: number; - y: number; -} - -interface PixelSize { - width: number; - height: number; -} - -interface GridPosition { - col: number; - row: number; - colSpan: number; - rowSpan: number; -} - -interface ResponsiveConfig { - sm: { col: number; colSpan: number }; - md: { col: number; colSpan: number }; - lg: { col: number; colSpan: number }; -} - /** * 픽셀 좌표를 그리드 셀 번호로 변환 */ export function pixelToGrid( - position: PixelPosition, - size: PixelSize + position: { x: number; y: number }, + size: { width: number; height: number } ): GridPosition { - // 컬럼 계산 (1-based) - const col = Math.max(1, Math.min(12, Math.round(position.x / COLUMN_WIDTH) + 1)); - - // 행 계산 (1-based) - const row = Math.max(1, Math.round(position.y / ROW_HEIGHT) + 1); - - // 컬럼 스팬 계산 - const colSpan = Math.max(1, Math.min(12 - col + 1, Math.round(size.width / COLUMN_WIDTH))); - - // 행 스팬 계산 - const rowSpan = Math.max(1, Math.round(size.height / ROW_HEIGHT)); - - return { col, row, colSpan, rowSpan }; -} - -/** - * 그리드 셀 번호를 픽셀 좌표로 변환 (디자인 모드용) - */ -export function gridToPixel( - grid: GridPosition -): { position: PixelPosition; size: PixelSize } { return { - position: { - x: (grid.col - 1) * COLUMN_WIDTH, - y: (grid.row - 1) * ROW_HEIGHT, - }, - size: { - width: grid.colSpan * COLUMN_WIDTH, - height: grid.rowSpan * ROW_HEIGHT, - }, + col: Math.max(1, Math.min(12, Math.round(position.x / COLUMN_WIDTH) + 1)), + row: Math.max(1, Math.round(position.y / ROW_HEIGHT) + 1), + colSpan: Math.max(1, Math.round(size.width / COLUMN_WIDTH)), + rowSpan: Math.max(1, Math.round(size.height / ROW_HEIGHT)), }; } /** * 기본 반응형 설정 생성 */ -export function getDefaultResponsive( - grid: GridPosition -): ResponsiveConfig { +export function getDefaultResponsive(grid: GridPosition): ResponsiveConfig { return { - // 모바일: 전체 너비, 원래 순서대로 스택 - sm: { - col: 1, - colSpan: 12 - }, - // 태블릿: 컬럼 스팬 2배 (최대 12) + sm: { col: 1, colSpan: 12 }, // 모바일: 전체 너비 md: { - col: Math.max(1, Math.round((grid.col - 1) / 2) + 1), + col: Math.max(1, Math.round(grid.col / 2)), colSpan: Math.min(grid.colSpan * 2, 12) - }, - // 데스크톱: 원본 유지 - lg: { - col: grid.col, - colSpan: grid.colSpan - }, + }, // 태블릿: 2배 확장 + lg: { col: grid.col, colSpan: grid.colSpan }, // 데스크톱: 원본 }; } ``` -### 4.2 Tailwind 클래스 생성 함수 - -```typescript -// frontend/lib/utils/gridClassGenerator.ts - -/** - * 그리드 위치/크기를 Tailwind 클래스로 변환 - */ -export function generateGridClasses( - grid: GridPosition, - responsive: ResponsiveConfig -): string { - const classes: string[] = []; - - // 모바일 (기본) - classes.push(`col-start-${responsive.sm.col}`); - classes.push(`col-span-${responsive.sm.colSpan}`); - - // 태블릿 - classes.push(`md:col-start-${responsive.md.col}`); - classes.push(`md:col-span-${responsive.md.colSpan}`); - - // 데스크톱 - classes.push(`lg:col-start-${responsive.lg.col}`); - classes.push(`lg:col-span-${responsive.lg.colSpan}`); - - return classes.join(' '); -} -``` - -**주의**: Tailwind는 빌드 타임에 클래스를 결정하므로, 동적 클래스 생성 시 safelist 설정 필요 - -```javascript -// tailwind.config.js -module.exports = { - safelist: [ - // 그리드 컬럼 시작 - { pattern: /col-start-(1|2|3|4|5|6|7|8|9|10|11|12)/ }, - { pattern: /md:col-start-(1|2|3|4|5|6|7|8|9|10|11|12)/ }, - { pattern: /lg:col-start-(1|2|3|4|5|6|7|8|9|10|11|12)/ }, - // 그리드 컬럼 스팬 - { pattern: /col-span-(1|2|3|4|5|6|7|8|9|10|11|12)/ }, - { pattern: /md:col-span-(1|2|3|4|5|6|7|8|9|10|11|12)/ }, - { pattern: /lg:col-span-(1|2|3|4|5|6|7|8|9|10|11|12)/ }, - ], -} -``` - ---- - -## 5. 렌더링 컴포넌트 수정 - -### 5.1 ResponsiveGridLayout 컴포넌트 +### 5.2 반응형 그리드 레이아웃 컴포넌트 ```tsx // frontend/lib/registry/layouts/responsive-grid/ResponsiveGridLayout.tsx -import { cn } from "@/lib/utils"; -import { generateGridClasses } from "@/lib/utils/gridClassGenerator"; +import React, { useRef, useState, useEffect } from "react"; + +type Breakpoint = "sm" | "md" | "lg"; interface ResponsiveGridLayoutProps { layout: LayoutData; @@ -337,35 +290,52 @@ export function ResponsiveGridLayout({ isDesignMode, renderer, }: ResponsiveGridLayoutProps) { - const { gridSettings, components } = layout; - + const containerRef = useRef(null); + const [breakpoint, setBreakpoint] = useState("lg"); + + // 화면 크기 감지 + useEffect(() => { + if (!containerRef.current) return; + + const observer = new ResizeObserver((entries) => { + const width = entries[0].contentRect.width; + if (width < 768) setBreakpoint("sm"); + else if (width < 1024) setBreakpoint("md"); + else setBreakpoint("lg"); + }); + + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); + + const gridSettings = layout.gridSettings || { columns: 12, rowHeight: 80, gap: 16 }; + return (
- {components + {layout.components .sort((a, b) => (a.grid?.row || 0) - (b.grid?.row || 0)) .map((component) => { - const gridClasses = generateGridClasses( - component.grid, - component.responsive - ); - + // 반응형 설정 가져오기 + const gridConfig = component.responsive?.[breakpoint] || component.grid; + const { col, colSpan } = gridConfig; + const rowSpan = component.grid?.rowSpan || 1; + return (
{renderer.renderChild(component)}
@@ -376,269 +346,325 @@ export function ResponsiveGridLayout({ } ``` -### 5.2 렌더링 결과 예시 +### 5.3 브레이크포인트 훅 -**데스크톱 (lg: 1024px+)**: -``` -┌─────────────────────────────────────────────────────────┐ -│ [분리] [저장] [수정] [삭제] │ ← 버튼들 오른쪽 정렬 -├─────────────────────────────────────────────────────────┤ -│ │ -│ 테이블 컴포넌트 │ -│ │ -└─────────────────────────────────────────────────────────┘ +```typescript +// frontend/lib/registry/layouts/responsive-grid/useBreakpoint.ts + +import { useState, useEffect, RefObject } from "react"; + +type Breakpoint = "sm" | "md" | "lg"; + +export function useBreakpoint(containerRef: RefObject): Breakpoint { + const [breakpoint, setBreakpoint] = useState("lg"); + + useEffect(() => { + if (!containerRef.current) return; + + const observer = new ResizeObserver((entries) => { + const width = entries[0].contentRect.width; + if (width < 768) setBreakpoint("sm"); + else if (width < 1024) setBreakpoint("md"); + else setBreakpoint("lg"); + }); + + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, [containerRef]); + + return breakpoint; +} ``` -**태블릿 (md: 768px ~ 1024px)**: -``` -┌───────────────────────────────┐ -│ [분리] [저장] [수정] [삭제] │ ← 버튼들 2개씩 -├───────────────────────────────┤ -│ │ -│ 테이블 컴포넌트 │ -│ │ -└───────────────────────────────┘ -``` +### 5.4 분할 패널 반응형 수정 -**모바일 (sm: < 768px)**: -``` -┌─────────────────┐ -│ [분리] │ -│ [저장] │ -│ [수정] │ ← 세로 스택 -│ [삭제] │ -├─────────────────┤ -│ 테이블 컴포넌트 │ -│ (스크롤) │ -└─────────────────┘ +```tsx +// frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx + +// 추가할 코드 +const containerRef = useRef(null); +const [isMobile, setIsMobile] = useState(false); + +useEffect(() => { + if (!containerRef.current) return; + + const observer = new ResizeObserver((entries) => { + const width = entries[0].contentRect.width; + setIsMobile(width < 768); + }); + + observer.observe(containerRef.current); + return () => observer.disconnect(); +}, []); + +// 렌더링 부분 수정 +return ( +
+
+ {/* 좌측/상단 패널 */} +
+
+ {/* 우측/하단 패널 */} +
+
+); ``` --- -## 6. 마이그레이션 계획 - -### 6.1 데이터 마이그레이션 스크립트 - -```sql --- 기존 데이터를 V3 구조로 변환하는 함수 -CREATE OR REPLACE FUNCTION migrate_layout_to_v3(layout_data JSONB) -RETURNS JSONB AS $$ -DECLARE - result JSONB; - component JSONB; - new_components JSONB := '[]'::JSONB; - grid_col INT; - grid_row INT; - col_span INT; - row_span INT; -BEGIN - -- 각 컴포넌트 변환 - FOR component IN SELECT * FROM jsonb_array_elements(layout_data->'components') - LOOP - -- 픽셀 → 그리드 변환 (160px = 1컬럼, 80px = 1행) - grid_col := GREATEST(1, LEAST(12, ROUND((component->'position'->>'x')::NUMERIC / 160) + 1)); - grid_row := GREATEST(1, ROUND((component->'position'->>'y')::NUMERIC / 80) + 1); - col_span := GREATEST(1, LEAST(13 - grid_col, ROUND((component->'size'->>'width')::NUMERIC / 160))); - row_span := GREATEST(1, ROUND((component->'size'->>'height')::NUMERIC / 80)); - - -- 새 컴포넌트 구조 생성 - component := component || jsonb_build_object( - 'grid', jsonb_build_object( - 'col', grid_col, - 'row', grid_row, - 'colSpan', col_span, - 'rowSpan', row_span - ), - 'responsive', jsonb_build_object( - 'sm', jsonb_build_object('col', 1, 'colSpan', 12), - 'md', jsonb_build_object('col', GREATEST(1, ROUND(grid_col / 2.0)), 'colSpan', LEAST(col_span * 2, 12)), - 'lg', jsonb_build_object('col', grid_col, 'colSpan', col_span) - ) - ); - - -- position, size 필드 제거 (선택사항 - 호환성 위해 유지 가능) - -- component := component - 'position' - 'size'; - - new_components := new_components || component; - END LOOP; - - -- 결과 생성 - result := jsonb_build_object( - 'version', '3.0', - 'layoutMode', 'grid', - 'components', new_components, - 'gridSettings', COALESCE(layout_data->'gridSettings', '{"columns": 12, "rowHeight": 80, "gap": 16}'::JSONB) - ); - - RETURN result; -END; -$$ LANGUAGE plpgsql; - --- 마이그레이션 실행 -UPDATE screen_layouts_v2 -SET layout_data = migrate_layout_to_v3(layout_data) -WHERE (layout_data->>'version') = '2.0'; -``` - -### 6.2 백워드 호환성 - -V2 ↔ V3 호환을 위한 변환 레이어: +## 6. 렌더링 분기 처리 ```typescript -// frontend/lib/utils/layoutVersionConverter.ts +// frontend/lib/registry/DynamicComponentRenderer.tsx -export function normalizeLayout(layout: any): NormalizedLayout { - const version = layout.version || "2.0"; - - if (version === "2.0") { - // V2 → V3 변환 (렌더링 시) - return { - ...layout, - version: "3.0", - layoutMode: "grid", - components: layout.components.map((comp: any) => ({ - ...comp, - grid: pixelToGrid(comp.position, comp.size), - responsive: getDefaultResponsive(pixelToGrid(comp.position, comp.size)), - })), - }; +function renderLayout(layout: LayoutData) { + // layoutMode에 따라 분기 + if (layout.layoutMode === "grid") { + return ; } - return layout; // V3는 그대로 + // 기존 방식 (폴백) + return ; } ``` --- -## 7. 디자인 모드 수정 +## 7. 마이그레이션 -### 7.1 그리드 편집 UI +### 7.1 백업 -디자인 모드에서 그리드 셀 선택 방식 추가: - -```tsx -// 기존: 픽셀 좌표 입력 - updatePosition({ x })} -/> - -// 변경: 그리드 셀 선택 -
- {Array.from({ length: 12 }).map((_, col) => ( -
setGridCol(col + 1)} - /> - ))} -
- -
- -
+```sql +-- 마이그레이션 전 백업 +CREATE TABLE screen_layouts_v2_backup_20260130 AS +SELECT * FROM screen_layouts_v2; ``` -### 7.2 반응형 미리보기 +### 7.2 마이그레이션 스크립트 -```tsx -// 화면 크기 미리보기 버튼 -
- - - -
+```sql +-- grid, responsive 필드 추가 +UPDATE screen_layouts_v2 +SET layout_data = ( + SELECT jsonb_set( + jsonb_set( + layout_data, + '{layoutMode}', + '"grid"' + ), + '{components}', + ( + SELECT jsonb_agg( + comp || jsonb_build_object( + 'grid', jsonb_build_object( + 'col', GREATEST(1, LEAST(12, ROUND((comp->'position'->>'x')::NUMERIC / 160) + 1)), + 'row', GREATEST(1, ROUND((comp->'position'->>'y')::NUMERIC / 80) + 1), + 'colSpan', GREATEST(1, ROUND((comp->'size'->>'width')::NUMERIC / 160)), + 'rowSpan', GREATEST(1, ROUND((comp->'size'->>'height')::NUMERIC / 80)) + ), + 'responsive', jsonb_build_object( + 'sm', jsonb_build_object('col', 1, 'colSpan', 12), + 'md', jsonb_build_object( + 'col', GREATEST(1, ROUND((ROUND((comp->'position'->>'x')::NUMERIC / 160) + 1) / 2.0)), + 'colSpan', LEAST(ROUND((comp->'size'->>'width')::NUMERIC / 160) * 2, 12) + ), + 'lg', jsonb_build_object( + 'col', GREATEST(1, LEAST(12, ROUND((comp->'position'->>'x')::NUMERIC / 160) + 1)), + 'colSpan', GREATEST(1, ROUND((comp->'size'->>'width')::NUMERIC / 160)) + ) + ) + ) + ) + FROM jsonb_array_elements(layout_data->'components') as comp + ) + ) +); +``` -// 미리보기 컨테이너 -
- -
+### 7.3 롤백 + +```sql +-- 문제 발생 시 롤백 +DROP TABLE screen_layouts_v2; +ALTER TABLE screen_layouts_v2_backup_20260130 RENAME TO screen_layouts_v2; ``` --- -## 8. 작업 목록 +## 8. 동작 흐름 -### Phase 1: 핵심 유틸리티 (1일) +### 8.1 데스크톱 (> 1024px) -| 작업 | 파일 | 상태 | -|------|------|------| -| 그리드 변환 함수 | `lib/utils/gridConverter.ts` | ⬜ | -| 클래스 생성 함수 | `lib/utils/gridClassGenerator.ts` | ⬜ | -| Tailwind safelist 설정 | `tailwind.config.js` | ⬜ | +``` +┌────────────────────────────────────────────────────────────┐ +│ 1 2 3 4 5 6 7 8 9 10 │ 11 12 │ │ +│ │ [버튼] │ │ +├────────────────────────────────────────────────────────────┤ +│ │ +│ 테이블 (12컬럼) │ +│ │ +└────────────────────────────────────────────────────────────┘ +``` -### Phase 2: 렌더링 수정 (1일) +### 8.2 태블릿 (768px ~ 1024px) -| 작업 | 파일 | 상태 | -|------|------|------| -| ResponsiveGridLayout 생성 | `lib/registry/layouts/responsive-grid/` | ⬜ | -| 레이아웃 버전 분기 처리 | `lib/registry/DynamicComponentRenderer.tsx` | ⬜ | +``` +┌─────────────────────────────────────┐ +│ 1 2 3 4 5 6 │ 7 8 9 10 11 12 │ +│ │ [버튼] │ +├─────────────────────────────────────┤ +│ │ +│ 테이블 (12컬럼) │ +│ │ +└─────────────────────────────────────┘ +``` -### Phase 3: 저장 로직 수정 (1일) +### 8.3 모바일 (< 768px) -| 작업 | 파일 | 상태 | -|------|------|------| -| 저장 시 그리드 변환 | `components/screen/ScreenDesigner.tsx` | ⬜ | -| V3 레이아웃 변환기 | `lib/utils/layoutV3Converter.ts` | ⬜ | +``` +┌──────────────────┐ +│ [버튼] │ ← 12컬럼 (전체 너비) +├──────────────────┤ +│ │ +│ 테이블 (스크롤) │ ← 12컬럼 (전체 너비) +│ │ +└──────────────────┘ +``` -### Phase 4: 디자인 모드 UI (1일) +### 8.4 분할 패널 (반응형) -| 작업 | 파일 | 상태 | -|------|------|------| -| 그리드 셀 편집 UI | `components/screen/panels/V2PropertiesPanel.tsx` | ⬜ | -| 반응형 미리보기 | `components/screen/ScreenDesigner.tsx` | ⬜ | +**데스크톱**: +``` +┌─────────────────────────┬─────────────────────────┐ +│ 좌측 패널 (60%) │ 우측 패널 (40%) │ +└─────────────────────────┴─────────────────────────┘ +``` -### Phase 5: 마이그레이션 (0.5일) - -| 작업 | 파일 | 상태 | -|------|------|------| -| 마이그레이션 스크립트 | `db/migrations/xxx_migrate_to_v3.sql` | ⬜ | -| 백워드 호환성 테스트 | - | ⬜ | +**모바일**: +``` +┌─────────────────────────┐ +│ 상단 패널 (이전 좌측) │ +├─────────────────────────┤ +│ 하단 패널 (이전 우측) │ +└─────────────────────────┘ +``` --- -## 9. 예상 일정 +## 9. 수정 파일 목록 -| 단계 | 기간 | 완료 기준 | -|------|------|-----------| -| Phase 1 | 1일 | 유틸리티 함수 테스트 통과 | -| Phase 2 | 1일 | 그리드 렌더링 정상 동작 | -| Phase 3 | 1일 | 저장/로드 정상 동작 | -| Phase 4 | 1일 | 디자인 모드 UI 완성 | -| Phase 5 | 0.5일 | 기존 데이터 마이그레이션 완료 | -| 테스트 | 0.5일 | 모든 화면 반응형 테스트 | -| **합계** | **5일** | | +### 9.1 새로 생성 + +| 파일 | 설명 | +|------|------| +| `lib/utils/gridConverter.ts` | 픽셀 → 그리드 변환 유틸리티 | +| `lib/registry/layouts/responsive-grid/ResponsiveGridLayout.tsx` | CSS Grid 레이아웃 | +| `lib/registry/layouts/responsive-grid/useBreakpoint.ts` | ResizeObserver 훅 | +| `lib/registry/layouts/responsive-grid/index.ts` | 모듈 export | + +### 9.2 수정 + +| 파일 | 수정 내용 | +|------|-----------| +| `lib/registry/DynamicComponentRenderer.tsx` | layoutMode 분기 추가 | +| `components/screen/ScreenDesigner.tsx` | 저장 시 grid/responsive 생성 | +| `v2-split-panel-layout/SplitPanelLayoutComponent.tsx` | 반응형 처리 추가 | + +### 9.3 수정 없음 + +| 파일 | 이유 | +|------|------| +| `v2-input/*` | 레이아웃과 무관 (shadcn 그대로) | +| `v2-button-primary/*` | 레이아웃과 무관 (shadcn 그대로) | +| `v2-table-list/*` | 레이아웃과 무관 (shadcn 그대로) | +| `v2-select/*` | 레이아웃과 무관 (shadcn 그대로) | +| **...모든 v2 컴포넌트** | **수정 불필요** | --- -## 10. 리스크 및 대응 +## 10. 작업 일정 -| 리스크 | 영향 | 대응 방안 | -|--------|------|-----------| -| 기존 레이아웃 깨짐 | 높음 | position/size 필드 유지하여 폴백 | -| Tailwind 동적 클래스 | 중간 | safelist로 모든 클래스 사전 정의 | -| 디자인 모드 혼란 | 낮음 | 그리드 가이드라인 시각화 | +| Phase | 작업 | 파일 | 시간 | +|-------|------|------|------| +| **1** | 그리드 변환 유틸리티 | `gridConverter.ts` | 2시간 | +| **1** | 브레이크포인트 훅 | `useBreakpoint.ts` | 1시간 | +| **2** | ResponsiveGridLayout | `ResponsiveGridLayout.tsx` | 4시간 | +| **2** | 렌더링 분기 처리 | `DynamicComponentRenderer.tsx` | 1시간 | +| **3** | 저장 로직 수정 | `ScreenDesigner.tsx` | 2시간 | +| **3** | 분할 패널 반응형 | `SplitPanelLayoutComponent.tsx` | 3시간 | +| **4** | 마이그레이션 스크립트 | SQL | 2시간 | +| **4** | 마이그레이션 실행 | - | 1시간 | +| **5** | 테스트 및 버그 수정 | - | 4시간 | +| | **합계** | | **약 2.5일** | --- -## 11. 참고 자료 +## 11. 체크리스트 + +### 개발 전 + +- [ ] screen_layouts_v2 백업 완료 +- [ ] 개발 환경에서 테스트 데이터 준비 + +### Phase 1: 유틸리티 + +- [ ] `gridConverter.ts` 생성 +- [ ] `useBreakpoint.ts` 생성 +- [ ] 단위 테스트 작성 + +### Phase 2: 레이아웃 + +- [ ] `ResponsiveGridLayout.tsx` 생성 +- [ ] `DynamicComponentRenderer.tsx` 분기 추가 +- [ ] 기존 화면 정상 동작 확인 + +### Phase 3: 저장/수정 + +- [ ] `ScreenDesigner.tsx` 저장 로직 수정 +- [ ] `SplitPanelLayoutComponent.tsx` 반응형 추가 +- [ ] 디자인 모드 테스트 + +### Phase 4: 마이그레이션 + +- [ ] 마이그레이션 스크립트 테스트 (개발 DB) +- [ ] 운영 DB 백업 +- [ ] 마이그레이션 실행 +- [ ] 검증 + +### Phase 5: 테스트 + +- [ ] PC (1920px, 1280px) 테스트 +- [ ] 태블릿 (768px, 1024px) 테스트 +- [ ] 모바일 (375px, 414px) 테스트 +- [ ] 분할 패널 화면 테스트 + +--- + +## 12. 리스크 및 대응 + +| 리스크 | 영향 | 대응 | +|--------|------|------| +| 마이그레이션 실패 | 높음 | 백업 테이블에서 즉시 롤백 | +| 기존 화면 깨짐 | 중간 | `layoutMode` 없으면 기존 방식 사용 (폴백) | +| 디자인 모드 혼란 | 낮음 | position/size 필드 유지 | + +--- + +## 13. 참고 - [COMPONENT_LAYOUT_V2_ARCHITECTURE.md](./COMPONENT_LAYOUT_V2_ARCHITECTURE.md) - V2 아키텍처 -- [Tailwind CSS Grid](https://tailwindcss.com/docs/grid-template-columns) - 그리드 시스템 +- [CSS Grid Layout - MDN](https://developer.mozilla.org/ko/docs/Web/CSS/CSS_Grid_Layout) +- [ResizeObserver - MDN](https://developer.mozilla.org/ko/docs/Web/API/ResizeObserver) - [shadcn/ui](https://ui.shadcn.com/) - 컴포넌트 라이브러리 diff --git a/frontend/components/screen/CopyScreenModal.tsx b/frontend/components/screen/CopyScreenModal.tsx index f1e49f9c..24f8231e 100644 --- a/frontend/components/screen/CopyScreenModal.tsx +++ b/frontend/components/screen/CopyScreenModal.tsx @@ -597,7 +597,7 @@ export default function CopyScreenModal({ screen_id: result.mainScreen.screenId, screen_role: "MAIN", display_order: 1, - target_company_code: finalCompanyCode, // 대상 회사 코드 전달 + target_company_code: targetCompanyCode || sourceScreen.companyCode, // 대상 회사 코드 전달 }); console.log(`✅ 복제된 화면을 그룹(${selectedTargetGroupId})에 추가 완료`); } catch (groupError) { @@ -606,8 +606,68 @@ export default function CopyScreenModal({ } } + // 추가 복사 옵션 처리 (단일 화면 복제용) + const sourceCompanyCode = sourceScreen.companyCode; + const copyTargetCompanyCode = targetCompanyCode || sourceCompanyCode; + let additionalCopyMessages: string[] = []; + + // 채번규칙 복제 + if (copyNumberingRules && sourceCompanyCode !== copyTargetCompanyCode) { + try { + console.log("📋 단일 화면: 채번규칙 복제 시작..."); + const numberingResult = await apiClient.post("/api/screen-management/copy-numbering-rules", { + sourceCompanyCode, + targetCompanyCode: copyTargetCompanyCode + }); + if (numberingResult.data.success) { + additionalCopyMessages.push(`채번규칙 ${numberingResult.data.copiedCount || 0}개`); + console.log("✅ 채번규칙 복제 완료:", numberingResult.data); + } + } catch (err: any) { + console.error("채번규칙 복제 실패:", err); + } + } + + // 카테고리 값 복제 + if (copyCategoryValues && sourceCompanyCode !== copyTargetCompanyCode) { + try { + console.log("📋 단일 화면: 카테고리 값 복제 시작..."); + const categoryResult = await apiClient.post("/api/screen-management/copy-category-mapping", { + sourceCompanyCode, + targetCompanyCode: copyTargetCompanyCode + }); + if (categoryResult.data.success) { + additionalCopyMessages.push(`카테고리 값 ${categoryResult.data.copiedValues || 0}개`); + console.log("✅ 카테고리 값 복제 완료:", categoryResult.data); + } + } catch (err: any) { + console.error("카테고리 값 복제 실패:", err); + } + } + + // 테이블 타입 컬럼 복제 + if (copyTableTypeColumns && sourceCompanyCode !== copyTargetCompanyCode) { + try { + console.log("📋 단일 화면: 테이블 타입 컬럼 복제 시작..."); + const tableTypeResult = await apiClient.post("/api/screen-management/copy-table-type-columns", { + sourceCompanyCode, + targetCompanyCode: copyTargetCompanyCode + }); + if (tableTypeResult.data.success) { + additionalCopyMessages.push(`테이블 타입 컬럼 ${tableTypeResult.data.copiedCount || 0}개`); + console.log("✅ 테이블 타입 컬럼 복제 완료:", tableTypeResult.data); + } + } catch (err: any) { + console.error("테이블 타입 컬럼 복제 실패:", err); + } + } + + const additionalInfo = additionalCopyMessages.length > 0 + ? ` + 추가: ${additionalCopyMessages.join(", ")}` + : ""; + toast.success( - `화면 복사가 완료되었습니다! (메인 1개 + 모달 ${result.modalScreens.length}개)` + `화면 복사가 완료되었습니다! (메인 1개 + 모달 ${result.modalScreens.length}개${additionalInfo})` ); // 새로고침 완료 후 모달 닫기 @@ -1678,6 +1738,50 @@ export default function CopyScreenModal({
)} + {/* 추가 복사 옵션 (단일 화면 복제용) */} +
+ + + {/* 채번규칙 복제 */} +
+ setCopyNumberingRules(checked === true)} + /> + +
+ + {/* 카테고리 값 복사 */} +
+ setCopyCategoryValues(checked === true)} + /> +