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)} + /> +