feat: 화면 그룹 및 서브 테이블 관련 로직 개선

- 화면 그룹 조회 시 삭제된 화면(is_active = 'D')을 제외하도록 쿼리를 수정하였습니다.
- 화면 서브 테이블 API에서 전역 메인 테이블 목록을 수집하여, 메인 테이블과 서브 테이블의 우선순위를 적용하였습니다.
- 화면 삭제 시 연결된 화면 그룹의 관계를 해제하는 로직을 추가하였습니다.
- 화면 관계 흐름에서 연결된 화면들을 추가하는 로직을 개선하여, 그룹 모드와 개별 화면 모드에서의 동작을 명확히 하였습니다.
- 관련 문서 및 주석을 업데이트하여 새로운 기능에 대한 이해를 돕도록 하였습니다.
This commit is contained in:
DDD1542 2026-02-03 15:50:23 +09:00
parent dd1ddd6418
commit ef9f1b94ff
8 changed files with 510 additions and 64 deletions

View File

@ -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);

View File

@ -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 레이아웃 저장 완료`);
}
}

View File

@ -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) => (
<div style={{
position: "absolute",
left: child.position?.x || 0,
top: child.position?.y || 0,
}}>
{renderer.renderChild(child)}
</div>
))}
```
**호환성**: ✅ **호환** (내부는 기존 방식 유지)
- 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 내부 깨짐 | 낮음 | 내부는 기존 방식 유지, 외부 배치만 변경 |
| 중첩 레이아웃 문제 | 낮음 | 각 레이아웃 컴포넌트는 독립적으로 동작 |
---

View File

@ -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<Set<string>>(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 아키텍처

View File

@ -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 |

View File

@ -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 (
<div
className={`group relative flex w-[260px] flex-col overflow-visible rounded-xl border shadow-md ${
// 필터 관련 테이블 (마스터 또는 디테일): 보라색
(hasFilterRelation || isFilterSource)
// 1. 필터 테이블 (마스터-디테일의 디테일 테이블): 항상 보라색 테두리
isFilterTable
? "border-2 border-violet-500 ring-2 ring-violet-500/20 shadow-lg bg-violet-50/50"
// 2. 필터 관련 테이블 (마스터 또는 디테일) 포커스 시: 진한 보라색
: (hasFilterRelation || isFilterSource)
? "border-2 border-violet-500 ring-4 ring-violet-500/30 shadow-xl bg-violet-50"
// 순수 포커스 (필터 관계 없음): 초록색
// 3. 순수 포커스 (필터 관계 없음): 초록색
: isFocused
? "border-2 border-emerald-500 ring-4 ring-emerald-500/30 shadow-xl bg-card"
// 흐리게 처리
// 4. 흐리게 처리
: isFaded
? "border-gray-200 opacity-60 bg-card"
// 기본
// 5. 기본
: "border-border hover:shadow-lg hover:ring-2 hover:ring-emerald-500/20 bg-card"
}`}
style={{

View File

@ -147,6 +147,19 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 강제 새로고침용 키 (설정 저장 후 시각화 재로딩)
const [refreshKey, setRefreshKey] = useState(0);
// 화면 삭제/추가 시 노드 플로워 새로고침 (screen-list-refresh 이벤트 구독)
useEffect(() => {
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<Record<number, Record<string, string[]>>>({});
// 전역 메인 테이블 목록 (우선순위: 메인 > 서브)
// 이 목록에 있는 테이블은 서브 테이블로 분류되지 않음
const [globalMainTables, setGlobalMainTables] = useState<Set<string>>(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<number, ScreenSubTablesData>;
// 서브 테이블 데이터 저장 (조인 컬럼 정보 포함)
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<string>(); // 마스터-디테일의 디테일 테이블들
globalMainTables.forEach((tableName) => {
if (!mainTableSet.has(tableName)) {
mainTableSet.add(tableName);
filterTableSet.add(tableName); // 필터 테이블로 분류 (보라색 테두리)
}
});
// 2. 서브 테이블 수집 (mainTableSet에 없는 것만)
const newScreenSubTableMap: Record<number, string[]> = {};
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에서 포커스 상태에 따라 동적으로 설정
},

3
frontend/stagewise.json Normal file
View File

@ -0,0 +1,3 @@
{
"appPort": 9771
}