diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts
index f0809386..9dd0da47 100644
--- a/backend-node/src/controllers/screenGroupController.ts
+++ b/backend-node/src/controllers/screenGroupController.ts
@@ -1382,6 +1382,58 @@ export const getScreenSubTables = async (req: Request, res: Response) => {
OR sl.properties->'componentConfig'->>'field' IS NOT NULL
OR sl.properties->>'field' IS NOT NULL
)
+
+ UNION
+
+ -- valueField 추출 (entity-search-input, autocomplete-search-input 등에서 사용)
+ SELECT
+ sd.screen_id,
+ sd.screen_name,
+ sd.table_name as main_table,
+ sl.properties->'componentConfig'->>'valueField' as column_name
+ 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'->>'valueField' IS NOT NULL
+
+ UNION
+
+ -- parentFieldId 추출 (캐스케이딩 관계에서 사용)
+ SELECT
+ sd.screen_id,
+ sd.screen_name,
+ sd.table_name as main_table,
+ sl.properties->'componentConfig'->>'parentFieldId' as column_name
+ 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'->>'parentFieldId' IS NOT NULL
+
+ UNION
+
+ -- cascadingParentField 추출 (캐스케이딩 부모 필드)
+ SELECT
+ sd.screen_id,
+ sd.screen_name,
+ sd.table_name as main_table,
+ sl.properties->'componentConfig'->>'cascadingParentField' as column_name
+ 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'->>'cascadingParentField' IS NOT NULL
+
+ UNION
+
+ -- controlField 추출 (conditional-container에서 사용)
+ SELECT
+ sd.screen_id,
+ sd.screen_name,
+ sd.table_name as main_table,
+ sl.properties->'componentConfig'->>'controlField' as column_name
+ 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'->>'controlField' IS NOT NULL
)
SELECT DISTINCT
suc.screen_id,
@@ -1398,6 +1450,7 @@ export const getScreenSubTables = async (req: Request, res: Response) => {
WHERE cl.reference_table IS NOT NULL
AND cl.reference_table != ''
AND cl.reference_table != suc.main_table
+ AND cl.input_type = 'entity'
ORDER BY suc.screen_id
`;
@@ -1625,13 +1678,27 @@ export const getScreenSubTables = async (req: Request, res: Response) => {
existingSubTable.fieldMappings!.push(newMapping);
}
});
+ // 추가 정보도 업데이트
+ if (relation?.type) {
+ (existingSubTable as any).originalRelationType = relation.type;
+ }
+ if (relation?.foreignKey) {
+ (existingSubTable as any).foreignKey = relation.foreignKey;
+ }
+ if (relation?.leftColumn) {
+ (existingSubTable as any).leftColumn = relation.leftColumn;
+ }
} else {
screenSubTables[screenId].subTables.push({
tableName: subTable,
componentType: componentType,
relationType: 'rightPanelRelation',
+ // 관계 유형 추론을 위한 추가 정보
+ originalRelationType: relation?.type || 'join', // 원본 relation.type ("join" | "detail")
+ foreignKey: relation?.foreignKey, // 디테일 테이블의 FK 컬럼
+ leftColumn: relation?.leftColumn, // 마스터 테이블의 선택 기준 컬럼
fieldMappings: fieldMappings.length > 0 ? fieldMappings : undefined,
- });
+ } as any);
}
});
diff --git a/docs/화면관계_시각화_개선_보고서.md b/docs/화면관계_시각화_개선_보고서.md
new file mode 100644
index 00000000..7dc11863
--- /dev/null
+++ b/docs/화면관계_시각화_개선_보고서.md
@@ -0,0 +1,1070 @@
+# 화면 관계 시각화 기능 개선 보고서
+
+## 개요
+
+화면 그룹 관리에서 ReactFlow를 사용한 화면-테이블 관계 시각화 기능의 범용성 및 정확성 개선 작업을 수행했습니다.
+
+---
+
+## 수정된 파일 목록
+
+| 파일 경로 | 역할 |
+|----------|------|
+| `backend-node/src/controllers/screenGroupController.ts` | 화면 서브테이블 정보 API |
+| `frontend/components/screen/ScreenRelationFlow.tsx` | ReactFlow 시각화 컴포넌트 |
+| `frontend/lib/api/screenGroup.ts` | API 인터페이스 정의 |
+
+---
+
+## 사용된 데이터베이스 테이블
+
+### 화면 정의 관련
+
+| 테이블명 | 용도 | 주요 컬럼 |
+|----------|------|----------|
+| `screen_definitions` | 화면 정의 정보 | `screen_id`, `screen_name`, `table_name`, `company_code` |
+| `screen_layouts` | 화면 레이아웃/컴포넌트 정보 | `screen_id`, `properties` (JSONB - componentConfig 포함) |
+| `screen_groups` | 화면 그룹 정보 | `group_id`, `group_code`, `group_name`, `parent_group_id` |
+| `screen_group_mappings` | 화면-그룹 매핑 | `group_id`, `screen_id`, `display_order` |
+
+### 메타데이터 관련
+
+| 테이블명 | 용도 | 주요 컬럼 |
+|----------|------|----------|
+| `column_labels` | 컬럼 한글명/참조 정보 | `table_name`, `column_name`, `column_label`, `reference_table`, `reference_column` |
+| `table_info` | 테이블 메타 정보 | `table_name`, `table_label`, `column_count` |
+
+### 조인 정보 추출 소스
+
+#### 1. column_labels 테이블 (테이블 관리에서 설정)
+
+```sql
+SELECT
+ cl.table_name,
+ cl.column_name,
+ cl.reference_table, -- 참조 테이블
+ cl.reference_column, -- 참조 컬럼
+ cl.column_label -- 한글명
+FROM column_labels cl
+WHERE cl.reference_table IS NOT NULL
+```
+
+#### 2. screen_layouts.properties (화면 컴포넌트에서 설정)
+
+```sql
+-- parentDataMapping 추출
+SELECT
+ sd.screen_id,
+ sl.properties->'componentConfig'->'parentDataMapping' as parent_data_mapping
+FROM screen_definitions sd
+JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
+WHERE sl.properties->'componentConfig'->'parentDataMapping' IS NOT NULL
+
+-- rightPanel.relation 추출
+SELECT
+ sd.screen_id,
+ sl.properties->'componentConfig'->'rightPanel'->'relation' as right_panel_relation
+FROM screen_definitions sd
+JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
+WHERE sl.properties->'componentConfig'->'rightPanel'->'relation' IS NOT NULL
+
+-- fieldMappings 추출
+SELECT
+ sd.screen_id,
+ sl.properties->'componentConfig'->'fieldMappings' as field_mappings
+FROM screen_definitions sd
+JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
+WHERE sl.properties->'componentConfig'->'fieldMappings' IS NOT NULL
+```
+
+### 테이블 관계도
+
+```
+screen_groups (그룹)
+ │
+ ├─── screen_group_mappings (매핑)
+ │ │
+ │ └─── screen_definitions (화면)
+ │ │
+ │ └─── screen_layouts (레이아웃/컴포넌트)
+ │ │
+ │ └─── properties.componentConfig
+ │ ├── fieldMappings
+ │ ├── parentDataMapping
+ │ ├── columns.mapping
+ │ └── rightPanel.relation
+ │
+ └─── column_labels (컬럼 메타데이터)
+ │
+ ├── reference_table (참조 테이블)
+ └── column_label (한글명)
+```
+
+---
+
+## 핵심 문제 및 해결
+
+### 1. 조인 컬럼 식별 오류
+
+#### 문제
+- `customer_item_mapping` 테이블에서 `customer_id`가 조인 컬럼으로 표시되지 않음
+- `customer_mng`가 `relationType: source`로 분류되어 `sourceField`가 잘못 사용됨
+
+#### 원인
+```typescript
+// 기존 잘못된 로직
+if (subTable.relationType === 'source') {
+ // sourceField를 메인테이블 컬럼으로 사용 (잘못됨)
+ joinColumns.push(mapping.sourceField);
+}
+```
+
+#### 해결
+```typescript
+// 수정된 범용 로직
+if (subTable.relationType === 'source' && mapping.sourceTable) {
+ // sourceTable이 있으면 parentDataMapping과 유사
+ // targetField가 메인테이블 컬럼
+ joinColumns.push(mapping.targetField);
+} else if (subTable.relationType === 'source') {
+ // 일반 source 타입
+ joinColumns.push(mapping.sourceField);
+}
+```
+
+---
+
+### 2. displayColumns 잘못된 처리
+
+#### 문제
+- `selected-items-detail-input` 컴포넌트의 `displayColumns`가 메인테이블 `joinColumns`에 추가됨
+- `customer_name`, `customer_code` 등이 조인 컬럼으로 잘못 표시됨
+
+#### 원인
+```typescript
+// 기존 잘못된 로직
+if (componentConfig.displayColumns) {
+ componentConfig.displayColumns.forEach((col) => {
+ joinColumns.push(col.name); // 연관 테이블 컬럼을 메인테이블에 추가 (잘못됨)
+ });
+}
+```
+
+#### 해결
+```typescript
+// 수정된 로직 - displayColumns는 연관 테이블 컬럼이므로 제외
+// 조인 컬럼은 parentDataMapping.targetField에서 별도 추출됨
+if (componentConfig.displayColumns) {
+ // 메인 테이블 joinColumns에 추가하지 않음
+}
+```
+
+---
+
+### 3. 조인 정보 한글명 미표시
+
+#### 문제
+- 조인 컬럼 옆에 `← customer_code` (영문)로 표시됨
+- `← 거래처 코드` (한글)로 표시되어야 함
+
+#### 해결
+백엔드에서 `column_labels` 테이블 조회하여 한글명 적용:
+
+```typescript
+// 모든 테이블/컬럼 조합 수집
+const columnLookups = [];
+screenSubTables.forEach((screenData) => {
+ screenData.subTables.forEach((subTable) => {
+ subTable.fieldMappings?.forEach((mapping) => {
+ if (mapping.sourceTable && mapping.sourceField) {
+ columnLookups.push({ tableName: mapping.sourceTable, columnName: mapping.sourceField });
+ }
+ if (screenData.mainTable && mapping.targetField) {
+ columnLookups.push({ tableName: screenData.mainTable, columnName: mapping.targetField });
+ }
+ });
+ });
+});
+
+// column_labels에서 한글명 조회
+const columnLabelsQuery = `
+ SELECT table_name, column_name, column_label
+ FROM column_labels
+ WHERE (table_name, column_name) IN (...)
+`;
+
+// 각 fieldMapping에 한글명 적용
+mapping.sourceDisplayName = columnLabelsMap[`${sourceTable}.${sourceField}`];
+mapping.targetDisplayName = columnLabelsMap[`${mainTable}.${targetField}`];
+```
+
+---
+
+### 4. 연결 선 라벨 제거 및 단일 로직화
+
+#### 문제
+- 테이블 간 연결 선에 `customer_code → customer_id` 라벨이 표시됨
+- 조인 정보가 테이블 노드 내부에 이미 표시되므로 중복
+- 메인-메인 테이블 조인 로직이 2개 존재 (주황색, 초록색)
+
+#### 해결
+1. **초록색 선 로직 완전 제거**: 중복 로직으로 인한 혼란 방지
+2. **주황색 선 로직 개선**: 모든 메인-메인 조인을 단일 로직으로 처리
+
+```typescript
+// 메인-메인 조인 엣지 생성 (단일 로직)
+const joinEdges: Edge[] = [];
+
+// 모든 화면의 메인 테이블 목록
+const allMainTables = new Set(Object.values(screenTableMap));
+
+focusedSubTablesData.subTables.forEach((subTable) => {
+ // 1. subTable.tableName이 다른 화면의 메인 테이블인 경우
+ const isTargetMainTable = allMainTables.has(subTable.tableName)
+ && subTable.tableName !== focusedMainTable;
+
+ if (isTargetMainTable) {
+ joinEdges.push({
+ id: `edge-main-join-${focusedScreenId}-${subTable.tableName}-${focusedMainTable}`,
+ source: `table-${subTable.tableName}`,
+ target: `table-${focusedMainTable}`,
+ type: 'smoothstep',
+ animated: true,
+ style: {
+ stroke: '#ea580c', // 주황색 (단일 색상)
+ strokeWidth: 2,
+ strokeDasharray: '8,4',
+ },
+ markerEnd: { type: MarkerType.ArrowClosed, color: '#ea580c' },
+ });
+ }
+
+ // 2. fieldMappings.sourceTable이 메인 테이블인 경우도 처리
+ // ... (parentMapping, rightPanelRelation 등)
+});
+```
+
+---
+
+### 5. 메인테이블 fieldMappings 미전달
+
+#### 문제
+- 서브테이블에만 `fieldMappings`가 전달되어 조인 정보 표시됨
+- 메인테이블에는 조인 컬럼 옆 연결 정보가 미표시
+
+#### 해결
+메인테이블과 연관테이블에 각각 `fieldMappings` 생성:
+
+```typescript
+// 메인테이블용 fieldMappings 생성
+let mainTableFieldMappings = [];
+if (isFocusedTable && focusedSubTablesData) {
+ focusedSubTablesData.subTables.forEach((subTable) => {
+ subTable.fieldMappings?.forEach((mapping) => {
+ if (subTable.relationType === 'source' && mapping.sourceTable) {
+ mainTableFieldMappings.push({
+ sourceField: mapping.sourceField,
+ targetField: mapping.targetField,
+ sourceDisplayName: mapping.sourceDisplayName,
+ targetDisplayName: mapping.targetDisplayName,
+ });
+ }
+ // ... 기타 relationType 처리
+ });
+ });
+}
+
+// 연관 테이블용 fieldMappings 생성
+let relatedTableFieldMappings = [];
+if (isRelatedTable && relatedTableInfo && focusedSubTablesData) {
+ // 이 테이블이 sourceTable인 경우의 매핑 추출
+}
+
+// node.data에 fieldMappings 전달
+return {
+ ...node,
+ data: {
+ ...node.data,
+ fieldMappings: isFocusedTable ? mainTableFieldMappings
+ : (isRelatedTable ? relatedTableFieldMappings : []),
+ },
+};
+```
+
+---
+
+### 6. 엣지 방향 오류 (참조 방향 반대)
+
+#### 문제
+- `customer_mng` 화면 포커스 시 `customer_item_mapping`으로 연결선이 표시됨
+- 실제로는 `customer_item_mapping.customer_id` → `customer_mng.customer_code` 참조 관계
+- 참조하는 쪽(A)이 아닌 참조당하는 쪽(B)에서 연결선이 나가는 오류
+
+#### 원인
+```typescript
+// 기존 잘못된 로직
+newEdges.push({
+ id: edgeId,
+ source: `table-${mainTable}`, // 참조당하는 테이블 (잘못됨)
+ target: `table-${subTable.tableName}`, // 참조하는 테이블 (잘못됨)
+ // ...
+});
+```
+
+#### 해결
+```typescript
+// 수정된 올바른 로직 - source/target 교환
+newEdges.push({
+ id: edgeId,
+ source: `table-${subTable.tableName}`, // 참조하는 테이블 (올바름)
+ target: `table-${mainTable}`, // 참조당하는 테이블 (올바름)
+ // ...
+});
+```
+
+#### 동작 예시
+| 관계 | 포커스된 화면 | 예상 동작 | 실제 동작 (수정 후) |
+|------|--------------|----------|-------------------|
+| A가 B를 참조 | A 포커스 | A→B 연결선 ✅ | A→B 연결선 ✅ |
+| A가 B를 참조 | B 포커스 | 연결선 없음 ✅ | 연결선 없음 ✅ |
+
+---
+
+### 7. column_labels 필터링 누락
+
+#### 문제
+- `inbound_mng.item_code`가 `item_info`를 참조하는 것으로 조인선 표시
+- 해당 필드는 과거 `entity` 타입이었다가 `text`로 변경됨
+- `reference_table` 데이터가 잔존하여 잘못된 조인 관계 생성
+
+#### 원인
+```sql
+-- 기존 잘못된 쿼리
+WHERE cl.reference_table IS NOT NULL
+ AND cl.reference_table != ''
+ AND cl.reference_table != suc.main_table
+ -- input_type 체크 없음!
+```
+
+#### 해결
+```sql
+-- 수정된 쿼리 - input_type = 'entity' 조건 추가
+WHERE cl.reference_table IS NOT NULL
+ AND cl.reference_table != ''
+ AND cl.reference_table != suc.main_table
+ AND cl.input_type = 'entity' -- 추가됨
+```
+
+---
+
+## NTT 설정 소스별 처리
+
+시스템에서 조인 정보가 정의되는 모든 소스를 범용적으로 처리합니다:
+
+| 소스 | 위치 | 처리 상태 |
+|------|------|----------|
+| `column_labels.reference_table` | 테이블 관리 | ✅ 처리됨 |
+| `componentConfig.fieldMappings` | autocomplete, entity-search | ✅ 처리됨 |
+| `componentConfig.columns.mapping` | modal-repeater-table | ✅ 처리됨 |
+| `componentConfig.parentDataMapping` | selected-items-detail-input | ✅ 처리됨 |
+| `componentConfig.rightPanel.relation` | split-panel-layout | ✅ 처리됨 |
+
+---
+
+## API 인터페이스 변경
+
+### FieldMappingInfo
+
+```typescript
+export interface FieldMappingInfo {
+ sourceTable?: string; // 연관 테이블명 (parentDataMapping에서 사용)
+ sourceField: string;
+ targetField: string;
+ sourceDisplayName?: string; // 연관 테이블 컬럼 한글명
+ targetDisplayName?: string; // 메인 테이블 컬럼 한글명
+}
+```
+
+### SubTableInfo
+
+```typescript
+export interface SubTableInfo {
+ tableName: string;
+ componentType: string;
+ relationType: 'lookup' | 'source' | 'join' | 'reference' | 'parentMapping' | 'rightPanelRelation';
+ fieldMappings?: FieldMappingInfo[];
+}
+```
+
+---
+
+## 테스트 결과
+
+### 판매품목정보 그룹 (3번째 화면 포커스)
+
+| 테이블 | 컬럼 | 표시 내용 |
+|--------|------|----------|
+| `customer_item_mapping` | 거래처 ID | ← 거래처 코드 (조인) |
+| `customer_item_mapping` | 품목 ID | ← 품번 (조인) |
+| `customer_mng` | 거래처 코드 | ← 거래처 ID (조인) |
+| `item_info` | 품번 | ← 품목 ID (조인) |
+
+### 수주관리 그룹 (기존 정상 작동 확인)
+
+모든 조인 관계 및 컬럼 표시 정상 작동
+
+---
+
+## 범용성 검증
+
+| 항목 | 상태 |
+|------|------|
+| 특정 테이블명 하드코딩 | ❌ 없음 |
+| 특정 컬럼명 하드코딩 | ❌ 없음 |
+| 조건 기반 분기 | ✅ `sourceTable` 존재 여부, `relationType`으로 판단 |
+| 새로운 화면/그룹 적용 | ✅ 자동 적용 |
+
+---
+
+## 시각화 결과 예시
+
+### 포커스 전 (그룹 전체 선택)
+```
+[화면1] ─── [화면2] ─── [화면3]
+ │ │ │
+ ▼ ▼ ▼
+[item_info] [customer_mng] [customer_item_mapping]
+ (메인) (메인) (메인)
+```
+
+### 3번째 화면 포커스 시
+```
+[화면1] ─── [화면2] ─── [화면3] ← 활성
+ │ │ │
+ ▼ ▼ ▼
+[item_info] [customer_mng] [customer_item_mapping]
+ (연관) (연관) (메인/활성)
+ │ │ │
+ │ │ ┌───────────┴───────────┐
+ │ │ │ │
+ └─────────────┼──────┤ 거래처 ID ← 거래처 코드 │
+ │ │ 품목 ID ← 품번 │
+ │ │ (+ 13개 사용 컬럼) │
+ │ └───────────────────────┘
+ ┌─────────────┘
+ │ 거래처 코드 ← 거래처 ID
+ └─────────────────────
+```
+
+---
+
+## 결론
+
+1. **범용성 확보**: 모든 NTT 설정 소스에서 조인 관계 자동 추출
+2. **정확성 개선**: `relationType`과 `sourceTable` 기반 정확한 조인 컬럼 식별
+3. **사용자 경험 향상**: 한글명 표시 및 직관적인 연결 정보 제공
+4. **유지보수성**: 새로운 화면/그룹 추가 시 별도 코드 수정 불필요
+5. **메인-메인 조인 로직 단일화**: 기존 중복 로직(초록색/주황색)을 주황색 단일 로직으로 통합
+ - `subTable.tableName`이 다른 화면의 메인 테이블인 경우 자동 연결
+ - `fieldMappings.sourceTable` 없어도 메인 테이블 간 조인 감지
+6. **연관 테이블 포커싱 개선**: 포커스된 화면의 서브 테이블이 다른 화면의 메인 테이블인 경우 활성화
+ - 회색 처리 대신 정상 표시 및 조인 컬럼 강조
+ - `relatedMainTables` 생성 로직 확장으로 `subTable.tableName` 기반 감지
+7. **선 연결 규격 정립**: 일관되고 직관적인 연결선 방향 규격화
+ - **메인-메인 연결선**: `bottom → bottom_target` 고정 (서브테이블 구간을 통해 연결)
+ - **서브 테이블 연결선**: `bottom → top` 고정 (아래로 문어발처럼 뻗어나감)
+ - **절대 규칙**: 선이 테이블이나 화면을 통과하지 않음
+8. **초기 메인-메인 엣지 생성**: 그룹 로딩 시점에 메인 테이블 간 연결선 미리 생성
+ - 연한 주황색(`#fdba74`) 점선으로 기본 표시
+ - 포커싱 시 진한 주황색(`#ea580c`)으로 강조 및 애니메이션
+ - 중복 방지를 위한 `pairKey` 기반 Set 사용
+9. **ReactFlow Handle ID 구분**: 메인-메인 연결을 위한 핸들 추가
+ - `TableNode`에 `id="bottom_target"` (type="target") 핸들 추가
+ - 메인-메인 엣지는 `sourceHandle="bottom"`, `targetHandle="bottom_target"` 사용
+ - 서브 테이블 엣지는 기존대로 `sourceHandle="bottom"`, `targetHandle="top"` 사용
+10. **메인-메인 강조 로직 개선**: 중복 연결선 및 잘못된 연결선 방지
+ - 포커스된 메인 테이블이 `source`인 경우에만 해당 엣지 강조
+ - 양방향 중복 강조 방지 (A→B와 B→A 동시 강조 안 함)
+ - 연결되지 않은 테이블에서 조인선이 나타나는 문제 해결
+11. **엣지 방향 수정**: 참조 방향에 맞게 엣지 source/target 교정
+ - 기존 잘못된 방향: `mainTable → subTable.tableName` (참조당하는 테이블 → 참조하는 테이블)
+ - 수정된 올바른 방향: `subTable.tableName → mainTable` (참조하는 테이블 → 참조당하는 테이블)
+ - A가 B를 참조(entity 설정)하면: A 포커스 시 A→B 연결선 표시
+ - B 포커스 시 연결선 없음 (B는 A를 참조하지 않으므로)
+12. **column_labels 필터링 강화**: `input_type = 'entity'`인 경우만 참조 관계로 인정
+ - `input_type = 'text'`인 경우 `reference_table`이 있어도 조인 관계로 취급하지 않음
+ - 과거 entity 설정 후 text로 변경된 경우 잔존 데이터 무시
+13. **범용적 엣지 방향 결정 로직**: `relationType`에 따른 조건부 방향 결정
+ - `parentMapping` (sourceTable 있음): `mainTable → sourceTable` 방향
+ - `rightPanelRelation` (foreignKey 있음): `subTable → mainTable` 방향
+ - `reference` (column_labels): `mainTable → subTable` 방향
+ - 기본값: `subTable → mainTable` 방향
+
+### 연결선 규격 다이어그램
+
+```
+[화면1] [화면2] [화면3] [화면4]
+ │ │ │ │
+ ▼ ▼ ▼ ▼
+[Table1] [Table2] [Table3] [Table4] ← 메인 테이블
+ │ │ │ │
+ │ bottom │ bottom │ bottom │ bottom
+ ▼ ▼ ▼ ▼
+┌─────────────────────────────────────────┐ ← 서브테이블 구간
+│ (메인-메인 연결선이 이 구간을 통과) │
+│ ◄── bottom ─ bottom_target ───► │
+│ (서브테이블 연결선도 이 구간에서 연결) │
+└─────────────────────────────────────────┘
+ │ │
+ ▼ top ▼ top
+ [서브1] [서브2] [서브3] ← 서브 테이블
+```
+
+---
+
+## [계획] 연결선 정리 시스템 설계
+
+> **상태**: 요건 정의 단계 (미구현)
+
+### 배경 및 필요성
+
+현재 시스템에서 연결선 종류가 증가함에 따라 체계적인 선 관리 시스템이 필요합니다.
+
+| 현재 상태 | 문제점 |
+|-----------|--------|
+| 조인선만 존재 | 종속 조회, 저장 테이블 등 추가 선 종류 필요 |
+| 경로 규격 미정립 | 선이 테이블을 통과할 가능성 |
+| 겹침 방지 미흡 | 같은 경로 사용 시 선 겹침 |
+
+---
+
+### 절대 규칙 (위반 불가)
+
+1. **선이 테이블 노드를 가로로 통과하면 안됨**
+2. **선이 화면 노드를 통과하면 안됨**
+3. **같은 종류의 선은 같은 구간에서 이동**
+4. **다른 종류의 선은 겹치지 않아야 함**
+
+---
+
+### 연결선 종류 정의
+
+#### 현재 구현됨
+
+| 번호 | 선 종류 | 의미 | 색상 | 스타일 | 상태 |
+|------|---------|------|------|--------|------|
+| 1 | 화면 → 메인 테이블 | 화면이 사용하는 테이블 | 파란색 (`#3b82f6`) | 실선 | 구현됨 |
+| 2 | 조인선 (엔티티 참조) | 테이블 간 데이터 병합 | 주황색 (`#ea580c`) | 점선 `8,4` | 구현됨 |
+
+#### 추가 예정
+
+| 번호 | 선 종류 | 의미 | 색상 (예정) | 스타일 (예정) | 상태 |
+|------|---------|------|-------------|---------------|------|
+| 3 | 종속 조회선 (Master-Detail) | 선택 기준 필터 조회 | 시안/보라색 | 점선 `4,4` | 미구현 |
+| 4 | 저장 테이블선 | 데이터 저장 대상 | 녹색 | 실선 or 점선 | 미구현 |
+| 5+ | 기타 확장 | 데이터 플로우 등 | 미정 | 미정 | 미구현 |
+
+---
+
+### 개념 구분: Join vs 종속 조회
+
+| 구분 | Join (조인) | 종속 조회 (Filter) |
+|------|------------|-------------------|
+| **데이터 처리** | 두 테이블 **병합** | 한 테이블로 다른 테이블 **필터링** |
+| **표시 방식** | 같은 그리드에 합쳐서 | 별도 그리드에 각각 |
+| **SQL** | `JOIN ON A.key = B.key` | `WHERE B.key = (선택된 A.key)` |
+| **예시** | `inbound_mng` + `item_info` 정보 합침 | `customer_mng` 선택 → `customer_item_mapping` 별도 조회 |
+| **현재 표현** | 주황색 점선 | 미표현 |
+
+**더블 그리드 패턴 예시:**
+```
+[customer_mng 그리드] | [customer_item_mapping 그리드]
+ (메인/선택) → (필터링된 결과)
+```
+- 이 경우 `customer_mng`가 진짜 메인
+- `customer_item_mapping`은 **종속 조회** (메인이 아님, 선택에 따라 필터링)
+
+---
+
+### 레이아웃 구간 정의
+
+```
+Y: 0-200 ┌─────────────────────────────────────────────┐
+ │ [화면1] [화면2] [화면3] [화면4] │ ← 화면 구간
+ └─────────────────────────────────────────────┘
+ │ │ │ │
+Y: 200-300 ════════════════════════════════════════════════ ← 화면-테이블 연결 구간 (파란 실선)
+ │ │ │ │
+Y: 300-500 ┌─────────────────────────────────────────────┐
+ │ [Table1] [Table2] [Table3] [Table4] │ ← 메인 테이블 구간
+ └─────────────────────────────────────────────┘
+ │ │ │ │
+ ▼ ▼ ▼ ▼
+Y: 500-700 ┌─────────────────────────────────────────────┐
+ │ [서브1] [서브2] [서브3] │ ← 서브 테이블 구간
+ └─────────────────────────────────────────────┘
+ │ │ │ │
+Y: 700-750 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ← [레벨1] 조인선 구간 (주황)
+Y: 750-800 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ← [레벨2] 종속 조회선 구간 (시안/보라)
+Y: 800-850 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ← [레벨3] 저장 테이블선 구간 (녹색)
+Y: 850+ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ← [레벨4+] 확장 구간
+```
+
+---
+
+### 선 경로 규격
+
+#### 원칙
+1. **수직 이동**: 노드 사이 공간에서만 (노드 통과 안함)
+2. **수평 이동**: 각 선 종류별 전용 레벨에서만
+3. **겹침 방지**: 서로 다른 Y 레벨 사용
+
+#### 선 종류별 경로
+
+| 선 종류 | 출발 핸들 | 도착 핸들 | 수평 이동 레벨 |
+|---------|-----------|-----------|----------------|
+| 화면 → 메인 | 화면 bottom | 테이블 top | Y: 200-300 (화면-테이블 구간) |
+| 메인 → 서브 | 테이블 bottom | 서브 top | Y: 500-700 (서브 테이블 구간 내) |
+| 조인선 (메인-메인) | 테이블 bottom | 테이블 bottom_target | **Y: 700-750** (레벨1) |
+| 종속 조회선 | 테이블 bottom | 테이블 bottom_target | **Y: 750-800** (레벨2) |
+| 저장 테이블선 | 테이블 bottom | 테이블 bottom_target | **Y: 800-850** (레벨3) |
+
+---
+
+### 핸들 설계
+
+현재 `TableNode`에 필요한 핸들:
+
+```
+ [top] (target) ← 화면에서 오는 선
+ │
+ ┌────────┼────────┐
+ │ │ │
+ │ 테이블 노드 │
+ │ │
+ └────────┬────────┘
+ │
+ [bottom] (source) ← 나가는 선 (서브테이블, 조인 등)
+ │
+ [bottom_target] (target) ← 메인-메인 연결용 들어오는 선
+```
+
+**추가 필요 핸들 (겹침 방지용):**
+
+```
+ ┌────────────────────────┐
+ │ │
+ │ 테이블 노드 │
+ │ │
+ └───┬───────┬───────┬────┘
+ │ │ │
+ (30%) (50%) (70%) ← X 위치
+ │ │ │
+ [bottom_join] [bottom] [bottom_filter]
+ │ │
+ 조인선 전용 종속 조회선 전용
+```
+
+---
+
+### 구현 방안 비교
+
+---
+
+#### 방안 A: 커스텀 엣지 경로 (완벽 분리)
+
+**레이어 다이어그램:**
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ [화면1] [화면2] [화면3] [화면4] │ Y: 0-200
+│ │ │ │ │ │
+└────┼─────────────┼──────────────┼──────────────┼────────────────────┘
+ │ │ │ │
+═════╧═════════════╧══════════════╧══════════════╧════════════════════ Y: 250 (화면-테이블 구간)
+ │ │ │ │
+┌────┼─────────────┼──────────────┼──────────────┼────────────────────┐
+│ [Table1] [Table2] [Table3] [Table4] │ Y: 300-500
+│ │ │ │ │ │
+└────┼─────────────┼──────────────┼──────────────┼────────────────────┘
+ │ │ │ │
+ ▼ ▼ ▼ ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│ [서브1] [서브2] [서브3] │ Y: 550-650
+└─────────────────────────────────────────────────────────────────────┘
+ │ │
+─────┴───────────── 레벨1: 조인선 (주황) ────────┴───────────────────── Y: 700
+ │ │
+ └───────────────────────────────────────────┘
+ │ │
+─────┴───────────── 레벨2: 종속 조회 (보라) ─────┴───────────────────── Y: 750
+ │ │
+ └─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
+ │ │
+─────┴───────────── 레벨3: 저장 테이블 (녹색) ───┴───────────────────── Y: 800
+ │ │
+ └─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
+```
+
+**특징**: 각 선 종류가 **전용 Y 레벨**에서만 수평 이동. 절대 겹치지 않음.
+
+**코드 예시:**
+
+```typescript
+// 커스텀 경로 계산
+const getCustomPath = (source, target, lineType) => {
+ const levels = {
+ join: 725, // 조인선 Y 레벨
+ filter: 775, // 종속 조회선 Y 레벨
+ save: 825, // 저장 테이블선 Y 레벨
+ };
+
+ const level = levels[lineType];
+
+ // 수직 → 수평(레벨) → 수직 경로
+ return `M${source.x},${source.y}
+ L${source.x},${level}
+ L${target.x},${level}
+ L${target.x},${target.y}`;
+};
+```
+
+**장점:**
+- 완벽한 분리 보장
+- 절대 규칙 100% 준수
+- 확장성 우수
+
+**단점:**
+- 구현 복잡도 높음
+- ReactFlow 기본 기능 대신 커스텀 필요
+
+---
+
+#### 방안 B: 핸들 위치 분리 + smoothstep
+
+**레이어 다이어그램:**
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ [화면1] [화면2] [화면3] [화면4] │
+└────┬─────────────┬──────────────┬──────────────┬────────────────────┘
+ │ │ │ │
+┌────┼─────────────┼──────────────┼──────────────┼────────────────────┐
+│ [Table1] [Table2] [Table3] [Table4] │
+│ 30% 50% 70% 30% 50% 70% │ ← 핸들 X 위치
+│ │ │ │ │ │ │ │
+└───┼───┼───┼──────────────────────────────────┼───┼───┼──────────────┘
+ │ │ │ │ │ │
+ │ │ └── 종속 조회 (보라) ──────────────┼───┼───┘ ← 70% 위치
+ │ │ │ │
+ │ └────── 조인선 (주황) ─────────────────┼───┘ ← 50% 위치
+ │ │
+ └────────── 저장 테이블 (녹색) ────────────┘ ← 30% 위치
+
+ ※ 시작점은 다르지만 경로가 가까움 (smoothstep 자동 계산)
+ ※ 선이 가까이 지나가서 겹칠 가능성 있음
+```
+
+**특징**: 핸들 X 위치만 다름. **경로가 가까이 지나가서 겹칠 가능성** 있음.
+
+**코드 예시:**
+
+```typescript
+// 핸들 위치로 분리
+
+
+
+// smoothstep + offset
+edge.pathOptions = { offset: lineType === 'filter' ? 50 : 0 };
+```
+
+**장점:**
+- ReactFlow 기본 기능 활용
+- 구현 상대적 단순
+
+**단점:**
+- 완벽한 분리 보장 어려움
+- 복잡한 경우 선 겹침 가능
+
+---
+
+#### 방안 C: 선 대신 마커/뱃지 (종속 조회만)
+
+**레이어 다이어그램:**
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ [화면1] [화면2] [화면3] [화면4] │
+└────┬─────────────┬──────────────┬──────────────┬────────────────────┘
+ │ │ │ │
+┌────┼─────────────┼──────────────┼──────────────┼────────────────────┐
+│ [Table1] [Table2] [Table3] [Table4 🔗] │
+│ │ ↑ │
+│ │ "Table1에서 필터 조회" (툴팁) │
+│ │ │
+│ └────────── 조인선만 표시 (주황) ──────────┘ │
+│ │
+│ ※ 종속 조회, 저장 테이블은 선 없이 뱃지/아이콘으로만 표시 │
+│ ※ 마우스 오버 시 관계 정보 툴팁 표시 │
+└─────────────────────────────────────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────────────┐
+│ [서브1] [서브2] [서브3] │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+**특징**: 선 없음 = **겹침/통과 문제 없음**. 하지만 관계 시각화 약함.
+
+**코드 예시:**
+
+```typescript
+// 테이블 노드에 관계 뱃지 표시
+
+
+
+```
+
+**장점:**
+- 선 없음 = 겹침/통과 문제 없음
+- 화면 깔끔
+
+**단점:**
+- 관계 시각화 약함
+- 일관성 부족 (조인은 선, 종속은 뱃지)
+
+---
+
+#### 방안 D: 하이브리드 (커스텀 경로 통일) - 권장
+
+**레이어 다이어그램:**
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ 레이어 0: 화면 노드 │
+│ [화면1] [화면2] [화면3] [화면4] │
+└─────────────────────────────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────────────┐
+│ 레이어 1: 화면-테이블 연결 (파란 실선) │
+│ ═══════════════════════════════════════════════════════════════════ │ Y: 250
+└─────────────────────────────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────────────┐
+│ 레이어 2: 메인 테이블 노드 │
+│ [Table1] [Table2] [Table3] [Table4] │
+└─────────────────────────────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────────────┐
+│ 레이어 3: 서브 테이블 노드 │
+│ [서브1] [서브2] [서브3] │
+└─────────────────────────────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────────────┐
+│ 레이어 4: 조인선 구간 (주황색) │
+│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ Y: 700-725
+│ Table1 ──────────────────────────────────► Table4 │
+└─────────────────────────────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────────────┐
+│ 레이어 5: 종속 조회 구간 (보라색) │
+│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ Y: 750-775
+│ Table1 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─► Table3 │
+└─────────────────────────────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────────────┐
+│ 레이어 6: 저장 테이블 구간 (녹색) │
+│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ Y: 800-825
+│ Table2 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─► Table4 │
+└─────────────────────────────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────────────────────┐
+│ 레이어 7+: 확장 가능 │
+│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ Y: 850+
+│ (미래 확장용) │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+**특징**: 모든 선이 **전용 레이어**에서 이동. 확장성 최고. 절대 겹치지 않음.
+
+**구현 방식:**
+- **조인선**: 커스텀 경로 (방안 A) - 레벨4 (Y: 700-725)
+- **종속 조회선**: 커스텀 경로 (방안 A) - 레벨5 (Y: 750-775)
+- **저장 테이블선**: 커스텀 경로 (방안 A) - 레벨6 (Y: 800-825)
+
+모든 선을 동일한 커스텀 경로 시스템으로 통일.
+
+**장점:**
+- 일관된 시스템
+- 완벽한 분리
+- 확장성 최고
+- 절대 규칙 100% 준수
+
+**단점:**
+- 초기 구현 비용 높음
+
+---
+
+### 방안 비교 요약표
+
+| 방안 | 겹침 방지 | 절대규칙 준수 | 구현 복잡도 | 확장성 | 시각적 일관성 |
+|------|-----------|---------------|-------------|--------|---------------|
+| **A** | 완벽 | 100% | 높음 | 좋음 | 좋음 |
+| **B** | 불완전 | 90% | 낮음 | 보통 | 보통 |
+| **C** | 완벽 (선 없음) | 100% | 낮음 | 좋음 | 약함 |
+| **D** | **완벽** | **100%** | 높음 | **최고** | **최고** |
+
+---
+
+### 구현 우선순위 (제안)
+
+| 순서 | 작업 | 설명 |
+|------|------|------|
+| 1 | 커스텀 엣지 컴포넌트 개발 | 레벨 기반 경로 계산 |
+| 2 | 기존 조인선 마이그레이션 | smoothstep → 커스텀 경로 |
+| 3 | 종속 조회선 구현 | 레벨2 경로 + 시안/보라 색상 |
+| 4 | 저장 테이블선 구현 | 레벨3 경로 + 녹색 |
+| 5 | 테스트 및 최적화 | 다양한 그룹에서 검증 |
+
+---
+
+### 색상 팔레트 (확정 - 2026-01-08)
+
+| 관계 유형 | 시각적 유형 | 기본 색상 | 강조 색상 | 설명 |
+|-----------|-------------|-----------|-----------|------|
+| 화면 → 메인 | - | `#3b82f6` (파랑) | `#2563eb` | 화면-테이블 연결 |
+| 마스터-디테일 | `filter` | `#8b5cf6` (보라) | `#c4b5fd` | split-panel의 필터링 관계 |
+| 계층 구조 | `hierarchy` | `#06b6d4` (시안) | `#a5f3fc` | 부모-자식 자기 참조 |
+| 코드 참조 | `lookup` | `#f59e0b` (주황) | `#fcd34d` | autocomplete 등 코드→명칭 |
+| 데이터 매핑 | `mapping` | `#10b981` (녹색) | `#6ee7b7` | parentDataMapping |
+| 엔티티 조인 | `join` | `#ea580c` (주황) | `#fdba74` | 실제 LEFT/INNER JOIN |
+
+---
+
+## 관계 유형 추론 시스템 (2026-01-08 구현)
+
+### 구현 개요
+
+테이블 간 연결선이 단순 "조인"만이 아니라 다양한 관계 유형을 가지므로,
+기존 컴포넌트 설정을 기반으로 관계 유형을 추론하여 시각화에 반영합니다.
+
+### 관계 유형 분류
+
+| 유형 | 기술적 의미 | 컴포넌트 | 식별 조건 |
+|------|------------|----------|-----------|
+| `filter` | 마스터-디테일 필터링 | split-panel-layout | `relationType='rightPanelRelation'` + `originalRelationType='join'` |
+| `hierarchy` | 자기 참조 계층 구조 | split-panel-layout | `relationType='rightPanelRelation'` + `originalRelationType='detail'` |
+| `mapping` | 데이터 참조 주입 | selected-items-detail-input | `relationType='parentMapping'` |
+| `lookup` | 코드→명칭 변환 | autocomplete, entity-search | `relationType='lookup'` |
+| `join` | 실제 엔티티 조인 | column_labels 참조 | `relationType='reference'` |
+
+### 백엔드 수정 사항
+
+`screenGroupController.ts`의 `getScreenSubTables` 함수에서 추가 필드 전달:
+
+```typescript
+// rightPanel.relation 파싱 시
+screenSubTables[screenId].subTables.push({
+ tableName: subTable,
+ componentType: componentType,
+ relationType: 'rightPanelRelation',
+ originalRelationType: relation?.type || 'join', // 추가: 원본 relation.type
+ foreignKey: relation?.foreignKey, // 추가: FK 컬럼
+ leftColumn: relation?.leftColumn, // 추가: 마스터 컬럼
+ fieldMappings: ...,
+});
+```
+
+### 프론트엔드 수정 사항
+
+1. **타입 확장** (`screenGroup.ts`):
+ - `SubTableInfo`에 `originalRelationType`, `foreignKey`, `leftColumn` 필드 추가
+ - `VisualRelationType` 타입 정의: `'filter' | 'hierarchy' | 'lookup' | 'mapping' | 'join'`
+ - `inferVisualRelationType()` 함수 추가
+
+2. **시각화 적용** (`ScreenRelationFlow.tsx`):
+ - `RELATION_COLORS` 상수 정의 (관계 유형별 색상)
+ - 엣지 생성 시 `inferVisualRelationType()` 호출
+ - 엣지 스타일에 관계 유형별 색상 적용
+
+### 2026-01-09 추가 수정 사항
+
+1. **방향 수정**: `rightPanelRelation` 엣지 방향을 `mainTable → subTable`로 변경
+ - 이전: `customer_item_mapping → customer_mng` (디테일 → 마스터, 잘못됨)
+ - 수정: `customer_mng → customer_item_mapping` (마스터 → 디테일, 올바름)
+
+2. **화면별 엣지 분리**: 같은 테이블 쌍이라도 화면별로 별도 엣지 생성
+ - `pairKey`에 `screenId` 포함: `${sourceScreenId}-${[mainTable, subTable].sort().join('-')}`
+ - `edgeId`에 `screenId` 포함: `edge-main-main-${sourceScreenId}-${referrerTable}-${referencedTable}`
+
+3. **포커스 필터링 개선**: 해당 화면에서 생성된 연결선만 표시
+ - 이전: `sourceTable === focusedMainTable` 조건만 체크 (다른 화면 연결선도 표시됨)
+ - 수정: `edge.data.sourceScreenId === focusedScreenId` 조건으로 변경
+
+4. **parentMapping을 join으로 변경**: `selected-items-detail-input`의 `parentDataMapping`은 FK 관계이므로 `join`으로 분류
+ - `customer_item_mapping → customer_mng`: 주황색 (FK: customer_id → customer_code)
+ - `customer_item_mapping → item_info`: 주황색 (FK: item_id → item_number)
+
+5. **참조 테이블 시각적 표시**: lookup/reference 관계로 참조되는 테이블에 "X곳 참조" 배지 표시
+ - `TableNodeData`에 `referencedBy` 필드 추가
+ - `ReferenceInfo` 인터페이스 정의 (fromTable, fromColumn, toColumn, relationType)
+ - 테이블 노드 헤더에 주황색 배지로 참조 카운트 표시
+ - 툴팁에 참조하는 테이블 목록 표시
+
+6. **마스터-디테일 필터링 관계 표시**: 디테일 테이블에 "X 필터" 배지 표시
+ - 마스터-디테일 관계(rightPanelRelation)도 참조 정보 수집에 추가
+ - **보라색 배지**로 "customer_mng 필터" 형태로 표시
+ - 툴팁에 FK 컬럼 정보 표시 (예: "customer_mng에서 필터링 (FK: customer_id)")
+ - lookup 관계는 주황색, filter 관계는 보라색으로 구분
+
+7. **FK 컬럼 보라색 강조 + 키값 정보 표시**
+ - 디테일 테이블에서 필터링에 사용되는 FK 컬럼을 **보라색 배경**으로 강조
+ - 컬럼 옆에 참조 정보 표시: "← customer_mng.customer_code"
+ - 배지에 키값 정보 명확히 표시: "customer_mng.customer_code 필터"
+ - `TableNodeData`에 `filterColumns` 필드 추가
+ - `ReferenceInfo`에서 `toColumn` 정보로 FK 컬럼 식별
+
+8. **포커스 상태 기반 필터 표시 개선**
+ - **문제**: 필터 배지가 모든 화면에서 항상 표시되어 혼란 발생
+ - **해결**: 포커스된 화면에서만 해당 관계의 필터 정보 표시
+ - 노드 생성 시 `referencedBy`, `filterColumns` 제거
+ - `styledNodes` 함수에서 포커스 상태에 따라 동적으로 설정
+ - 배지를 헤더 아래 별도 영역으로 이동하여 테이블명 가림 방지
+
+**결과:**
+| 화면 | customer_item_mapping 표시 |
+|------|----------------------------|
+| 1번 화면 포커스 | 필터 배지 O + FK 컬럼 보라색 |
+| 4번 화면 포커스 | 필터 배지 X, 조인만 표시 |
+| 그룹 선택 (포커스 없음) | 필터 배지 X, 테이블명만 표시 |
+
+### 향후 개선 가능 사항
+
+1. [ ] 범례(Legend) UI 추가 - 관계 유형별 색상 설명
+2. [ ] 엣지 라벨에 관계 유형 표시
+3. [x] 툴팁에 상세 관계 정보 표시 (FK, 연결 컬럼 등) - 완료
+
+---
+
+### 다음 단계
+
+1. [x] 방안 확정 - 방안 1 (추론 로직) 선택
+2. [x] 색상 팔레트 확정
+3. [x] 관계 유형 추론 함수 구현
+4. [x] 방향 및 포커스 필터링 수정
+5. [x] parentMapping을 join으로 변경
+6. [x] 참조 테이블 시각적 표시 추가
+7. [x] 마스터-디테일 필터링 관계 표시 추가
+8. [x] FK 컬럼 보라색 강조 + 키값 정보 표시
+9. [x] 포커스 상태 기반 필터 표시 개선
+10. [ ] 범례 UI 추가 (선택사항)
+11. [ ] 엣지 라벨에 관계 유형 표시 (선택사항)
+
+---
+
+## 관련 문서
+
+- [멀티테넌시 구현 가이드](.cursor/rules/multi-tenancy-guide.mdc)
+- [API 클라이언트 사용 규칙](.cursor/rules/api-client-usage.mdc)
+- [관리자 페이지 스타일 가이드](.cursor/rules/admin-page-style-guide.mdc)
+
diff --git a/frontend/components/screen/ScreenGroupTreeView.tsx b/frontend/components/screen/ScreenGroupTreeView.tsx
index 086ea228..49fbc23f 100644
--- a/frontend/components/screen/ScreenGroupTreeView.tsx
+++ b/frontend/components/screen/ScreenGroupTreeView.tsx
@@ -149,8 +149,8 @@ export function ScreenGroupTreeView({
const getScreensInGroup = (groupId: number): ScreenDefinition[] => {
const group = groups.find((g) => g.id === groupId);
if (!group?.screens) {
- const screenIds = groupScreensMap.get(groupId) || [];
- return screens.filter((screen) => screenIds.includes(screen.screenId));
+ const screenIds = groupScreensMap.get(groupId) || [];
+ return screens.filter((screen) => screenIds.includes(screen.screenId));
}
// 그룹의 screens 배열에서 display_order 정보를 가져와서 정렬
@@ -428,15 +428,15 @@ export function ScreenGroupTreeView({
{groups
.filter((g) => !(g as any).parent_group_id) // 대분류만 (parent_group_id가 null)
.map((group) => {
- const groupId = String(group.id);
- const isExpanded = expandedGroups.has(groupId);
- const groupScreens = getScreensInGroup(group.id);
+ const groupId = String(group.id);
+ const isExpanded = expandedGroups.has(groupId);
+ const groupScreens = getScreensInGroup(group.id);
// 하위 그룹들 찾기
const childGroups = groups.filter((g) => (g as any).parent_group_id === group.id);
- return (
-
+ return (
+
{/* 그룹 헤더 */}
= ({ data }) => {
- const { label, subLabel, isMain, isFocused, isFaded, columns, highlightedColumns, joinColumns, fieldMappings } = data;
+ const { label, subLabel, isMain, isFocused, isFaded, columns, highlightedColumns, joinColumns, filterColumns, fieldMappings, referencedBy } = data;
// 강조할 컬럼 세트 (영문 컬럼명 기준)
const highlightSet = new Set(highlightedColumns || []);
+ const filterSet = new Set(filterColumns || []); // 필터링에 사용되는 FK 컬럼
const joinSet = new Set(joinColumns || []);
// 필드 매핑 맵 생성 (targetField → { sourceField, sourceDisplayName })
@@ -475,12 +487,21 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
}}
>
{/* Handles */}
+ {/* top target: 화면 → 메인테이블 연결용 */}
+ {/* top source: 메인테이블 → 메인테이블 연결용 (위쪽으로 나가는 선) */}
+
= ({ data }) => {
id="bottom"
className="!h-2 !w-2 !border-2 !border-background !bg-orange-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
+ {/* bottom target: 메인테이블 ← 메인테이블 연결용 (아래에서 들어오는 선) */}
+
{/* 헤더 (초록색, 컴팩트) */}
-
+
{label}
{subLabel &&
{subLabel}
}
{hasActiveColumns && (
-
+
{displayColumns.length}개 활성
)}
+
+ {/* 필터링/참조 관계 표시 (포커스 시에만 표시, 헤더 아래 별도 영역) */}
+ {referencedBy && referencedBy.length > 0 && (() => {
+ const filterRefs = referencedBy.filter(r => r.relationType === 'filter');
+ const lookupRefs = referencedBy.filter(r => r.relationType === 'lookup');
+
+ if (filterRefs.length === 0 && lookupRefs.length === 0) return null;
+
+ return (
+
+ {filterRefs.length > 0 && (
+ `${r.fromTable}.${r.fromColumn || 'id'} → ${r.toColumn}`).join('\n')}`}
+ >
+
+ 필터
+
+ )}
+ {filterRefs.length > 0 && (
+
+ {filterRefs.map(r => `${r.fromTable}.${r.fromColumn || 'id'}`).join(', ')}
+
+ )}
+ {lookupRefs.length > 0 && (
+ `${r.fromTable} → ${r.toColumn}`).join('\n')}`}
+ >
+ {lookupRefs.length}곳 참조
+
+ )}
+
+ );
+ })()}
{/* 컬럼 목록 - 컴팩트하게 (스크롤 가능) */}
@@ -523,14 +587,22 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
{displayColumns.map((col, idx) => {
const colOriginal = col.originalName || col.name;
const isJoinColumn = joinSet.has(colOriginal);
+ const isFilterColumn = filterSet.has(colOriginal); // 필터링 FK 컬럼
const isHighlighted = highlightSet.has(colOriginal);
+ // 필터링 참조 정보 (어떤 테이블의 어떤 컬럼에서 필터링되는지)
+ const filterRefInfo = referencedBy?.find(
+ r => r.relationType === 'filter' && r.toColumn === colOriginal
+ );
+
return (
= ({ data }) => {
opacity: hasActiveColumns ? 0 : 1,
}}
>
- {/* PK/FK/조인 아이콘 */}
+ {/* PK/FK/조인/필터 아이콘 */}
{isJoinColumn &&
}
- {!isJoinColumn && col.isPrimaryKey &&
}
- {!isJoinColumn && col.isForeignKey && !col.isPrimaryKey &&
}
- {!isJoinColumn && !col.isPrimaryKey && !col.isForeignKey &&
}
+ {isFilterColumn && !isJoinColumn &&
}
+ {!isJoinColumn && !isFilterColumn && col.isPrimaryKey &&
}
+ {!isJoinColumn && !isFilterColumn && col.isForeignKey && !col.isPrimaryKey &&
}
+ {!isJoinColumn && !isFilterColumn && !col.isPrimaryKey && !col.isForeignKey &&
}
{/* 컬럼명 */}
{col.name}
@@ -567,7 +643,12 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
조인
>
)}
- {isHighlighted && !isJoinColumn && (
+ {isFilterColumn && !isJoinColumn && filterRefInfo && (
+
+ ← {filterRefInfo.fromTable}.{filterRefInfo.fromColumn || 'id'}
+
+ )}
+ {isHighlighted && !isJoinColumn && !isFilterColumn && (
사용
)}
diff --git a/frontend/components/screen/ScreenRelationFlow.tsx b/frontend/components/screen/ScreenRelationFlow.tsx
index e8d9d259..a593fe59 100644
--- a/frontend/components/screen/ScreenRelationFlow.tsx
+++ b/frontend/components/screen/ScreenRelationFlow.tsx
@@ -17,7 +17,7 @@ import {
import "@xyflow/react/dist/style.css";
import { ScreenDefinition } from "@/types/screen";
-import { ScreenNode, TableNode, ScreenNodeData, TableNodeData } from "./ScreenNode";
+import { ScreenNode, TableNode, ScreenNodeData, TableNodeData, ReferenceInfo } from "./ScreenNode";
import {
getFieldJoins,
getDataFlows,
@@ -27,9 +27,21 @@ import {
getScreenSubTables,
ScreenLayoutSummary,
ScreenSubTablesData,
+ SubTableInfo,
+ inferVisualRelationType,
+ VisualRelationType,
} from "@/lib/api/screenGroup";
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
+// 관계 유형별 색상 정의
+const RELATION_COLORS: Record
= {
+ filter: { stroke: '#8b5cf6', strokeLight: '#c4b5fd', label: '마스터-디테일' }, // 보라색
+ hierarchy: { stroke: '#06b6d4', strokeLight: '#a5f3fc', label: '계층 구조' }, // 시안색
+ lookup: { stroke: '#f59e0b', strokeLight: '#fcd34d', label: '코드 참조' }, // 주황색 (기존)
+ mapping: { stroke: '#10b981', strokeLight: '#6ee7b7', label: '데이터 매핑' }, // 녹색
+ join: { stroke: '#ea580c', strokeLight: '#fdba74', label: '엔티티 조인' }, // 주황색 (진한)
+};
+
// 노드 타입 등록
const nodeTypes = {
screenNode: ScreenNode,
@@ -38,8 +50,8 @@ const nodeTypes = {
// 레이아웃 상수
const SCREEN_Y = 50; // 화면 노드 Y 위치 (상단)
-const TABLE_Y = 420; // 메인 테이블 노드 Y 위치 (중단) - 위로 이동
-const SUB_TABLE_Y = 680; // 서브 테이블 노드 Y 위치 (하단) - 위로 이동
+const TABLE_Y = 420; // 메인 테이블 노드 Y 위치 (중단)
+const SUB_TABLE_Y = 690; // 서브 테이블 노드 Y 위치 (하단) - 메인과 270px 간격
const NODE_WIDTH = 260; // 노드 너비
const NODE_GAP = 40; // 노드 간격
@@ -385,6 +397,43 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 화면별 서브 테이블 매핑 저장
setScreenSubTableMap(newScreenSubTableMap);
+
+ // ========== 참조 관계 수집 (어떤 테이블이 어디서 참조되는지) ==========
+ // 테이블명 → 참조 정보 배열 (이 테이블을 참조하는 관계들)
+ const tableReferencesMap = new Map();
+
+ Object.entries(subTablesData).forEach(([screenIdStr, screenSubData]) => {
+ const mainTable = screenSubData.mainTable;
+
+ screenSubData.subTables.forEach((subTable) => {
+ const visualType = inferVisualRelationType(subTable);
+
+ // 1. lookup, reference 관계: 참조되는 테이블에 정보 추가
+ if (subTable.relationType === 'lookup' || subTable.relationType === 'reference') {
+ const existingRefs = tableReferencesMap.get(subTable.tableName) || [];
+ existingRefs.push({
+ fromTable: mainTable,
+ fromColumn: subTable.fieldMappings?.[0]?.sourceField || '',
+ toColumn: subTable.fieldMappings?.[0]?.targetField || '',
+ relationType: 'lookup',
+ });
+ tableReferencesMap.set(subTable.tableName, existingRefs);
+ }
+
+ // 2. rightPanelRelation (마스터-디테일 필터링): 디테일 테이블에 정보 추가
+ // 마스터(mainTable) → 디테일(subTable.tableName) 필터링 관계
+ if (subTable.relationType === 'rightPanelRelation') {
+ const existingRefs = tableReferencesMap.get(subTable.tableName) || [];
+ existingRefs.push({
+ fromTable: mainTable,
+ fromColumn: subTable.leftColumn || '', // 마스터 테이블의 선택 기준 컬럼
+ toColumn: subTable.foreignKey || '', // 디테일 테이블의 FK 컬럼
+ relationType: 'filter',
+ });
+ tableReferencesMap.set(subTable.tableName, existingRefs);
+ }
+ });
+ });
// 메인 테이블 노드 배치 (화면들의 중앙 아래에 배치)
const mainTableList = Array.from(mainTableSet);
@@ -434,6 +483,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
? `메인 테이블 (${linkedScreens.length}개 화면)`
: "메인 테이블";
+ // 이 테이블을 참조하는 관계들
tableNodes.push({
id: `table-${tableName}`,
type: "tableNode",
@@ -443,10 +493,11 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
subLabel: subLabel,
isMain: true, // mainTableSet의 모든 테이블은 메인
columns: formattedColumns,
+ // referencedBy, filterColumns는 styledNodes에서 포커스 상태에 따라 동적으로 설정
},
});
}
-
+
// ========== 하단: 서브 테이블 노드들 (참조/조회용) ==========
const subTableList = Array.from(subTableSet);
@@ -496,6 +547,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
isMain: false,
columns: formattedColumns,
isFaded: true, // 기본적으로 흐리게 표시 (포커스 시에만 활성화)
+ // referencedBy, filterColumns는 styledNodes에서 포커스 상태에 따라 동적으로 설정
},
});
}
@@ -503,26 +555,26 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// ========== 엣지: 연결선 생성 ==========
const newEdges: Edge[] = [];
-
+
// 그룹 선택 시: 화면 간 연결선 (display_order 순)
if (selectedGroup && screenList.length > 1) {
for (let i = 0; i < screenList.length - 1; i++) {
const currentScreen = screenList[i];
const nextScreen = screenList[i + 1];
- newEdges.push({
+ newEdges.push({
id: `edge-screen-flow-${i}`,
source: `screen-${currentScreen.screenId}`,
target: `screen-${nextScreen.screenId}`,
sourceHandle: "right",
targetHandle: "left",
- type: "smoothstep",
+ type: "smoothstep",
label: `${i + 1}`,
labelStyle: { fontSize: 11, fill: "#0ea5e9", fontWeight: 600 },
labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
labelBgPadding: [4, 2] as [number, number],
markerEnd: { type: MarkerType.ArrowClosed, color: "#0ea5e9" },
- animated: true,
+ animated: true,
style: { stroke: "#0ea5e9", strokeWidth: 2 },
});
}
@@ -536,9 +588,9 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
id: `edge-screen-table-${scr.screenId}`,
source: `screen-${scr.screenId}`,
target: `table-${scr.tableName}`,
- sourceHandle: "bottom",
- targetHandle: "top",
- type: "smoothstep",
+ sourceHandle: "bottom",
+ targetHandle: "top",
+ type: "smoothstep",
animated: true, // 모든 메인 테이블 연결은 애니메이션
style: {
stroke: "#3b82f6",
@@ -547,17 +599,111 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
});
}
});
-
- // 메인 테이블 → 서브 테이블 연결선 생성 (점선 + 애니메이션)
+
+ // 메인 테이블 → 서브 테이블 연결선 생성 (점선)
+ // 메인 테이블 → 메인 테이블 연결선도 생성 (점선, 연한 주황색)
// 화면별 서브 테이블 연결을 추적하기 위해 screenId 정보도 엣지 ID에 포함
+ const mainToMainEdgeSet = new Set(); // 중복 방지용
+
Object.entries(subTablesData).forEach(([screenIdStr, screenSubData]) => {
const sourceScreenId = parseInt(screenIdStr);
const mainTable = screenSubData.mainTable;
if (!mainTable || !mainTableSet.has(mainTable)) return;
screenSubData.subTables.forEach((subTable) => {
- // 서브 테이블 노드가 실제로 생성되었는지 확인
- if (!subTableSet.has(subTable.tableName)) return;
+ const isTargetSubTable = subTableSet.has(subTable.tableName);
+ const isTargetMainTable = mainTableSet.has(subTable.tableName);
+
+ // 자기 자신 연결 방지
+ if (mainTable === subTable.tableName) return;
+
+ // 메인 테이블 → 메인 테이블 연결 (서브테이블 구간을 통해 연결)
+ // 규격: bottom → bottom_target (참조하는 테이블에서 참조당하는 테이블로)
+ //
+ // 방향 결정 로직 (범용성):
+ // 1. parentMapping (fieldMappings에 sourceTable 있음): mainTable → sourceTable
+ // - mainTable이 sourceTable을 참조하는 관계
+ // - 예: customer_item_mapping.customer_id → customer_mng.customer_code
+ // 2. rightPanelRelation (foreignKey 있음): subTable → mainTable
+ // - subTable이 mainTable을 참조하는 관계
+ // - 예: customer_item_mapping.customer_id → customer_mng.customer_code
+ // 3. reference (column_labels): mainTable → subTable
+ // - mainTable이 subTable을 참조하는 관계
+ if (isTargetMainTable) {
+ // 실제 참조 방향 결정
+ let referrerTable: string; // 참조하는 테이블 (source)
+ let referencedTable: string; // 참조당하는 테이블 (target)
+
+ // fieldMappings에서 sourceTable 확인
+ const hasSourceTable = subTable.fieldMappings?.some(
+ (fm: any) => fm.sourceTable && fm.sourceTable === subTable.tableName
+ );
+
+ if (subTable.relationType === 'parentMapping' || hasSourceTable) {
+ // parentMapping: mainTable이 sourceTable(=subTable.tableName)을 참조
+ // 방향: mainTable → subTable.tableName
+ referrerTable = mainTable;
+ referencedTable = subTable.tableName;
+ } else if (subTable.relationType === 'rightPanelRelation') {
+ // rightPanelRelation: split-panel-layout의 마스터-디테일 관계
+ // mainTable(leftPanel, 마스터)이 subTable(rightPanel, 디테일)을 필터링
+ // 방향: mainTable(마스터) → subTable(디테일)
+ referrerTable = mainTable;
+ referencedTable = subTable.tableName;
+ } else if (subTable.relationType === 'reference') {
+ // reference (column_labels): mainTable이 subTable을 참조
+ // 방향: mainTable → subTable.tableName
+ referrerTable = mainTable;
+ referencedTable = subTable.tableName;
+ } else {
+ // 기본: subTable이 mainTable을 참조한다고 가정
+ referrerTable = subTable.tableName;
+ referencedTable = mainTable;
+ }
+
+ // 화면별로 고유한 키 생성 (같은 테이블 쌍이라도 다른 화면에서는 별도 엣지)
+ const pairKey = `${sourceScreenId}-${[mainTable, subTable.tableName].sort().join('-')}`;
+ if (!mainToMainEdgeSet.has(pairKey)) {
+ mainToMainEdgeSet.add(pairKey);
+
+ // 관계 유형 추론 및 색상 결정
+ const visualRelationType = inferVisualRelationType(subTable as SubTableInfo);
+ const relationColor = RELATION_COLORS[visualRelationType];
+
+ // 화면별로 고유한 엣지 ID
+ const edgeId = `edge-main-main-${sourceScreenId}-${referrerTable}-${referencedTable}`;
+ newEdges.push({
+ id: edgeId,
+ source: `table-${referrerTable}`, // 참조하는 테이블
+ target: `table-${referencedTable}`, // 참조당하는 테이블
+ sourceHandle: "bottom", // 하단에서 나감 (서브테이블 구간으로)
+ targetHandle: "bottom_target", // 하단으로 들어감
+ type: "smoothstep",
+ animated: false,
+ style: {
+ stroke: relationColor.strokeLight, // 관계 유형별 연한 색상
+ strokeWidth: 1.5,
+ strokeDasharray: "8,4",
+ opacity: 0.5,
+ },
+ markerEnd: {
+ type: MarkerType.ArrowClosed,
+ color: relationColor.strokeLight
+ },
+ data: {
+ sourceScreenId,
+ isMainToMain: true,
+ referrerTable,
+ referencedTable,
+ visualRelationType, // 관계 유형 저장
+ },
+ });
+ }
+ return; // 메인-메인은 위에서 처리했으므로 스킵
+ }
+
+ // 서브 테이블이 아니면 스킵
+ if (!isTargetSubTable) return;
// 화면별로 고유한 엣지 ID (같은 서브 테이블이라도 다른 화면에서 사용하면 별도 엣지)
const edgeId = `edge-main-sub-${sourceScreenId}-${mainTable}-${subTable.tableName}`;
@@ -578,18 +724,28 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
targetHandle: "top",
type: "smoothstep",
label: relationLabel,
- labelStyle: { fontSize: 9, fill: "#94a3b8", fontWeight: 500 }, // 기본 흐린 색상
- labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
+ labelStyle: {
+ fontSize: 9,
+ fill: "#94a3b8",
+ fontWeight: 500
+ },
+ labelBgStyle: {
+ fill: "white",
+ stroke: "#e2e8f0",
+ strokeWidth: 1
+ },
labelBgPadding: [3, 2] as [number, number],
- markerEnd: { type: MarkerType.ArrowClosed, color: "#94a3b8" }, // 기본 흐린 색상
+ markerEnd: {
+ type: MarkerType.ArrowClosed,
+ color: "#94a3b8"
+ },
animated: false, // 기본: 애니메이션 비활성화 (포커스 시에만 활성화)
style: {
- stroke: "#94a3b8", // 기본 흐린 색상
+ stroke: "#94a3b8",
strokeWidth: 1,
strokeDasharray: "6,4", // 점선
opacity: 0.5, // 기본 투명도
},
- // 화면 ID 정보를 data에 저장 (styledEdges에서 활용)
data: { sourceScreenId },
});
});
@@ -769,34 +925,56 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 모든 화면의 메인 테이블과 그에 연결된 조인 정보 매핑
// 포커스된 화면이 다른 메인 테이블을 참조하는 경우 해당 테이블도 강조
const relatedMainTables: Record = {};
+
+ // 모든 화면의 메인 테이블 Set (빠른 조회용)
+ const allMainTableSet = new Set(Object.values(screenTableMap));
+
if (focusedSubTablesData) {
- // screenTableMap에서 다른 화면들의 메인 테이블 확인
- Object.entries(screenTableMap).forEach(([screenIdStr, mainTableName]) => {
- const screenId = parseInt(screenIdStr);
- if (screenId === focusedScreenId) return; // 자신의 메인 테이블은 제외
-
- // 포커스된 화면의 subTables 중 이 메인 테이블을 참조하는지 확인
- focusedSubTablesData.subTables.forEach((subTable) => {
+ // 포커스된 화면의 subTables 순회
+ focusedSubTablesData.subTables.forEach((subTable) => {
+ // 1. subTable.tableName 자체가 다른 화면의 메인 테이블인 경우
+ if (allMainTableSet.has(subTable.tableName) && subTable.tableName !== focusedSubTablesData.mainTable) {
+ if (!relatedMainTables[subTable.tableName]) {
+ relatedMainTables[subTable.tableName] = { columns: [], displayNames: [] };
+ }
+
+ // fieldMappings가 있으면 조인 컬럼 정보 추출
if (subTable.fieldMappings) {
subTable.fieldMappings.forEach((mapping: any) => {
- // mapping에 sourceTable 정보가 있는 경우 (parentDataMapping에서 설정)
- if (mapping.sourceTable && mapping.sourceTable === mainTableName) {
- if (!relatedMainTables[mainTableName]) {
- relatedMainTables[mainTableName] = { columns: [], displayNames: [] };
- }
- if (mapping.sourceField && !relatedMainTables[mainTableName].columns.includes(mapping.sourceField)) {
- relatedMainTables[mainTableName].columns.push(mapping.sourceField);
- relatedMainTables[mainTableName].displayNames.push(mapping.sourceDisplayName || mapping.sourceField);
- }
+ // reference, source 타입: targetField가 서브(연관) 테이블 컬럼
+ // parentMapping 등: sourceField가 연관 테이블 컬럼
+ const relatedColumn = mapping.sourceTable
+ ? mapping.sourceField // parentMapping 스타일
+ : mapping.targetField; // reference/source 스타일
+ const displayName = mapping.sourceTable
+ ? (mapping.sourceDisplayName || mapping.sourceField)
+ : (mapping.targetDisplayName || mapping.targetField);
+
+ if (relatedColumn && !relatedMainTables[subTable.tableName].columns.includes(relatedColumn)) {
+ relatedMainTables[subTable.tableName].columns.push(relatedColumn);
+ relatedMainTables[subTable.tableName].displayNames.push(displayName);
}
});
}
- });
+ }
+
+ // 2. fieldMappings.sourceTable이 다른 화면의 메인 테이블인 경우
+ if (subTable.fieldMappings) {
+ subTable.fieldMappings.forEach((mapping: any) => {
+ if (mapping.sourceTable && allMainTableSet.has(mapping.sourceTable) && mapping.sourceTable !== focusedSubTablesData.mainTable) {
+ if (!relatedMainTables[mapping.sourceTable]) {
+ relatedMainTables[mapping.sourceTable] = { columns: [], displayNames: [] };
+ }
+ if (mapping.sourceField && !relatedMainTables[mapping.sourceTable].columns.includes(mapping.sourceField)) {
+ relatedMainTables[mapping.sourceTable].columns.push(mapping.sourceField);
+ relatedMainTables[mapping.sourceTable].displayNames.push(mapping.sourceDisplayName || mapping.sourceField);
+ }
+ }
+ });
+ }
});
}
- console.log('[DEBUG] relatedMainTables:', relatedMainTables);
-
return nodes.map((node) => {
// 화면 노드 스타일링 (포커스가 있을 때만)
if (node.id.startsWith("screen-")) {
@@ -843,102 +1021,25 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
let joinColumns: string[] = [...(focusedUsedColumns?.[`${tableName}__join`] || [])];
// 서브 테이블 연결 정보에서도 추가 (포커스된 화면의 메인 테이블인 경우)
- // relationType에 따라 다름:
- // - reference, source: sourceField가 메인테이블 컬럼 (예: manager_id -> user_id, material -> material)
- // - parentMapping, rightPanelRelation: targetField가 메인테이블 컬럼
- // - lookup 등: targetField가 메인테이블 컬럼
- console.log('[DEBUG] joinColumns before subTable processing:', {
- tableName,
- focusedMainTable: focusedSubTablesData?.mainTable,
- subTables: focusedSubTablesData?.subTables?.map((st: any) => ({
- tableName: st.tableName,
- relationType: st.relationType,
- fieldMappings: st.fieldMappings
- }))
- });
if (focusedSubTablesData && focusedSubTablesData.mainTable === tableName) {
- // 디버그: subTables 처리 전 로그
- if (tableName === 'customer_item_mapping') {
- console.log('[DEBUG] Processing subTables for mainTable:', {
- mainTable: tableName,
- subTablesCount: focusedSubTablesData.subTables.length,
- subTables: focusedSubTablesData.subTables.map(st => ({
- tableName: st.tableName,
- relationType: st.relationType,
- fieldMappingsCount: st.fieldMappings?.length || 0,
- fieldMappings: st.fieldMappings?.map(fm => ({
- sourceField: fm.sourceField,
- targetField: fm.targetField,
- })),
- })),
- });
- }
- focusedSubTablesData.subTables.forEach((subTable, stIdx) => {
- // 각 서브테이블 디버그 로그
- if (tableName === 'customer_item_mapping') {
- console.log(`[DEBUG] SubTable ${stIdx}:`, {
- tableName: subTable.tableName,
- relationType: subTable.relationType,
- hasFieldMappings: !!subTable.fieldMappings,
- fieldMappingsCount: subTable.fieldMappings?.length || 0,
- });
- }
+ focusedSubTablesData.subTables.forEach((subTable) => {
if (subTable.fieldMappings) {
- subTable.fieldMappings.forEach((mapping, mIdx) => {
- // 각 매핑 디버그 로그
- if (tableName === 'customer_item_mapping') {
- console.log(`[DEBUG] SubTable ${stIdx} Mapping ${mIdx}:`, {
- sourceField: mapping.sourceField,
- targetField: mapping.targetField,
- relationType: subTable.relationType,
- });
- }
- // sourceTable이 있으면 parentDataMapping/rightPanelRelation에서 추가된 것이므로
- // relationType과 관계없이 targetField가 메인테이블 컬럼
+ subTable.fieldMappings.forEach((mapping) => {
const hasSourceTable = 'sourceTable' in mapping && mapping.sourceTable;
if (hasSourceTable) {
- // parentDataMapping/rightPanelRelation: targetField가 메인테이블 컬럼
- if (tableName === 'customer_item_mapping') {
- console.log('[DEBUG] Adding targetField to joinColumns (has sourceTable):', {
- subTableName: subTable.tableName,
- relationType: subTable.relationType,
- sourceTable: mapping.sourceTable,
- targetField: mapping.targetField,
- alreadyIncludes: joinColumns.includes(mapping.targetField),
- });
- }
if (mapping.targetField && !joinColumns.includes(mapping.targetField)) {
joinColumns.push(mapping.targetField);
}
} else if (subTable.relationType === 'reference' || subTable.relationType === 'source') {
- // reference, source (sourceTable 없는 경우): sourceField가 메인테이블 컬럼
if (mapping.sourceField && !joinColumns.includes(mapping.sourceField)) {
joinColumns.push(mapping.sourceField);
}
} else if (subTable.relationType === 'parentMapping' || subTable.relationType === 'rightPanelRelation') {
- // parentMapping, rightPanelRelation: targetField가 메인테이블 컬럼
- if (tableName === 'customer_item_mapping') {
- console.log('[DEBUG] Adding targetField to joinColumns (parentMapping):', {
- subTableName: subTable.tableName,
- relationType: subTable.relationType,
- targetField: mapping.targetField,
- alreadyIncludes: joinColumns.includes(mapping.targetField),
- });
- }
if (mapping.targetField && !joinColumns.includes(mapping.targetField)) {
joinColumns.push(mapping.targetField);
}
} else {
- // lookup 등: targetField가 메인테이블 컬럼
- if (tableName === 'customer_item_mapping') {
- console.log('[DEBUG] Adding targetField to joinColumns (else branch):', {
- subTableName: subTable.tableName,
- relationType: subTable.relationType,
- targetField: mapping.targetField,
- alreadyIncludes: joinColumns.includes(mapping.targetField),
- });
- }
if (mapping.targetField && !joinColumns.includes(mapping.targetField)) {
joinColumns.push(mapping.targetField);
}
@@ -1014,8 +1115,33 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 연관 테이블용 fieldMappings 생성
let relatedTableFieldMappings: Array<{ sourceField: string; targetField: string; sourceDisplayName?: string; targetDisplayName?: string }> = [];
- if (isRelatedTable && relatedTableInfo && focusedSubTablesData) {
+ if (isRelatedTable && focusedSubTablesData) {
focusedSubTablesData.subTables.forEach((subTable) => {
+ // 1. subTable.tableName === tableName인 경우 (메인-메인 조인)
+ if (subTable.tableName === tableName && subTable.fieldMappings) {
+ subTable.fieldMappings.forEach((mapping) => {
+ // reference/source 타입: sourceField가 메인테이블, targetField가 이 연관테이블
+ // 연관 테이블 표시: targetField ← sourceDisplayName (메인테이블 컬럼 한글명)
+ if (subTable.relationType === 'reference' || subTable.relationType === 'source') {
+ relatedTableFieldMappings.push({
+ sourceField: mapping.sourceField, // 메인 테이블 컬럼 (참조 표시용)
+ targetField: mapping.targetField, // 연관 테이블 컬럼 (표시할 컬럼)
+ sourceDisplayName: mapping.sourceDisplayName || mapping.sourceField,
+ targetDisplayName: mapping.targetDisplayName || mapping.targetField,
+ });
+ } else {
+ // lookup, parentMapping 등: targetField가 메인테이블, sourceField가 연관테이블
+ relatedTableFieldMappings.push({
+ sourceField: mapping.targetField, // 메인 테이블 컬럼 (참조 표시용)
+ targetField: mapping.sourceField, // 연관 테이블 컬럼 (표시할 컬럼)
+ sourceDisplayName: mapping.targetDisplayName || mapping.targetField,
+ targetDisplayName: mapping.sourceDisplayName || mapping.sourceField,
+ });
+ }
+ });
+ }
+
+ // 2. mapping.sourceTable === tableName인 경우 (parentDataMapping 등)
if (subTable.fieldMappings) {
subTable.fieldMappings.forEach((mapping) => {
if (mapping.sourceTable === tableName) {
@@ -1030,6 +1156,39 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
});
}
});
+
+ // 중복 제거
+ const seen = new Set();
+ relatedTableFieldMappings = relatedTableFieldMappings.filter(fm => {
+ const key = `${fm.sourceField}-${fm.targetField}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+ }
+
+ // 포커스된 화면에서 이 테이블이 필터링 대상인지 확인
+ // (rightPanelRelation의 서브테이블인 경우)
+ let focusedFilterColumns: string[] = [];
+ let focusedReferencedBy: ReferenceInfo[] = [];
+
+ if (focusedScreenId !== null && focusedSubTablesData) {
+ // 포커스된 화면에서 이 테이블이 rightPanelRelation의 서브테이블인 경우
+ focusedSubTablesData.subTables.forEach((subTable) => {
+ if (subTable.tableName === tableName && subTable.relationType === 'rightPanelRelation') {
+ // FK 컬럼 추출
+ if (subTable.foreignKey) {
+ focusedFilterColumns.push(subTable.foreignKey);
+ }
+ // 참조 정보 생성
+ focusedReferencedBy.push({
+ fromTable: focusedSubTablesData.mainTable,
+ fromColumn: subTable.leftColumn || 'id',
+ toColumn: subTable.foreignKey || '',
+ relationType: 'filter',
+ });
+ }
+ });
}
return {
@@ -1041,6 +1200,8 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
isFaded: focusedScreenId !== null && !isActiveTable,
highlightedColumns: isActiveTable ? highlightedColumns : [],
joinColumns: isActiveTable ? joinColumns : [],
+ filterColumns: focusedFilterColumns, // 포커스 상태에서만 표시
+ referencedBy: focusedReferencedBy.length > 0 ? focusedReferencedBy : undefined, // 포커스 상태에서만 표시
fieldMappings: isFocusedTable ? mainTableFieldMappings : (isRelatedTable ? relatedTableFieldMappings : []),
},
};
@@ -1195,48 +1356,79 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
// 그룹 모드가 아니면 원본 반환
if (!selectedGroup) return edges;
- // 연관 테이블 간 조인 엣지 생성 (parentDataMapping, rightPanelRelation)
+ // 연관 테이블 간 조인 엣지 생성 (메인 테이블 간 조인 관계)
const joinEdges: Edge[] = [];
if (focusedScreenId !== null) {
const focusedSubTablesData = subTablesDataMap[focusedScreenId];
const focusedMainTable = screenTableMap[focusedScreenId];
+ // 모든 화면의 메인 테이블 목록 (메인-메인 조인 판단용)
+ const allMainTables = new Set(Object.values(screenTableMap));
+
+ // 이미 추가된 테이블 쌍 추적 (중복 방지)
+ const addedPairs = new Set();
if (focusedSubTablesData) {
focusedSubTablesData.subTables.forEach((subTable) => {
- // fieldMappings에 sourceTable이 있는 경우 처리 (parentMapping, rightPanelRelation 등)
- if (subTable.fieldMappings) {
+ // 1. subTable.tableName이 다른 화면의 메인 테이블인 경우 (메인-메인 조인)
+ const isTargetMainTable = allMainTables.has(subTable.tableName) && subTable.tableName !== focusedMainTable;
+
+ if (isTargetMainTable) {
+ const pairKey = `${subTable.tableName}-${focusedMainTable}`;
+ if (addedPairs.has(pairKey)) return;
+ addedPairs.add(pairKey);
- subTable.fieldMappings.forEach((mapping: any, idx: number) => {
+ // 메인 테이블 간 조인 연결선 - edge-main-main 스타일 업데이트만 수행
+ // 별도의 edge-main-join을 생성하지 않고, styledEdges에서 edge-main-main을 강조 처리
+ }
+
+ // 2. fieldMappings.sourceTable이 있는 경우 (parentMapping, rightPanelRelation 등)
+ if (subTable.fieldMappings) {
+ subTable.fieldMappings.forEach((mapping: any) => {
const sourceTable = mapping.sourceTable;
if (!sourceTable) return;
- // 연관 테이블 → 포커싱된 화면의 메인 테이블로 연결
- // sourceTable(연관) → focusedMainTable(메인)
- const edgeId = `edge-join-relation-${focusedScreenId}-${sourceTable}-${focusedMainTable}-${idx}`;
+ // sourceTable이 메인 테이블인 경우만 메인-메인 조인선 추가
+ if (!allMainTables.has(sourceTable) || sourceTable === focusedMainTable) return;
+
+ const pairKey = `${sourceTable}-${focusedMainTable}`;
+ if (addedPairs.has(pairKey)) return;
+ addedPairs.add(pairKey);
+
+ const edgeId = `edge-join-relation-${focusedScreenId}-${sourceTable}-${focusedMainTable}`;
+ const sourceNodeId = `table-${sourceTable}`;
+ const targetNodeId = `table-${focusedMainTable}`;
// 이미 존재하는 엣지인지 확인
if (joinEdges.some(e => e.id === edgeId)) return;
- // 라벨 제거 - 조인 정보는 테이블 노드 내부에서 컬럼 옆에 표시
+ // 관계 유형 추론 및 색상 결정
+ const visualRelationType = inferVisualRelationType(subTable as SubTableInfo);
+ const relationColor = RELATION_COLORS[visualRelationType];
+
joinEdges.push({
id: edgeId,
- source: `table-${sourceTable}`,
- target: `table-${focusedMainTable}`,
+ source: sourceNodeId,
+ target: targetNodeId,
+ sourceHandle: 'bottom', // 고정: 서브테이블 구간 통과
+ targetHandle: 'bottom_target', // 고정: 서브테이블 구간 통과
type: 'smoothstep',
animated: true,
style: {
- stroke: '#ea580c',
+ stroke: relationColor.stroke, // 관계 유형별 색상
strokeWidth: 2,
strokeDasharray: '8,4',
},
markerEnd: {
type: MarkerType.ArrowClosed,
- color: '#ea580c',
+ color: relationColor.stroke,
width: 15,
height: 15,
},
+ data: {
+ visualRelationType,
+ },
});
});
}
@@ -1294,12 +1486,14 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
}
// 메인 테이블 → 서브 테이블 연결선
- // 기본: 흐리게 처리, 포커스된 화면의 서브 테이블만 강조
+ // 규격: bottom → top 고정 (아래로 문어발처럼 뻗어나감)
if (edge.source.startsWith("table-") && edge.target.startsWith("subtable-")) {
// 포커스가 없으면 모든 서브 테이블 연결선 흐리게 (기본 상태)
if (focusedScreenId === null) {
return {
...edge,
+ sourceHandle: "bottom", // 고정: 메인 테이블 하단에서 나감
+ targetHandle: "top", // 고정: 서브 테이블 상단으로 들어감
animated: false,
style: {
...edge.style,
@@ -1332,6 +1526,8 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
return {
...edge,
+ sourceHandle: "bottom", // 고정
+ targetHandle: "top", // 고정
animated: isActive, // 활성화된 것만 애니메이션
style: {
...edge.style,
@@ -1347,12 +1543,69 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
};
}
+ // 메인 테이블 → 메인 테이블 연결선 (서브테이블 구간 통과)
+ // 규격: bottom → bottom_target 고정 (아래쪽 서브테이블 선 구간을 통해 연결)
+ if (edge.source.startsWith("table-") && edge.target.startsWith("table-") && edge.id.startsWith("edge-main-main-")) {
+ // 관계 유형별 색상 결정
+ const visualRelationType = (edge.data as any)?.visualRelationType as VisualRelationType || 'join';
+ const relationColor = RELATION_COLORS[visualRelationType];
+
+ if (focusedScreenId === null) {
+ // 포커스 없으면 관계 유형별 연한 색상으로 표시
+ return {
+ ...edge,
+ sourceHandle: "bottom",
+ targetHandle: "bottom_target",
+ animated: false,
+ style: {
+ ...edge.style,
+ stroke: relationColor.strokeLight,
+ strokeWidth: 1.5,
+ strokeDasharray: "8,4",
+ opacity: 0.4,
+ },
+ };
+ }
+
+ // 포커스된 화면에서 생성된 연결선만 표시
+ const edgeSourceScreenId = (edge.data as any)?.sourceScreenId;
+ const isMyConnection = edgeSourceScreenId === focusedScreenId;
+
+ // 포커스된 화면과 관련 없는 메인-메인 엣지는 숨김
+ if (!isMyConnection) {
+ return {
+ ...edge,
+ hidden: true,
+ };
+ }
+
+ return {
+ ...edge,
+ sourceHandle: "bottom", // 고정: 서브테이블 구간 통과
+ targetHandle: "bottom_target", // 고정: 서브테이블 구간 통과
+ animated: true,
+ style: {
+ ...edge.style,
+ stroke: relationColor.stroke, // 관계 유형별 진한 색상
+ strokeWidth: 2.5,
+ strokeDasharray: "8,4",
+ opacity: 1,
+ },
+ markerEnd: {
+ type: MarkerType.ArrowClosed,
+ color: relationColor.stroke,
+ width: 15,
+ height: 15,
+ },
+ };
+ }
+
return edge;
});
// 기존 엣지 + 조인 관계 엣지 합치기
return [...styledOriginalEdges, ...joinEdges];
- }, [edges, selectedGroup, focusedScreenId, screen, screenSubTableMap, subTablesDataMap, screenTableMap]);
+ }, [edges, nodes, selectedGroup, focusedScreenId, screen, screenSubTableMap, subTablesDataMap, screenTableMap]);
// 조건부 렌더링 (모든 훅 선언 후에 위치해야 함)
if (!screen && !selectedGroup) {
@@ -1378,20 +1631,20 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
{/* isViewReady가 false면 숨김 처리하여 깜빡임 방지 */}
-
+ nodeTypes={nodeTypes}
+ minZoom={0.3}
+ maxZoom={1.5}
+ proOptions={{ hideAttribution: true }}
+ >
-
-
+
+
);
diff --git a/frontend/components/screen/panels/DataFlowPanel.tsx b/frontend/components/screen/panels/DataFlowPanel.tsx
index b9b70913..97a62791 100644
--- a/frontend/components/screen/panels/DataFlowPanel.tsx
+++ b/frontend/components/screen/panels/DataFlowPanel.tsx
@@ -456,3 +456,5 @@ export default function DataFlowPanel({ groupId, screenId, screens = [] }: DataF
+
+
diff --git a/frontend/components/screen/panels/FieldJoinPanel.tsx b/frontend/components/screen/panels/FieldJoinPanel.tsx
index 0995a14f..df44ebb0 100644
--- a/frontend/components/screen/panels/FieldJoinPanel.tsx
+++ b/frontend/components/screen/panels/FieldJoinPanel.tsx
@@ -408,3 +408,5 @@ export default function FieldJoinPanel({ screenId, componentId, layoutId }: Fiel
+
+
diff --git a/frontend/lib/api/screenGroup.ts b/frontend/lib/api/screenGroup.ts
index 89f5591d..b3f4e849 100644
--- a/frontend/lib/api/screenGroup.ts
+++ b/frontend/lib/api/screenGroup.ts
@@ -406,6 +406,44 @@ export interface SubTableInfo {
componentType: string;
relationType: 'lookup' | 'source' | 'join' | 'reference' | 'parentMapping' | 'rightPanelRelation';
fieldMappings?: FieldMappingInfo[];
+ // rightPanelRelation에서 추가 정보 (관계 유형 추론용)
+ originalRelationType?: 'join' | 'detail'; // 원본 relation.type
+ foreignKey?: string; // 디테일 테이블의 FK 컬럼
+ leftColumn?: string; // 마스터 테이블의 선택 기준 컬럼
+}
+
+// 시각적 관계 유형 (시각화에서 사용)
+export type VisualRelationType = 'filter' | 'hierarchy' | 'lookup' | 'mapping' | 'join';
+
+// 관계 유형 추론 함수
+export function inferVisualRelationType(subTable: SubTableInfo): VisualRelationType {
+ // 1. split-panel-layout의 rightPanel.relation
+ if (subTable.relationType === 'rightPanelRelation') {
+ // 원본 relation.type 기반 구분
+ if (subTable.originalRelationType === 'detail') {
+ return 'hierarchy'; // 부모-자식 계층 구조 (같은 테이블 자기 참조)
+ }
+ return 'filter'; // 마스터-디테일 필터링
+ }
+
+ // 2. selected-items-detail-input의 parentDataMapping
+ // parentDataMapping은 FK 관계를 정의하므로 조인으로 분류
+ if (subTable.relationType === 'parentMapping') {
+ return 'join'; // FK 조인 (sourceTable.sourceField → targetTable.targetField)
+ }
+
+ // 3. column_labels.reference_table
+ if (subTable.relationType === 'reference') {
+ return 'join'; // 실제 엔티티 조인 (LEFT JOIN 등)
+ }
+
+ // 4. autocomplete, entity-search
+ if (subTable.relationType === 'lookup') {
+ return 'lookup'; // 코드→명칭 변환
+ }
+
+ // 5. 기타 (source, join 등)
+ return 'join';
}
export interface ScreenSubTablesData {