feat: 화면 그룹 및 서브 테이블 관련 로직 개선
- 화면 그룹 조회 시 삭제된 화면(is_active = 'D')을 제외하도록 쿼리를 수정하였습니다. - 화면 서브 테이블 API에서 전역 메인 테이블 목록을 수집하여, 메인 테이블과 서브 테이블의 우선순위를 적용하였습니다. - 화면 삭제 시 연결된 화면 그룹의 관계를 해제하는 로직을 추가하였습니다. - 화면 관계 흐름에서 연결된 화면들을 추가하는 로직을 개선하여, 그룹 모드와 개별 화면 모드에서의 동작을 명확히 하였습니다. - 관련 문서 및 주석을 업데이트하여 새로운 기능에 대한 이해를 돕도록 하였습니다.
This commit is contained in:
parent
dd1ddd6418
commit
ef9f1b94ff
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 레이아웃 저장 완료`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 내부 깨짐 | 낮음 | 내부는 기존 방식 유지, 외부 배치만 변경 |
|
||||
| 중첩 레이아웃 문제 | 낮음 | 각 레이아웃 컴포넌트는 독립적으로 동작 |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 아키텍처
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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에서 포커스 상태에 따라 동적으로 설정
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"appPort": 9771
|
||||
}
|
||||
Loading…
Reference in New Issue