diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index df0c4f4d..32ce60c3 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -46,11 +46,13 @@ export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) const countResult = await pool.query(countQuery, params); const total = parseInt(countResult.rows[0].total); - // 데이터 조회 (screens 배열 포함) + // 데이터 조회 (screens 배열 포함) - 삭제된 화면(is_active = 'D') 제외 const dataQuery = ` SELECT sg.*, - (SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count, + (SELECT COUNT(*) FROM screen_group_screens sgs + LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id + WHERE sgs.group_id = sg.id AND sd.is_active != 'D') as screen_count, (SELECT json_agg( json_build_object( 'id', sgs.id, @@ -64,6 +66,7 @@ export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) ) FROM screen_group_screens sgs LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id WHERE sgs.group_id = sg.id + AND sd.is_active != 'D' ) as screens FROM screen_groups sg ${whereClause} @@ -111,6 +114,7 @@ export const getScreenGroup = async (req: AuthenticatedRequest, res: Response) = ) FROM screen_group_screens sgs LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id WHERE sgs.group_id = sg.id + AND sd.is_active != 'D' ) as screens FROM screen_groups sg WHERE sg.id = $1 @@ -1737,7 +1741,9 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons }); // 4. rightPanel.relation 파싱 (split-panel-layout 등에서 사용) + // screen_layouts (v1)와 screen_layouts_v2 모두 조회 const rightPanelQuery = ` + -- V1: screen_layouts에서 조회 SELECT sd.screen_id, sd.screen_name, @@ -1750,6 +1756,23 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons JOIN screen_layouts sl ON sd.screen_id = sl.screen_id WHERE sd.screen_id = ANY($1) AND sl.properties->'componentConfig'->'rightPanel'->'relation' IS NOT NULL + + UNION ALL + + -- V2: screen_layouts_v2에서 조회 (v2-split-panel-layout 컴포넌트) + SELECT + sd.screen_id, + sd.screen_name, + sd.table_name as main_table, + comp->'overrides'->>'type' as component_type, + comp->'overrides'->'rightPanel'->'relation' as right_panel_relation, + comp->'overrides'->'rightPanel'->>'tableName' as right_panel_table, + comp->'overrides'->'rightPanel'->'columns' as right_panel_columns + FROM screen_definitions sd + JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id, + jsonb_array_elements(slv2.layout_data->'components') as comp + WHERE sd.screen_id = ANY($1) + AND comp->'overrides'->'rightPanel'->'relation' IS NOT NULL `; const rightPanelResult = await pool.query(rightPanelQuery, [screenIds]); @@ -2118,9 +2141,56 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons })) }); + // ============================================================ + // 6. 전역 메인 테이블 목록 수집 (우선순위 적용용) + // ============================================================ + // 메인 테이블 조건: + // 1. screen_definitions.table_name (컴포넌트 직접 연결) + // 2. v2-split-panel-layout의 rightPanel.tableName (WHERE 조건 대상) + // + // 이 목록에 있으면 서브 테이블로 분류되지 않음 (우선순위: 메인 > 서브) + const globalMainTablesQuery = ` + -- 1. 모든 화면의 메인 테이블 (screen_definitions.table_name) + SELECT DISTINCT table_name as main_table + FROM screen_definitions + WHERE screen_id = ANY($1) + AND table_name IS NOT NULL + + UNION + + -- 2. v2-split-panel-layout의 rightPanel.tableName (WHERE 조건 대상) + -- 현재 그룹의 화면들에서 마스터-디테일로 연결된 테이블 + SELECT DISTINCT comp->'overrides'->'rightPanel'->>'tableName' as main_table + FROM screen_definitions sd + JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id, + jsonb_array_elements(slv2.layout_data->'components') as comp + WHERE sd.screen_id = ANY($1) + AND comp->'overrides'->'rightPanel'->>'tableName' IS NOT NULL + + UNION + + -- 3. v1 screen_layouts의 rightPanel.tableName (WHERE 조건 대상) + SELECT DISTINCT sl.properties->'componentConfig'->'rightPanel'->>'tableName' as main_table + FROM screen_definitions sd + JOIN screen_layouts sl ON sd.screen_id = sl.screen_id + WHERE sd.screen_id = ANY($1) + AND sl.properties->'componentConfig'->'rightPanel'->>'tableName' IS NOT NULL + `; + + const globalMainTablesResult = await pool.query(globalMainTablesQuery, [screenIds]); + const globalMainTables = globalMainTablesResult.rows + .map((r: any) => r.main_table) + .filter((t: string) => t != null && t !== ''); + + logger.info("전역 메인 테이블 목록 수집 완료", { + count: globalMainTables.length, + tables: globalMainTables + }); + res.json({ success: true, data: screenSubTables, + globalMainTables: globalMainTables, // 메인 테이블로 분류되어야 하는 테이블 목록 }); } catch (error: any) { logger.error("화면 서브 테이블 정보 조회 실패:", error); diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 572f2443..37a21a0a 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -731,6 +731,14 @@ export class ScreenManagementService { WHERE screen_id = $1 AND is_active = 'Y'`, [screenId], ); + + // 5. 화면 그룹 연결 삭제 (screen_group_screens) + await client.query( + `DELETE FROM screen_group_screens WHERE screen_id = $1`, + [screenId], + ); + + logger.info("화면 삭제 시 그룹 연결 해제", { screenId }); }); } @@ -5110,18 +5118,6 @@ export class ScreenManagementService { console.log( `V2 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`, ); - - // 🐛 디버깅: finished_timeline의 fieldMapping 확인 - const splitPanel = layout.layout_data?.components?.find((c: any) => - c.url?.includes("v2-split-panel-layout") - ); - const finishedTimeline = splitPanel?.overrides?.rightPanel?.components?.find( - (c: any) => c.id === "finished_timeline" - ); - if (finishedTimeline) { - console.log("🐛 [Backend] finished_timeline fieldMapping:", JSON.stringify(finishedTimeline.componentConfig?.fieldMapping)); - } - return layout.layout_data; } @@ -5161,20 +5157,16 @@ export class ScreenManagementService { ...layoutData }; - // SUPER_ADMIN인 경우 화면 정의의 company_code로 저장 (로드와 일관성 유지) - const saveCompanyCode = companyCode === "*" ? existingScreen.company_code : companyCode; - console.log(`저장할 company_code: ${saveCompanyCode} (원본: ${companyCode}, 화면 정의: ${existingScreen.company_code})`); - // UPSERT (있으면 업데이트, 없으면 삽입) await 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()`, - [screenId, saveCompanyCode, JSON.stringify(dataToSave)], + [screenId, companyCode, JSON.stringify(dataToSave)], ); - console.log(`V2 레이아웃 저장 완료 (company_code: ${saveCompanyCode})`); + console.log(`V2 레이아웃 저장 완료`); } } diff --git a/docs/DDD1542/RESPONSIVE_GRID_SYSTEM_ARCHITECTURE.md b/docs/DDD1542/RESPONSIVE_GRID_SYSTEM_ARCHITECTURE.md index 42cd872b..411fdd1f 100644 --- a/docs/DDD1542/RESPONSIVE_GRID_SYSTEM_ARCHITECTURE.md +++ b/docs/DDD1542/RESPONSIVE_GRID_SYSTEM_ARCHITECTURE.md @@ -103,6 +103,162 @@ - 분할 패널 반응형 처리 ``` +### 2.5 레이아웃 시스템 구조 + +현재 시스템에는 두 가지 레벨의 레이아웃이 존재합니다: + +#### 2.5.1 화면 레이아웃 (screen_layouts_v2) + +화면 전체의 컴포넌트 배치를 담당합니다. + +```json +// DB 구조 +{ + "version": "2.0", + "components": [ + { "id": "comp_1", "position": { "x": 100, "y": 50 }, ... }, + { "id": "comp_2", "position": { "x": 500, "y": 50 }, ... }, + { "id": "GridLayout_1", "position": { "x": 100, "y": 200 }, ... } + ] +} +``` + +**현재**: absolute 포지션으로 컴포넌트 배치 → **반응형 불가** + +#### 2.5.2 컴포넌트 레이아웃 (GridLayout, FlexboxLayout 등) + +개별 레이아웃 컴포넌트 내부의 zone 배치를 담당합니다. + +| 컴포넌트 | 위치 | 내부 구조 | CSS Grid 사용 | +|----------|------|-----------|---------------| +| `GridLayout` | `layouts/grid/` | zones 배열 | ✅ 이미 사용 | +| `FlexboxLayout` | `layouts/flexbox/` | zones 배열 | ❌ absolute | +| `SplitLayout` | `layouts/split/` | left/right | ❌ flex | +| `TabsLayout` | `layouts/` | tabs 배열 | ❌ 탭 구조 | +| `CardLayout` | `layouts/card-layout/` | zones 배열 | ❌ flex | +| `AccordionLayout` | `layouts/accordion/` | items 배열 | ❌ 아코디언 | + +#### 2.5.3 구조 다이어그램 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ screen_layouts_v2 (화면 전체) │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ 현재: absolute 포지션 → 반응형 불가 │ │ +│ │ 변경: ResponsiveGridLayout (CSS Grid) → 반응형 가능 │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌─────────────────────────────┐ │ +│ │ v2-button │ │ v2-input │ │ GridLayout (컴포넌트) │ │ +│ │ (shadcn) │ │ (shadcn) │ │ ┌─────────┬─────────────┐ │ │ +│ └──────────┘ └──────────┘ │ │ zone1 │ zone2 │ │ │ +│ │ │ (이미 │ (이미 │ │ │ +│ │ │ CSS Grid│ CSS Grid) │ │ │ +│ │ └─────────┴─────────────┘ │ │ +│ └─────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2.6 기존 레이아웃 컴포넌트 호환성 + +#### 2.6.1 GridLayout (기존 커스텀 그리드) + +```tsx +// frontend/lib/registry/layouts/grid/GridLayout.tsx +// 이미 CSS Grid를 사용하고 있음! + +const gridStyle: React.CSSProperties = { + display: "grid", + gridTemplateRows: `repeat(${gridConfig.rows}, 1fr)`, + gridTemplateColumns: `repeat(${gridConfig.columns}, 1fr)`, + gap: `${gridConfig.gap || 16}px`, +}; +``` + +**호환성**: ✅ **완전 호환** +- GridLayout은 화면 내 하나의 컴포넌트로 취급됨 +- ResponsiveGridLayout이 GridLayout의 **위치만** 관리 +- GridLayout 내부는 기존 방식 그대로 동작 + +#### 2.6.2 FlexboxLayout + +```tsx +// frontend/lib/registry/layouts/flexbox/FlexboxLayout.tsx +// zone 내부에서 컴포넌트를 absolute로 배치 + +{zoneChildren.map((child) => ( +
+ {renderer.renderChild(child)} +
+))} +``` + +**호환성**: ✅ **호환** (내부는 기존 방식 유지) +- FlexboxLayout 컴포넌트 자체의 위치는 ResponsiveGridLayout이 관리 +- 내부 zone의 컴포넌트 배치는 기존 absolute 방식 유지 + +#### 2.6.3 SplitPanelLayout (분할 패널) + +**호환성**: ⚠️ **별도 수정 필요** +- 외부 위치: ResponsiveGridLayout이 관리 ✅ +- 내부 반응형: 별도 수정 필요 (모바일에서 상하 분할) + +#### 2.6.4 호환성 요약 + +| 컴포넌트 | 외부 배치 | 내부 동작 | 추가 수정 | +|----------|----------|----------|-----------| +| **v2-button, v2-input 등** | ✅ 반응형 | ✅ shadcn 그대로 | ❌ 불필요 | +| **GridLayout** | ✅ 반응형 | ✅ CSS Grid 그대로 | ❌ 불필요 | +| **FlexboxLayout** | ✅ 반응형 | ⚠️ absolute 유지 | ❌ 불필요 | +| **SplitPanelLayout** | ✅ 반응형 | ❌ 좌우 고정 | ⚠️ 내부 반응형 추가 | +| **TabsLayout** | ✅ 반응형 | ✅ 탭 그대로 | ❌ 불필요 | + +### 2.7 동작 방식 비교 + +#### 변경 전 + +``` +화면 로드 + ↓ +screen_layouts_v2에서 components 조회 + ↓ +각 컴포넌트를 position.x, position.y로 absolute 배치 + ↓ +GridLayout 컴포넌트도 absolute로 배치됨 + ↓ +GridLayout 내부는 CSS Grid로 zone 배치 + ↓ +결과: 화면 크기 변해도 모든 컴포넌트 위치 고정 +``` + +#### 변경 후 + +``` +화면 로드 + ↓ +screen_layouts_v2에서 components 조회 + ↓ +layoutMode === "grid" 확인 + ↓ +ResponsiveGridLayout으로 렌더링 (CSS Grid) + ↓ +각 컴포넌트를 grid.col, grid.colSpan으로 배치 + ↓ +화면 크기 감지 (ResizeObserver) + ↓ +breakpoint에 따라 responsive.sm/md/lg 적용 + ↓ +GridLayout 컴포넌트도 반응형으로 배치됨 + ↓ +GridLayout 내부는 기존 CSS Grid로 zone 배치 (변경 없음) + ↓ +결과: 화면 크기에 따라 컴포넌트 재배치 +``` + --- ## 3. 기술 결정 @@ -649,6 +805,10 @@ ALTER TABLE screen_layouts_v2_backup_20260130 RENAME TO screen_layouts_v2; - [ ] 태블릿 (768px, 1024px) 테스트 - [ ] 모바일 (375px, 414px) 테스트 - [ ] 분할 패널 화면 테스트 +- [ ] GridLayout 컴포넌트 포함 화면 테스트 +- [ ] FlexboxLayout 컴포넌트 포함 화면 테스트 +- [ ] TabsLayout 컴포넌트 포함 화면 테스트 +- [ ] 중첩 레이아웃 (GridLayout 안에 컴포넌트) 테스트 --- @@ -659,6 +819,8 @@ ALTER TABLE screen_layouts_v2_backup_20260130 RENAME TO screen_layouts_v2; | 마이그레이션 실패 | 높음 | 백업 테이블에서 즉시 롤백 | | 기존 화면 깨짐 | 중간 | `layoutMode` 없으면 기존 방식 사용 (폴백) | | 디자인 모드 혼란 | 낮음 | position/size 필드 유지 | +| GridLayout 내부 깨짐 | 낮음 | 내부는 기존 방식 유지, 외부 배치만 변경 | +| 중첩 레이아웃 문제 | 낮음 | 각 레이아웃 컴포넌트는 독립적으로 동작 | --- diff --git a/docs/DDD1542/화면관계_시각화_개선_보고서.md b/docs/DDD1542/화면관계_시각화_개선_보고서.md index 27946afa..aea92243 100644 --- a/docs/DDD1542/화면관계_시각화_개선_보고서.md +++ b/docs/DDD1542/화면관계_시각화_개선_보고서.md @@ -23,7 +23,8 @@ | 테이블명 | 용도 | 주요 컬럼 | |----------|------|----------| | `screen_definitions` | 화면 정의 정보 | `screen_id`, `screen_name`, `table_name`, `company_code` | -| `screen_layouts` | 화면 레이아웃/컴포넌트 정보 | `screen_id`, `properties` (JSONB - componentConfig 포함) | +| `screen_layouts` | 화면 레이아웃/컴포넌트 정보 (Legacy) | `screen_id`, `properties` (JSONB - componentConfig 포함) | +| `screen_layouts_v2` | 화면 레이아웃/컴포넌트 정보 (V2) | `screen_id`, `layout_data` (JSONB - components 배열) | | `screen_groups` | 화면 그룹 정보 | `group_id`, `group_code`, `group_name`, `parent_group_id` | | `screen_group_mappings` | 화면-그룹 매핑 | `group_id`, `screen_id`, `display_order` | @@ -86,9 +87,17 @@ screen_groups (그룹) │ │ │ └─── screen_definitions (화면) │ │ - │ └─── screen_layouts (레이아웃/컴포넌트) + │ ├─── screen_layouts (Legacy) + │ │ │ + │ │ └─── properties.componentConfig + │ │ ├── fieldMappings + │ │ ├── parentDataMapping + │ │ ├── columns.mapping + │ │ └── rightPanel.relation + │ │ + │ └─── screen_layouts_v2 (V2) ← 현재 표준 │ │ - │ └─── properties.componentConfig + │ └─── layout_data.components[].overrides │ ├── fieldMappings │ ├── parentDataMapping │ ├── columns.mapping @@ -1120,9 +1129,12 @@ screenSubTables[screenId].subTables.push({ 21. [x] 필터 연결선 포커싱 제어 (해당 화면 포커싱 시에만 표시) 22. [x] 저장 테이블 제외 조건 추가 (table-list + 체크박스 + openModalWithData) 23. [x] 첫 진입 시 포커싱 없이 시작 (트리에서 화면 클릭 시 그룹만 진입) -24. [ ] **선 교차점 이질감 해결** (계획 중) -22. [ ] 범례 UI 추가 (선택사항) -23. [ ] 엣지 라벨에 관계 유형 표시 (선택사항) +24. [x] **screen_layouts_v2 지원 추가** (rightPanel.relation V2 UNION 쿼리) ✅ 2026-01-30 +25. [x] **테이블 분류 우선순위 시스템** (메인 > 서브 우선순위 적용) ✅ 2026-01-30 +26. [x] **globalMainTables API 추가** (WHERE 조건 대상 테이블 목록 반환) ✅ 2026-01-30 +27. [ ] **선 교차점 이질감 해결** (계획 중) +28. [ ] 범례 UI 추가 (선택사항) +29. [ ] 엣지 라벨에 관계 유형 표시 (선택사항) --- @@ -1682,6 +1694,149 @@ frontend/ --- +## 테이블 분류 우선순위 시스템 (2026-01-30) + +### 배경 + +마스터-디테일 관계의 디테일 테이블(예: `user_dept`)이 다른 곳에서 autocomplete 참조로도 사용되는 경우, +서브 테이블 영역에 잘못 배치되는 문제가 발생했습니다. + +### 문제 상황 + +``` +[user_info] - 화면 139의 디테일 → 메인 테이블 영역 (O) +[user_dept] - 화면 162의 디테일이지만 autocomplete 참조도 있음 → 서브 테이블 영역 (X) +``` + +**원인**: 테이블 분류 시 우선순위가 없어서 먼저 발견된 관계 타입으로 분류됨 + +### 해결책: 우선순위 기반 테이블 분류 + +#### 분류 규칙 + +| 우선순위 | 분류 | 조건 | 비고 | +|----------|------|------|------| +| **1순위** | 메인 테이블 | `screen_definitions.table_name` | 컴포넌트 직접 연결 | +| **1순위** | 메인 테이블 | `v2-split-panel-layout.rightPanel.tableName` | WHERE 조건 대상 | +| **2순위** | 서브 테이블 | 조인으로만 연결된 테이블 | autocomplete 등 참조 | + +#### 핵심 규칙 + +> **메인 조건에 해당하면, 서브 조건이 있어도 무조건 메인으로 분류** + +### 백엔드 변경 (`screenGroupController.ts`) + +#### 1. screen_layouts_v2 지원 추가 + +`rightPanelQuery`에 V2 테이블 UNION 추가: + +```sql +-- V1: screen_layouts에서 조회 +SELECT ... +FROM screen_definitions sd +JOIN screen_layouts sl ON sd.screen_id = sl.screen_id +WHERE sl.properties->'componentConfig'->'rightPanel'->'relation' IS NOT NULL + +UNION ALL + +-- V2: screen_layouts_v2에서 조회 (v2-split-panel-layout 컴포넌트) +SELECT + sd.screen_id, + comp->'overrides'->>'type' as component_type, + comp->'overrides'->'rightPanel'->'relation' as right_panel_relation, + comp->'overrides'->'rightPanel'->>'tableName' as right_panel_table, + ... +FROM screen_definitions sd +JOIN screen_layouts_v2 slv2 ON sd.screen_id = slv2.screen_id, +jsonb_array_elements(slv2.layout_data->'components') as comp +WHERE comp->'overrides'->'rightPanel'->'relation' IS NOT NULL +``` + +#### 2. globalMainTables API 추가 + +`getScreenSubTables` 응답에 전역 메인 테이블 목록 추가: + +```sql +-- 모든 화면의 메인 테이블 수집 +SELECT DISTINCT table_name as main_table FROM screen_definitions WHERE screen_id = ANY($1) +UNION +SELECT DISTINCT comp->'overrides'->'rightPanel'->>'tableName' as main_table +FROM screen_layouts_v2 ... +``` + +**응답 구조:** +```typescript +res.json({ + success: true, + data: screenSubTables, + globalMainTables: globalMainTables, // 메인 테이블 목록 추가 +}); +``` + +### 프론트엔드 변경 (`ScreenRelationFlow.tsx`) + +#### 1. globalMainTables 상태 추가 + +```typescript +const [globalMainTables, setGlobalMainTables] = useState>(new Set()); +``` + +#### 2. 우선순위 기반 테이블 분류 + +```typescript +// 1. globalMainTables를 mainTableSet에 먼저 추가 (우선순위 적용) +globalMainTables.forEach((tableName) => { + if (!mainTableSet.has(tableName)) { + mainTableSet.add(tableName); + filterTableSet.add(tableName); // 보라색 테두리 + } +}); + +// 2. 서브 테이블 수집 (mainTableSet에 없는 것만) +screenSubData.subTables.forEach((subTable) => { + if (mainTableSet.has(subTable.tableName)) { + return; // 메인 테이블은 서브에서 제외 + } + subTableSet.add(subTable.tableName); +}); +``` + +### 시각적 결과 + +#### 변경 전 + +``` +[화면 노드들] + │ + ▼ +[메인 테이블: dept_info, user_info] ← user_dept 없음 + │ + ▼ +[서브 테이블: user_dept, customer_mng] ← user_dept가 잘못 배치됨 +``` + +#### 변경 후 + +``` +[화면 노드들] + │ + ▼ +[메인 테이블: dept_info, user_info, user_dept] ← user_dept 보라색 테두리 + │ + ▼ +[서브 테이블: customer_mng] ← 조인 참조용 테이블만 +``` + +### 관련 파일 + +| 파일 | 변경 내용 | +|------|----------| +| `backend-node/src/controllers/screenGroupController.ts` | screen_layouts_v2 UNION 추가, globalMainTables 반환 | +| `frontend/components/screen/ScreenRelationFlow.tsx` | globalMainTables 상태, 우선순위 분류 로직 | +| `frontend/components/screen/ScreenNode.tsx` | isFilterTable prop 및 보라색 테두리 스타일 | + +--- + ## 화면 설정 모달 개선 (2026-01-12) ### 개요 @@ -1742,4 +1897,6 @@ npm install react-zoom-pan-pinch - [멀티테넌시 구현 가이드](.cursor/rules/multi-tenancy-guide.mdc) - [API 클라이언트 사용 규칙](.cursor/rules/api-client-usage.mdc) - [관리자 페이지 스타일 가이드](.cursor/rules/admin-page-style-guide.mdc) +- [화면 복제 V2 마이그레이션 계획서](../SCREEN_COPY_V2_MIGRATION_PLAN.md) - screen_layouts_v2 복제 로직 +- [V2 컴포넌트 마이그레이션 분석](../V2_COMPONENT_MIGRATION_ANALYSIS.md) - V2 아키텍처 diff --git a/docs/SCREEN_COPY_V2_MIGRATION_PLAN.md b/docs/SCREEN_COPY_V2_MIGRATION_PLAN.md index 7e1afcba..bcb2a765 100644 --- a/docs/SCREEN_COPY_V2_MIGRATION_PLAN.md +++ b/docs/SCREEN_COPY_V2_MIGRATION_PLAN.md @@ -467,9 +467,9 @@ V2 전환 롤백 (필요시): - [x] copyScreens() - Legacy 제거, V2로 교체 ✅ 2026-01-28 - [x] hasLayoutChangesV2() 함수 추가 ✅ 2026-01-28 - [x] updateTabScreenReferences() V2 지원 추가 ✅ 2026-01-28 -- [ ] 단위 테스트 통과 -- [ ] 통합 테스트 통과 -- [ ] V2 전용 복제 동작 확인 +- [x] 단위 테스트 통과 ✅ 2026-01-30 +- [x] 통합 테스트 통과 ✅ 2026-01-30 +- [x] V2 전용 복제 동작 확인 ✅ 2026-01-30 ### 9.3 Phase 2 완료 조건 @@ -522,3 +522,4 @@ V2 전환 롤백 (필요시): | 2026-01-28 | 시뮬레이션 검증 - updateTabScreenReferences V2 지원 추가 | Claude | | 2026-01-28 | V2 경로 지원 추가 - action/sections 직접 경로 (componentConfig 없이) | Claude | | 2026-01-30 | **실제 코드 구현 완료** - copyScreen(), copyScreens() V2 전환 | Claude | +| 2026-01-30 | **Phase 1 테스트 완료** - 단위/통합 테스트 통과 확인 | Claude | diff --git a/frontend/components/screen/ScreenNode.tsx b/frontend/components/screen/ScreenNode.tsx index e49bf6d8..bcaaf054 100644 --- a/frontend/components/screen/ScreenNode.tsx +++ b/frontend/components/screen/ScreenNode.tsx @@ -58,6 +58,7 @@ export interface TableNodeData { label: string; subLabel?: string; isMain?: boolean; + isFilterTable?: boolean; // 마스터-디테일의 디테일 테이블인지 (보라색 테두리) isFocused?: boolean; // 포커스된 테이블인지 isFaded?: boolean; // 흑백 처리할지 columns?: Array<{ @@ -448,7 +449,7 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: // ========== 테이블 노드 (하단) - 컬럼 목록 표시 (컴팩트) ========== export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { - const { label, subLabel, isMain, isFocused, isFaded, columns, highlightedColumns, joinColumns, joinColumnRefs, filterColumns, fieldMappings, referencedBy, saveInfos } = data; + const { label, subLabel, isMain, isFilterTable, isFocused, isFaded, columns, highlightedColumns, joinColumns, joinColumnRefs, filterColumns, fieldMappings, referencedBy, saveInfos } = data; // 강조할 컬럼 세트 (영문 컬럼명 기준) const highlightSet = new Set(highlightedColumns || []); @@ -574,16 +575,19 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { return (
{ + const handleScreenListRefresh = () => { + // refreshKey 증가로 데이터 재로드 트리거 + setRefreshKey(prev => prev + 1); + }; + + window.addEventListener("screen-list-refresh", handleScreenListRefresh); + return () => { + window.removeEventListener("screen-list-refresh", handleScreenListRefresh); + }; + }, []); + // 그룹 또는 화면이 변경될 때 포커스 초기화 useEffect(() => { setFocusedScreenId(null); @@ -170,6 +183,10 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId // 화면별 사용 컬럼 매핑 (화면 ID -> 테이블명 -> 사용 컬럼들) const [screenUsedColumnsMap, setScreenUsedColumnsMap] = useState>>({}); + + // 전역 메인 테이블 목록 (우선순위: 메인 > 서브) + // 이 목록에 있는 테이블은 서브 테이블로 분류되지 않음 + const [globalMainTables, setGlobalMainTables] = useState>(new Set()); // 테이블 컬럼 정보 로드 const loadTableColumns = useCallback( @@ -266,24 +283,26 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId const flows = flowsRes.success ? flowsRes.data || [] : []; const relations = relationsRes.success ? relationsRes.data || [] : []; - // 데이터 흐름에서 연결된 화면들 추가 - flows.forEach((flow: any) => { - if (flow.source_screen_id === screen.screenId && flow.target_screen_id) { - const exists = screenList.some((s) => s.screenId === flow.target_screen_id); - if (!exists) { - screenList.push({ - screenId: flow.target_screen_id, - screenName: flow.target_screen_name || `화면 ${flow.target_screen_id}`, - screenCode: "", - tableName: "", - companyCode: screen.companyCode, - isActive: "Y", - createdDate: new Date(), - updatedDate: new Date(), - } as ScreenDefinition); + // 데이터 흐름에서 연결된 화면들 추가 (개별 화면 모드에서만 - 그룹 모드에서는 그룹 내 화면만 표시) + if (!selectedGroup && screen) { + flows.forEach((flow: any) => { + if (flow.source_screen_id === screen.screenId && flow.target_screen_id) { + const exists = screenList.some((s) => s.screenId === flow.target_screen_id); + if (!exists) { + screenList.push({ + screenId: flow.target_screen_id, + screenName: flow.target_screen_name || `화면 ${flow.target_screen_id}`, + screenCode: "", + tableName: "", + companyCode: screen.companyCode, + isActive: "Y", + createdDate: new Date(), + updatedDate: new Date(), + } as ScreenDefinition); + } } - } - }); + }); + } // 화면 레이아웃 요약 정보 로드 const screenIds = screenList.map((s) => s.screenId); @@ -305,6 +324,13 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId subTablesData = subTablesRes.data as Record; // 서브 테이블 데이터 저장 (조인 컬럼 정보 포함) setSubTablesDataMap(subTablesData); + + // 전역 메인 테이블 목록 저장 (우선순위 적용용) + // 이 목록에 있는 테이블은 서브 테이블로 분류되지 않음 + const globalMainTablesArr = (subTablesRes as any).globalMainTables as string[] | undefined; + if (globalMainTablesArr && Array.isArray(globalMainTablesArr)) { + setGlobalMainTables(new Set(globalMainTablesArr)); + } } } catch (e) { console.error("레이아웃 요약/서브 테이블 로드 실패:", e); @@ -434,9 +460,27 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId if (rel.table_name) mainTableSet.add(rel.table_name); }); - // 서브 테이블 수집 (componentConfig에서 추출된 테이블들) - // 서브 테이블은 메인 테이블과 다른 테이블들 - // 화면별 서브 테이블 매핑도 함께 구축 + // ============================================================ + // 테이블 분류 (우선순위: 메인 > 서브) + // ============================================================ + // 메인 테이블 조건: + // 1. screen_definitions.table_name (컴포넌트 직접 연결) - 이미 mainTableSet에 추가됨 + // 2. globalMainTables (WHERE 조건 대상, 마스터-디테일의 디테일 테이블) + // + // 서브 테이블 조건: + // - 조인(JOIN)으로만 연결된 테이블 (autocomplete 등에서 참조) + // - 단, mainTableSet에 있으면 제외 (우선순위: 메인 > 서브) + + // 1. globalMainTables를 mainTableSet에 먼저 추가 (우선순위 적용) + const filterTableSet = new Set(); // 마스터-디테일의 디테일 테이블들 + globalMainTables.forEach((tableName) => { + if (!mainTableSet.has(tableName)) { + mainTableSet.add(tableName); + filterTableSet.add(tableName); // 필터 테이블로 분류 (보라색 테두리) + } + }); + + // 2. 서브 테이블 수집 (mainTableSet에 없는 것만) const newScreenSubTableMap: Record = {}; Object.entries(subTablesData).forEach(([screenIdStr, screenSubData]) => { @@ -444,11 +488,14 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId const subTableNames: string[] = []; screenSubData.subTables.forEach((subTable) => { - // 메인 테이블에 없는 것만 서브 테이블로 추가 - if (!mainTableSet.has(subTable.tableName)) { - subTableSet.add(subTable.tableName); - subTableNames.push(subTable.tableName); + // mainTableSet에 있으면 서브 테이블에서 제외 (우선순위: 메인 > 서브) + if (mainTableSet.has(subTable.tableName)) { + return; } + + // 조인으로만 연결된 테이블 → 서브 테이블 + subTableSet.add(subTable.tableName); + subTableNames.push(subTable.tableName); }); if (subTableNames.length > 0) { @@ -539,10 +586,19 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId isForeignKey: !!col.referenceTable || (col.columnName?.includes("_id") && col.columnName !== "id"), })); - // 여러 화면이 같은 테이블 사용하면 "공통 메인 테이블", 아니면 "메인 테이블" - const subLabel = linkedScreens.length > 1 - ? `메인 테이블 (${linkedScreens.length}개 화면)` - : "메인 테이블"; + // 테이블 분류에 따른 라벨 결정 + // 1. 필터 테이블 (마스터-디테일의 디테일): "필터 대상 테이블" + // 2. 여러 화면이 같은 테이블 사용: "공통 메인 테이블 (N개 화면)" + // 3. 일반 메인 테이블: "메인 테이블" + const isFilterTable = filterTableSet.has(tableName); + let subLabel: string; + if (isFilterTable) { + subLabel = "필터 대상 테이블 (마스터-디테일)"; + } else if (linkedScreens.length > 1) { + subLabel = `메인 테이블 (${linkedScreens.length}개 화면)`; + } else { + subLabel = "메인 테이블"; + } // 이 테이블을 참조하는 관계들 tableNodes.push({ @@ -552,7 +608,8 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId data: { label: tableName, subLabel: subLabel, - isMain: true, // mainTableSet의 모든 테이블은 메인 + isMain: !isFilterTable, // 필터 테이블은 isMain: false로 설정 (보라색 테두리 표시용) + isFilterTable: isFilterTable, // 필터 테이블 여부 표시 columns: formattedColumns, // referencedBy, filterColumns, saveInfos는 styledNodes에서 포커스 상태에 따라 동적으로 설정 }, diff --git a/frontend/stagewise.json b/frontend/stagewise.json new file mode 100644 index 00000000..d6cd6af2 --- /dev/null +++ b/frontend/stagewise.json @@ -0,0 +1,3 @@ +{ + "appPort": 9771 +} \ No newline at end of file