diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index 9dd0da47..a942861c 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -1607,7 +1607,8 @@ export const getScreenSubTables = async (req: Request, res: Response) => { sd.table_name as main_table, sl.properties->>'componentType' as component_type, sl.properties->'componentConfig'->'rightPanel'->'relation' as right_panel_relation, - sl.properties->'componentConfig'->'rightPanel'->'tableName' as right_panel_table + sl.properties->'componentConfig'->'rightPanel'->'tableName' as right_panel_table, + sl.properties->'componentConfig'->'rightPanel'->'columns' as right_panel_columns FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id WHERE sd.screen_id = ANY($1) @@ -1616,6 +1617,29 @@ export const getScreenSubTables = async (req: Request, res: Response) => { const rightPanelResult = await pool.query(rightPanelQuery, [screenIds]); + // rightPanel.columns에서 참조되는 외부 테이블 수집 (예: customer_mng.customer_name → customer_mng) + const rightPanelJoinedTables: Map> = new Map(); // screenId_tableName → Set<참조테이블> + + rightPanelResult.rows.forEach((row: any) => { + const screenId = row.screen_id; + const rightPanelTable = row.right_panel_table; + const rightPanelColumns = row.right_panel_columns; + + if (rightPanelColumns && Array.isArray(rightPanelColumns)) { + rightPanelColumns.forEach((col: any) => { + const colName = col.name || col.columnName || col.field; + if (colName && colName.includes('.')) { + const refTable = colName.split('.')[0]; + const key = `${screenId}_${rightPanelTable}`; + if (!rightPanelJoinedTables.has(key)) { + rightPanelJoinedTables.set(key, new Set()); + } + rightPanelJoinedTables.get(key)!.add(refTable); + } + }); + } + }); + rightPanelResult.rows.forEach((row: any) => { const screenId = row.screen_id; const mainTable = row.main_table; @@ -1626,6 +1650,10 @@ export const getScreenSubTables = async (req: Request, res: Response) => { // relation 객체에서 테이블 및 필드 매핑 추출 const subTable = rightPanelTable || relation?.targetTable || relation?.tableName; if (!subTable || subTable === mainTable) return; + + // rightPanel.columns에서 참조하는 외부 테이블 목록 + const key = `${screenId}_${subTable}`; + const joinedTables = rightPanelJoinedTables.get(key) ? Array.from(rightPanelJoinedTables.get(key)!) : []; if (!screenSubTables[screenId]) { screenSubTables[screenId] = { @@ -1697,6 +1725,7 @@ export const getScreenSubTables = async (req: Request, res: Response) => { originalRelationType: relation?.type || 'join', // 원본 relation.type ("join" | "detail") foreignKey: relation?.foreignKey, // 디테일 테이블의 FK 컬럼 leftColumn: relation?.leftColumn, // 마스터 테이블의 선택 기준 컬럼 + joinedTables: joinedTables.length > 0 ? joinedTables : undefined, // rightPanel.columns에서 참조하는 외부 테이블들 fieldMappings: fieldMappings.length > 0 ? fieldMappings : undefined, } as any); } @@ -1706,6 +1735,86 @@ export const getScreenSubTables = async (req: Request, res: Response) => { screenIds, rightPanelCount: rightPanelResult.rows.length }); + + // 5. joinedTables에 대한 FK 컬럼을 column_labels에서 조회 + // rightPanelRelation에서 joinedTables가 있는 경우, 해당 테이블과 조인하는 FK 컬럼 찾기 + const joinedTableFKLookups: Array<{ subTableName: string; refTable: string }> = []; + Object.values(screenSubTables).forEach((screenData: any) => { + screenData.subTables.forEach((subTable: any) => { + if (subTable.joinedTables && Array.isArray(subTable.joinedTables)) { + subTable.joinedTables.forEach((refTable: string) => { + joinedTableFKLookups.push({ subTableName: subTable.tableName, refTable }); + }); + } + }); + }); + + // column_labels에서 FK 컬럼 조회 (reference_table로 조인하는 컬럼 찾기) + const joinColumnsByTable: { [key: string]: string[] } = {}; // tableName → [FK 컬럼들] + if (joinedTableFKLookups.length > 0) { + const uniqueLookups = joinedTableFKLookups.filter((item, index, self) => + index === self.findIndex((t) => t.subTableName === item.subTableName && t.refTable === item.refTable) + ); + + // 각 subTable에 대해 reference_table이 일치하는 컬럼 조회 + const subTableNames = [...new Set(uniqueLookups.map(l => l.subTableName))]; + const refTableNames = [...new Set(uniqueLookups.map(l => l.refTable))]; + + const fkQuery = ` + SELECT + cl.table_name, + cl.column_name, + cl.column_label, + cl.reference_table, + cl.reference_column, + tl.table_label as reference_table_label + FROM column_labels cl + LEFT JOIN table_labels tl ON cl.reference_table = tl.table_name + WHERE cl.table_name = ANY($1) + AND cl.reference_table = ANY($2) + `; + + const fkResult = await pool.query(fkQuery, [subTableNames, refTableNames]); + + // 참조 정보 포함 객체 배열로 저장 (한글명 포함) + const joinColumnRefsByTable: Record> = {}; + + fkResult.rows.forEach((row: any) => { + if (!joinColumnRefsByTable[row.table_name]) { + joinColumnRefsByTable[row.table_name] = []; + } + // 중복 체크 + const exists = joinColumnRefsByTable[row.table_name].some( + (ref) => ref.column === row.column_name && ref.refTable === row.reference_table + ); + if (!exists) { + joinColumnRefsByTable[row.table_name].push({ + column: row.column_name, + columnLabel: row.column_label || row.column_name, // 컬럼 한글명 (없으면 영문명) + refTable: row.reference_table, + refTableLabel: row.reference_table_label || row.reference_table, // 참조 테이블 한글명 (없으면 영문명) + refColumn: row.reference_column || 'id', + }); + } + }); + + // subTables에 joinColumns (문자열 배열) 및 joinColumnRefs (참조 정보 배열) 추가 + Object.values(screenSubTables).forEach((screenData: any) => { + screenData.subTables.forEach((subTable: any) => { + const refs = joinColumnRefsByTable[subTable.tableName]; + if (refs) { + (subTable as any).joinColumns = refs.map(r => r.column); + (subTable as any).joinColumnRefs = refs; + } + }); + }); + + logger.info("rightPanel joinedTables FK 조회 완료", { + lookupCount: uniqueLookups.length, + resultCount: fkResult.rows.length, + joinColumnsByTable + }); + } // 5. 모든 fieldMappings의 한글명을 column_labels에서 가져와서 적용 // 모든 테이블/컬럼 조합을 수집 diff --git a/docs/화면관계_시각화_개선_보고서.md b/docs/화면관계_시각화_개선_보고서.md index 7dc11863..20edf254 100644 --- a/docs/화면관계_시각화_개선_보고서.md +++ b/docs/화면관계_시각화_개선_보고서.md @@ -1034,10 +1034,39 @@ screenSubTables[screenId].subTables.push({ **결과:** | 화면 | customer_item_mapping 표시 | |------|----------------------------| -| 1번 화면 포커스 | 필터 배지 O + FK 컬럼 보라색 | +| 1번 화면 포커스 | 필터 배지 O + FK 컬럼 보라색 + **상단 정렬** | | 4번 화면 포커스 | 필터 배지 X, 조인만 표시 | | 그룹 선택 (포커스 없음) | 필터 배지 X, 테이블명만 표시 | +9. **필터 컬럼 상단 정렬** + - 필터 컬럼도 파란색/주황색 컬럼처럼 상단에 정렬되어 표시 + - `potentialFilteredColumns`에 `filterSet` 포함 + - 정렬 순서: **조인 컬럼 → 필터 컬럼 → 사용 컬럼** + - 보라색 강조로 필터링 관계 명확히 구분 + +**정렬 우선순위:** +| 순서 | 컬럼 유형 | 색상 | 설명 | +|------|----------|------|------| +| 1 | 조인 컬럼 | 주황색 | FK 조인 관계 | +| 2 | 필터 컬럼 | 보라색 | 마스터-디테일 필터링 | +| 3 | 사용 컬럼 | 파란색 | 화면 필드 매핑 | + +10. **방안 C 적용: 필터선 제거 + 보라색 테두리 애니메이션** + - 필터 관계는 선 없이 뱃지 + 테이블 테두리로만 표시 (겹침 방지) + - 필터링된 테이블에 **보라색 테두리 + 펄스 애니메이션** 적용 + - 조인선(주황)만 표시, 필터선(보라) 제거 + +**시각적 표현:** +| 관계 유형 | 선 표시 | 테두리 | 배지 | +|----------|---------|--------|------| +| 조인 | ✅ 주황색 점선 | - | "조인" | +| 필터 | ❌ 없음 | 보라색 펄스 | "필터 + 키값" | +| 룩업 | ✅ 황색 점선 | - | "N곳 참조" | + +**구현 상세:** +- `ScreenRelationFlow.tsx`: `visualRelationType === 'filter'`인 경우 엣지 생성 건너뛰기 +- `ScreenNode.tsx`: `hasFilterRelation` 조건으로 보라색 테두리 + `animate-pulse` 클래스 적용 + ### 향후 개선 가능 사항 1. [ ] 범례(Legend) UI 추가 - 관계 유형별 색상 설명 @@ -1057,8 +1086,10 @@ screenSubTables[screenId].subTables.push({ 7. [x] 마스터-디테일 필터링 관계 표시 추가 8. [x] FK 컬럼 보라색 강조 + 키값 정보 표시 9. [x] 포커스 상태 기반 필터 표시 개선 -10. [ ] 범례 UI 추가 (선택사항) -11. [ ] 엣지 라벨에 관계 유형 표시 (선택사항) +10. [x] 필터 컬럼 상단 정렬 (조인 → 필터 → 사용 순서) +11. [x] 방안 C 적용: 필터선 제거 + 보라색 테두리 애니메이션 +12. [ ] 범례 UI 추가 (선택사항) +13. [ ] 엣지 라벨에 관계 유형 표시 (선택사항) --- diff --git a/frontend/components/screen/ScreenNode.tsx b/frontend/components/screen/ScreenNode.tsx index 5b4eeee2..7c934b3e 100644 --- a/frontend/components/screen/ScreenNode.tsx +++ b/frontend/components/screen/ScreenNode.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import React, { useMemo } from "react"; import { Handle, Position } from "@xyflow/react"; import { Monitor, @@ -43,9 +43,12 @@ export interface FieldMappingDisplay { // 참조 관계 정보 (다른 테이블에서 이 테이블을 참조하는 경우) export interface ReferenceInfo { - fromTable: string; // 참조하는 테이블명 - fromColumn: string; // 참조하는 컬럼명 + fromTable: string; // 참조하는 테이블명 (영문) + fromTableLabel?: string; // 참조하는 테이블 한글명 + fromColumn: string; // 참조하는 컬럼명 (영문) + fromColumnLabel?: string; // 참조하는 컬럼 한글명 toColumn: string; // 참조되는 컬럼명 (이 테이블의 컬럼) + toColumnLabel?: string; // 참조되는 컬럼 한글명 relationType: 'lookup' | 'join' | 'filter'; // 참조 유형 } @@ -66,6 +69,12 @@ export interface TableNodeData { // 포커스 시 강조할 컬럼 정보 highlightedColumns?: string[]; // 화면에서 사용하는 컬럼 (영문명) joinColumns?: string[]; // 조인에 사용되는 컬럼 + joinColumnRefs?: Array<{ // 조인 컬럼의 참조 정보 + column: string; // FK 컬럼명 (예: 'customer_id') + refTable: string; // 참조 테이블 (예: 'customer_mng') + refTableLabel?: string; // 참조 테이블 한글명 (예: '거래처 관리') + refColumn: string; // 참조 컬럼 (예: 'customer_code') + }>; filterColumns?: string[]; // 필터링에 사용되는 FK 컬럼 (마스터-디테일 관계) // 필드 매핑 정보 (조인 관계 표시용) fieldMappings?: FieldMappingDisplay[]; // 서브 테이블일 때 조인 관계 표시 @@ -431,13 +440,25 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType: // ========== 테이블 노드 (하단) - 컬럼 목록 표시 (컴팩트) ========== export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { - const { label, subLabel, isMain, isFocused, isFaded, columns, highlightedColumns, joinColumns, filterColumns, fieldMappings, referencedBy } = data; + const { label, subLabel, isMain, isFocused, isFaded, columns, highlightedColumns, joinColumns, joinColumnRefs, filterColumns, fieldMappings, referencedBy } = data; // 강조할 컬럼 세트 (영문 컬럼명 기준) const highlightSet = new Set(highlightedColumns || []); const filterSet = new Set(filterColumns || []); // 필터링에 사용되는 FK 컬럼 const joinSet = new Set(joinColumns || []); + // 조인 컬럼 참조 정보 맵 생성 (column → { refTable, refTableLabel, refColumn }) + const joinRefMap = new Map(); + if (joinColumnRefs) { + joinColumnRefs.forEach((ref) => { + joinRefMap.set(ref.column, { + refTable: ref.refTable, + refTableLabel: ref.refTableLabel || ref.refTable, // 한글명 (없으면 영문명) + refColumn: ref.refColumn + }); + }); + } + // 필드 매핑 맵 생성 (targetField → { sourceField, sourceDisplayName }) // 서브 테이블에서 targetField가 어떤 메인 테이블 컬럼(sourceField)과 연결되는지 const fieldMappingMap = new Map(); @@ -451,39 +472,93 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { }); } + // 필터 소스 컬럼 세트 (메인 테이블에서 필터에 사용되는 컬럼) + const filterSourceSet = new Set( + referencedBy?.filter(r => r.relationType === 'filter').map(r => r.fromColumn) || [] + ); + // 포커스 모드: 사용 컬럼만 필터링하여 표시 // originalName (영문) 또는 name으로 매칭 시도 + // 필터 컬럼(filterSet) 및 필터 소스 컬럼(filterSourceSet)도 포함하여 보라색으로 표시 const potentialFilteredColumns = columns?.filter(col => { const colOriginal = col.originalName || col.name; - return highlightSet.has(colOriginal) || joinSet.has(colOriginal); + return highlightSet.has(colOriginal) || joinSet.has(colOriginal) || filterSet.has(colOriginal) || filterSourceSet.has(colOriginal); }) || []; - const hasActiveColumns = potentialFilteredColumns.length > 0; + + // 정렬: 조인 컬럼 → 필터 컬럼/필터 소스 컬럼 → 사용 컬럼 순서 + const sortedFilteredColumns = [...potentialFilteredColumns].sort((a, b) => { + const aOriginal = a.originalName || a.name; + const bOriginal = b.originalName || b.name; + + const aIsJoin = joinSet.has(aOriginal); + const bIsJoin = joinSet.has(bOriginal); + const aIsFilter = filterSet.has(aOriginal) || filterSourceSet.has(aOriginal); + const bIsFilter = filterSet.has(bOriginal) || filterSourceSet.has(bOriginal); + + // 조인 컬럼 우선 + if (aIsJoin && !bIsJoin) return -1; + if (!aIsJoin && bIsJoin) return 1; + // 필터 컬럼/필터 소스 다음 + if (aIsFilter && !bIsFilter) return -1; + if (!aIsFilter && bIsFilter) return 1; + // 나머지는 원래 순서 유지 + return 0; + }); + + const hasActiveColumns = sortedFilteredColumns.length > 0; + + // 필터 관계가 있는 테이블인지 확인 (마스터-디테일 필터링) + // - hasFilterRelation: 디테일 테이블 (WHERE 조건 대상) - filterColumns에 FK 컬럼이 있음 + // - isFilterSource: 마스터 테이블 (필터 소스, WHERE 조건 제공) - 포커스된 화면의 메인 테이블이고 filterSourceSet에 컬럼이 있음 + // 디테일 테이블: filterColumns(filterSet)에 FK 컬럼이 있고, 포커스된 화면의 메인이 아님 + const hasFilterRelation = filterSet.size > 0 && !isFocused; + // 마스터 테이블: 포커스된 화면의 메인 테이블(isFocused)이고 filterSourceSet에 컬럼이 있음 + const isFilterSource = isFocused && filterSourceSet.size > 0; // 표시할 컬럼: - // - 포커스 시 (활성 컬럼 있음): 필터된 컬럼만 표시 + // - 포커스 시 (활성 컬럼 있음): 정렬된 컬럼만 표시 // - 비포커스 시: 최대 8개만 표시 const MAX_DEFAULT_COLUMNS = 8; const allColumns = columns || []; const displayColumns = hasActiveColumns - ? potentialFilteredColumns + ? sortedFilteredColumns : allColumns.slice(0, MAX_DEFAULT_COLUMNS); const remainingCount = hasActiveColumns ? 0 : Math.max(0, allColumns.length - MAX_DEFAULT_COLUMNS); const totalCount = allColumns.length; + // 컬럼 수 기반 높이 계산 (DOM 측정 없이) + // - 각 컬럼 행 높이: 약 22px (py-0.5 + text + gap-px) + // - 컨테이너 패딩: p-1.5 = 12px (상하 합계) + const COLUMN_ROW_HEIGHT = 22; + const CONTAINER_PADDING = 12; + const MAX_HEIGHT = 180; + + const calculatedHeight = useMemo(() => { + const rawHeight = CONTAINER_PADDING + (displayColumns.length * COLUMN_ROW_HEIGHT); + return Math.min(rawHeight, MAX_HEIGHT); + }, [displayColumns.length]); + return (
{/* Handles */} @@ -529,14 +604,21 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { className="!h-2 !w-2 !border-2 !border-background !bg-orange-500 opacity-0 transition-opacity group-hover:opacity-100" /> - {/* 헤더 (초록색, 컴팩트) */} -
{label}
- {subLabel &&
{subLabel}
} + {/* 필터 관계에 따른 문구 변경 */} +
+ {isFilterSource + ? "마스터 테이블 (필터 소스)" + : hasFilterRelation + ? "디테일 테이블 (WHERE 조건)" + : subLabel} +
{hasActiveColumns && ( @@ -565,7 +647,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { )} {filterRefs.length > 0 && ( - {filterRefs.map(r => `${r.fromTable}.${r.fromColumn || 'id'}`).join(', ')} + {filterRefs.map(r => `${r.fromTableLabel || r.fromTable}.${r.fromColumnLabel || r.fromColumn || 'id'}`).join(', ')} )} {lookupRefs.length > 0 && ( @@ -580,29 +662,38 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { ); })()} - {/* 컬럼 목록 - 컴팩트하게 (스크롤 가능) */} -
+ {/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환) */} +
{displayColumns.length > 0 ? ( -
+
{displayColumns.map((col, idx) => { const colOriginal = col.originalName || col.name; const isJoinColumn = joinSet.has(colOriginal); - const isFilterColumn = filterSet.has(colOriginal); // 필터링 FK 컬럼 + const isFilterColumn = filterSet.has(colOriginal); // 서브 테이블의 필터링 FK 컬럼 const isHighlighted = highlightSet.has(colOriginal); - // 필터링 참조 정보 (어떤 테이블의 어떤 컬럼에서 필터링되는지) + // 필터링 참조 정보 (어떤 테이블의 어떤 컬럼에서 필터링되는지) - 서브 테이블용 const filterRefInfo = referencedBy?.find( r => r.relationType === 'filter' && r.toColumn === colOriginal ); + // 메인 테이블에서 필터 소스로 사용되는 컬럼인지 (fromColumn과 일치) + const isFilterSourceColumn = filterSourceSet.has(colOriginal); + return (
= ({ data }) => { : "bg-slate-50 hover:bg-slate-100" }`} style={{ - animation: hasActiveColumns ? `fadeIn 0.3s ease ${idx * 50}ms forwards` : undefined, + animation: hasActiveColumns ? `fadeIn 0.5s ease-out ${idx * 80}ms forwards` : undefined, opacity: hasActiveColumns ? 0 : 1, }} > {/* PK/FK/조인/필터 아이콘 */} {isJoinColumn && } - {isFilterColumn && !isJoinColumn && } - {!isJoinColumn && !isFilterColumn && col.isPrimaryKey && } - {!isJoinColumn && !isFilterColumn && col.isForeignKey && !col.isPrimaryKey && } - {!isJoinColumn && !isFilterColumn && !col.isPrimaryKey && !col.isForeignKey &&
} + {(isFilterColumn || isFilterSourceColumn) && !isJoinColumn && } + {!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isPrimaryKey && } + {!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isForeignKey && !col.isPrimaryKey && } + {!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && !col.isPrimaryKey && !col.isForeignKey &&
} {/* 컬럼명 */} @@ -634,8 +725,14 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { {/* 역할 태그 + 참조 관계 표시 */} {isJoinColumn && ( <> - {/* 참조 관계 표시: ← 한글 컬럼명 (또는 영문) */} - {fieldMappingMap.has(colOriginal) && ( + {/* 조인 참조 테이블 표시 (joinColumnRefs에서) */} + {joinRefMap.has(colOriginal) && ( + + ← {joinRefMap.get(colOriginal)?.refTableLabel} + + )} + {/* 필드 매핑 참조 표시 (fieldMappingMap에서, joinRefMap에 없는 경우) */} + {!joinRefMap.has(colOriginal) && fieldMappingMap.has(colOriginal) && ( ← {fieldMappingMap.get(colOriginal)?.sourceDisplayName} @@ -643,12 +740,19 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => { 조인 )} - {isFilterColumn && !isJoinColumn && filterRefInfo && ( - - ← {filterRefInfo.fromTable}.{filterRefInfo.fromColumn || 'id'} - + {isFilterColumn && !isJoinColumn && ( + 필터 )} - {isHighlighted && !isJoinColumn && !isFilterColumn && ( + {/* 메인 테이블에서 필터 소스로 사용되는 컬럼: "필터" + "사용" 둘 다 표시 */} + {isFilterSourceColumn && !isJoinColumn && !isFilterColumn && ( + <> + 필터 + {isHighlighted && ( + 사용 + )} + + )} + {isHighlighted && !isJoinColumn && !isFilterColumn && !isFilterSourceColumn && ( 사용 )} diff --git a/frontend/components/screen/ScreenRelationFlow.tsx b/frontend/components/screen/ScreenRelationFlow.tsx index a593fe59..96b68753 100644 --- a/frontend/components/screen/ScreenRelationFlow.tsx +++ b/frontend/components/screen/ScreenRelationFlow.tsx @@ -668,6 +668,12 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId // 관계 유형 추론 및 색상 결정 const visualRelationType = inferVisualRelationType(subTable as SubTableInfo); + + // 방안 C: 필터 관계는 선 없이 뱃지로만 표시 (겹침 방지) + if (visualRelationType === 'filter') { + return; // 필터선 생성 건너뛰기 + } + const relationColor = RELATION_COLORS[visualRelationType]; // 화면별로 고유한 엣지 ID @@ -1172,23 +1178,79 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId let focusedFilterColumns: string[] = []; let focusedReferencedBy: ReferenceInfo[] = []; + // 조인 컬럼 참조 정보 수집 + let focusedJoinColumnRefs: Array<{ column: string; refTable: string; refColumn: string }> = []; + if (focusedScreenId !== null && focusedSubTablesData) { // 포커스된 화면에서 이 테이블이 rightPanelRelation의 서브테이블인 경우 focusedSubTablesData.subTables.forEach((subTable) => { if (subTable.tableName === tableName && subTable.relationType === 'rightPanelRelation') { - // FK 컬럼 추출 + // FK 컬럼 추출 (필터링 기준) if (subTable.foreignKey) { focusedFilterColumns.push(subTable.foreignKey); } - // 참조 정보 생성 + // 조인 컬럼 추가 (rightPanel.columns에서 외부 테이블 참조하는 FK) + // 예: customer_mng.customer_name 표시를 위해 customer_id 사용 + // 조인 컬럼은 주황색으로 표시되어야 하므로 joinColumns에 추가 + if (subTable.joinColumns && Array.isArray(subTable.joinColumns)) { + subTable.joinColumns.forEach((col) => { + if (!joinColumns.includes(col)) { + joinColumns.push(col); + } + }); + } + // 조인 컬럼 참조 정보 추가 + if (subTable.joinColumnRefs && Array.isArray(subTable.joinColumnRefs)) { + focusedJoinColumnRefs = [...focusedJoinColumnRefs, ...subTable.joinColumnRefs]; + } + // 참조 정보 생성 (한글명 포함) - 서브 테이블용 + const mainTableCols = tableColumns[focusedSubTablesData.mainTable] || []; + const fromColInfo = mainTableCols.find(c => c.columnName === (subTable.leftColumn || 'id')); + const fromColumnLabel = fromColInfo?.displayName || subTable.leftColumn || 'id'; + + const subTableCols = tableColumns[subTable.tableName] || []; + const toColInfo = subTableCols.find(c => c.columnName === subTable.foreignKey); + const toColumnLabel = toColInfo?.displayName || subTable.foreignKey || ''; + focusedReferencedBy.push({ fromTable: focusedSubTablesData.mainTable, + fromTableLabel: focusedSubTablesData.mainTable, fromColumn: subTable.leftColumn || 'id', + fromColumnLabel: fromColumnLabel, toColumn: subTable.foreignKey || '', + toColumnLabel: toColumnLabel, relationType: 'filter', }); } }); + + // 메인 테이블인 경우: 이 테이블이 필터 소스로 사용되는 정보 추가 + if (isFocusedTable) { + focusedSubTablesData.subTables.forEach((subTable) => { + if (subTable.relationType === 'rightPanelRelation' && subTable.leftColumn) { + // 메인 테이블의 컬럼 한글명 조회 + const mainTableCols = tableColumns[focusedSubTablesData.mainTable] || []; + const fromColInfo = mainTableCols.find(c => c.columnName === subTable.leftColumn); + const fromColumnLabel = fromColInfo?.displayName || subTable.leftColumn || 'id'; + + // 서브 테이블의 컬럼 한글명 조회 + const subTableCols = tableColumns[subTable.tableName] || []; + const toColInfo = subTableCols.find(c => c.columnName === subTable.foreignKey); + const toColumnLabel = toColInfo?.displayName || subTable.foreignKey || ''; + + // 메인 테이블 입장: "내 컬럼이 서브 테이블의 필터 소스로 사용됨" + focusedReferencedBy.push({ + fromTable: focusedSubTablesData.mainTable, + fromTableLabel: focusedSubTablesData.mainTable, + fromColumn: subTable.leftColumn, + fromColumnLabel: fromColumnLabel, + toColumn: subTable.foreignKey || '', + toColumnLabel: toColumnLabel, + relationType: 'filter', + }); + } + }); + } } return { @@ -1200,6 +1262,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId isFaded: focusedScreenId !== null && !isActiveTable, highlightedColumns: isActiveTable ? highlightedColumns : [], joinColumns: isActiveTable ? joinColumns : [], + joinColumnRefs: focusedJoinColumnRefs.length > 0 ? focusedJoinColumnRefs : undefined, // 조인 컬럼 참조 정보 filterColumns: focusedFilterColumns, // 포커스 상태에서만 표시 referencedBy: focusedReferencedBy.length > 0 ? focusedReferencedBy : undefined, // 포커스 상태에서만 표시 fieldMappings: isFocusedTable ? mainTableFieldMappings : (isRelatedTable ? relatedTableFieldMappings : []), @@ -1304,7 +1367,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId return node; }); - }, [nodes, selectedGroup, focusedScreenId, screenSubTableMap, subTablesDataMap, screenUsedColumnsMap, screenTableMap]); + }, [nodes, selectedGroup, focusedScreenId, screenSubTableMap, subTablesDataMap, screenUsedColumnsMap, screenTableMap, tableColumns]); // 포커스에 따른 엣지 스타일링 (그룹 모드 & 개별 화면 모드) const styledEdges = React.useMemo(() => { @@ -1405,6 +1468,12 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId // 관계 유형 추론 및 색상 결정 const visualRelationType = inferVisualRelationType(subTable as SubTableInfo); + + // 방안 C: 필터 관계는 선 없이 뱃지로만 표시 (겹침 방지) + if (visualRelationType === 'filter') { + return; // 필터선 생성 건너뛰기 + } + const relationColor = RELATION_COLORS[visualRelationType]; joinEdges.push({ diff --git a/frontend/lib/api/screenGroup.ts b/frontend/lib/api/screenGroup.ts index b3f4e849..b5003b83 100644 --- a/frontend/lib/api/screenGroup.ts +++ b/frontend/lib/api/screenGroup.ts @@ -410,6 +410,16 @@ export interface SubTableInfo { originalRelationType?: 'join' | 'detail'; // 원본 relation.type foreignKey?: string; // 디테일 테이블의 FK 컬럼 leftColumn?: string; // 마스터 테이블의 선택 기준 컬럼 + // rightPanel.columns에서 외부 테이블 참조 정보 + joinedTables?: string[]; // 참조하는 외부 테이블들 (예: ['customer_mng']) + joinColumns?: string[]; // 외부 테이블과 조인하는 FK 컬럼들 (예: ['customer_id']) + joinColumnRefs?: Array<{ // FK 컬럼 참조 정보 (어떤 테이블.컬럼에서 오는지) + column: string; // FK 컬럼명 (예: 'customer_id') + columnLabel: string; // FK 컬럼 한글명 (예: '거래처 ID') + refTable: string; // 참조 테이블 (예: 'customer_mng') + refTableLabel: string; // 참조 테이블 한글명 (예: '거래처 관리') + refColumn: string; // 참조 컬럼 (예: 'customer_code') + }>; } // 시각적 관계 유형 (시각화에서 사용)