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 {