feat: 서브 테이블 조인 및 필터 관계 정보 개선

- rightPanel.columns에서 참조하는 외부 테이블 및 조인 컬럼 정보를 수집하는 로직 추가
- 서브 테이블의 조인 컬럼 참조 정보를 포함하여 시각화 개선
- 필터 관계를 선 없이 뱃지로 표시하여 겹침 방지 및 시각적 표현 개선
- TableNodeData 인터페이스에 조인 컬럼 및 참조 정보 필드 추가
- 화면 관계 흐름에서 조인 컬럼 및 필터 관계 정보 표시 기능 개선
This commit is contained in:
DDD1542 2026-01-08 16:20:51 +09:00
parent 8928d851ca
commit b8c8b31033
5 changed files with 369 additions and 46 deletions

View File

@ -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<string, Set<string>> = 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;
@ -1627,6 +1651,10 @@ export const getScreenSubTables = async (req: Request, res: Response) => {
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] = {
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);
}
@ -1707,6 +1736,86 @@ export const getScreenSubTables = async (req: Request, res: Response) => {
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<string, Array<{ column: string; columnLabel: string; refTable: string; refTableLabel: string; refColumn: string }>> = {};
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에서 가져와서 적용
// 모든 테이블/컬럼 조합을 수집
const columnLookups: Array<{ tableName: string; columnName: string }> = [];

View File

@ -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. [ ] 엣지 라벨에 관계 유형 표시 (선택사항)
---

View File

@ -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<string, { refTable: string; refTableLabel: string; refColumn: string }>();
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<string, { sourceField: string; sourceDisplayName: string }>();
@ -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 (
<div
className={`group relative flex w-[260px] flex-col overflow-hidden rounded-lg border bg-card shadow-md transition-all duration-300 ${
isFocused
? "border-2 border-emerald-500 ring-4 ring-emerald-500/30 shadow-xl"
className={`group relative flex w-[260px] flex-col overflow-hidden rounded-lg border shadow-md ${
// 필터 관련 테이블 (마스터 또는 디테일): 보라색
(hasFilterRelation || isFilterSource)
? "border-2 border-violet-500 ring-4 ring-violet-500/30 shadow-xl bg-violet-50"
// 순수 포커스 (필터 관계 없음): 초록색
: isFocused
? "border-2 border-emerald-500 ring-4 ring-emerald-500/30 shadow-xl bg-card"
// 흐리게 처리
: isFaded
? "border-gray-200 opacity-60"
: "border-border hover:shadow-lg hover:ring-2 hover:ring-emerald-500/20"
? "border-gray-200 opacity-60 bg-card"
// 기본
: "border-border hover:shadow-lg hover:ring-2 hover:ring-emerald-500/20 bg-card"
}`}
style={{
filter: isFaded ? "grayscale(80%)" : "none",
transition: "all 0.3s ease",
// 색상/테두리/그림자만 transition (높이 제외)
transition: "background-color 0.7s ease, border-color 0.7s ease, box-shadow 0.7s ease, filter 0.3s ease, opacity 0.3s ease",
}}
>
{/* 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"
/>
{/* 헤더 (초록색, 컴팩트) */}
<div className={`flex items-center gap-2 px-3 py-1.5 text-white transition-colors duration-300 ${
isFaded ? "bg-gray-400" : isMain ? "bg-emerald-600" : "bg-slate-500"
{/* 헤더 (필터 관계: 보라색, 필터 소스: 보라색, 메인: 초록색, 기본: 슬레이트) */}
<div className={`flex items-center gap-2 px-3 py-1.5 text-white transition-colors duration-700 ease-in-out ${
isFaded ? "bg-gray-400" : (hasFilterRelation || isFilterSource) ? "bg-violet-600" : isMain ? "bg-emerald-600" : "bg-slate-500"
}`}>
<Database className="h-3.5 w-3.5 shrink-0" />
<div className="flex-1 min-w-0">
<div className="truncate text-[11px] font-semibold">{label}</div>
{subLabel && <div className="truncate text-[9px] opacity-80">{subLabel}</div>}
{/* 필터 관계에 따른 문구 변경 */}
<div className="truncate text-[9px] opacity-80">
{isFilterSource
? "마스터 테이블 (필터 소스)"
: hasFilterRelation
? "디테일 테이블 (WHERE 조건)"
: subLabel}
</div>
</div>
{hasActiveColumns && (
<span className="rounded-full bg-white/20 px-1.5 py-0.5 text-[8px] shrink-0">
@ -565,7 +647,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
)}
{filterRefs.length > 0 && (
<span className="text-violet-600 truncate">
{filterRefs.map(r => `${r.fromTable}.${r.fromColumn || 'id'}`).join(', ')}
{filterRefs.map(r => `${r.fromTableLabel || r.fromTable}.${r.fromColumnLabel || r.fromColumn || 'id'}`).join(', ')}
</span>
)}
{lookupRefs.length > 0 && (
@ -580,29 +662,38 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
);
})()}
{/* 컬럼 목록 - 컴팩트하게 (스크롤 가능) */}
<div className="p-1.5 transition-all duration-300 max-h-[150px] overflow-y-auto">
{/* 컬럼 목록 - 컴팩트하게 (부드러운 높이 전환) */}
<div
className="p-1.5 overflow-hidden"
style={{
height: `${calculatedHeight}px`,
transition: 'height 0.7s cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
{displayColumns.length > 0 ? (
<div className="flex flex-col gap-px">
<div className="flex flex-col gap-px transition-all duration-700 ease-in-out">
{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 (
<div
key={col.name}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 transition-all duration-300 ${
isJoinColumn
? "bg-orange-100 border border-orange-300 shadow-sm"
: isFilterColumn
? "bg-violet-100 border border-violet-300 shadow-sm" // 필터 컬럼: 보라색
: isFilterColumn || isFilterSourceColumn
? "bg-violet-100 border border-violet-300 shadow-sm" // 필터 컬럼/필터 소스: 보라색
: isHighlighted
? "bg-blue-100 border border-blue-300 shadow-sm"
: hasActiveColumns
@ -610,21 +701,21 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ 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 && <Link2 className="h-2.5 w-2.5 text-orange-500" />}
{isFilterColumn && !isJoinColumn && <Link2 className="h-2.5 w-2.5 text-violet-500" />}
{!isJoinColumn && !isFilterColumn && col.isPrimaryKey && <Key className="h-2.5 w-2.5 text-amber-500" />}
{!isJoinColumn && !isFilterColumn && col.isForeignKey && !col.isPrimaryKey && <Link2 className="h-2.5 w-2.5 text-blue-500" />}
{!isJoinColumn && !isFilterColumn && !col.isPrimaryKey && !col.isForeignKey && <div className="w-2.5" />}
{(isFilterColumn || isFilterSourceColumn) && !isJoinColumn && <Link2 className="h-2.5 w-2.5 text-violet-500" />}
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isPrimaryKey && <Key className="h-2.5 w-2.5 text-amber-500" />}
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && col.isForeignKey && !col.isPrimaryKey && <Link2 className="h-2.5 w-2.5 text-blue-500" />}
{!isJoinColumn && !isFilterColumn && !isFilterSourceColumn && !col.isPrimaryKey && !col.isForeignKey && <div className="w-2.5" />}
{/* 컬럼명 */}
<span className={`flex-1 truncate font-mono text-[9px] font-medium ${
isJoinColumn ? "text-orange-700"
: isFilterColumn ? "text-violet-700"
: (isFilterColumn || isFilterSourceColumn) ? "text-violet-700"
: isHighlighted ? "text-blue-700"
: "text-slate-700"
}`}>
@ -634,8 +725,14 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
{/* 역할 태그 + 참조 관계 표시 */}
{isJoinColumn && (
<>
{/* 참조 관계 표시: ← 한글 컬럼명 (또는 영문) */}
{fieldMappingMap.has(colOriginal) && (
{/* 조인 참조 테이블 표시 (joinColumnRefs에서) */}
{joinRefMap.has(colOriginal) && (
<span className="rounded bg-orange-100 px-1 text-[7px] text-orange-600">
{joinRefMap.get(colOriginal)?.refTableLabel}
</span>
)}
{/* 필드 매핑 참조 표시 (fieldMappingMap에서, joinRefMap에 없는 경우) */}
{!joinRefMap.has(colOriginal) && fieldMappingMap.has(colOriginal) && (
<span className="rounded bg-orange-100 px-1 text-[7px] text-orange-600">
{fieldMappingMap.get(colOriginal)?.sourceDisplayName}
</span>
@ -643,12 +740,19 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
<span className="rounded bg-orange-200 px-1 text-[7px] text-orange-700"></span>
</>
)}
{isFilterColumn && !isJoinColumn && filterRefInfo && (
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700">
{filterRefInfo.fromTable}.{filterRefInfo.fromColumn || 'id'}
</span>
{isFilterColumn && !isJoinColumn && (
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700"></span>
)}
{isHighlighted && !isJoinColumn && !isFilterColumn && (
{/* 메인 테이블에서 필터 소스로 사용되는 컬럼: "필터" + "사용" 둘 다 표시 */}
{isFilterSourceColumn && !isJoinColumn && !isFilterColumn && (
<>
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700"></span>
{isHighlighted && (
<span className="rounded bg-blue-200 px-1 text-[7px] text-blue-700"></span>
)}
</>
)}
{isHighlighted && !isJoinColumn && !isFilterColumn && !isFilterSourceColumn && (
<span className="rounded bg-blue-200 px-1 text-[7px] text-blue-700"></span>
)}

View File

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

View File

@ -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')
}>;
}
// 시각적 관계 유형 (시각화에서 사용)