feat: 서브 테이블 정보 및 관계 시각화 개선

- 화면 서브 테이블에서 valueField, parentFieldId, cascadingParentField, controlField 정보를 추출하는 쿼리 추가
- 서브 테이블의 관계 유형을 추론하기 위한 추가 정보 필드(originalRelationType, foreignKey, leftColumn) 포함
- 필터링에 사용되는 FK 컬럼을 TableNodeData 인터페이스에 추가하여 시각화 개선
- 관계 유형별 색상 정의 및 시각적 관계 유형 추론 함수 추가
- 화면 관계 흐름에서 서브 테이블 연결선 및 필터링 참조 정보 표시 기능 개선
This commit is contained in:
DDD1542 2026-01-08 14:24:33 +09:00
parent b279f8d58d
commit 8928d851ca
8 changed files with 1673 additions and 160 deletions

View File

@ -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);
}
});

File diff suppressed because it is too large Load Diff

View File

@ -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 (
<div key={groupId} className="mb-1">
return (
<div key={groupId} className="mb-1">
{/* 그룹 헤더 */}
<div
className={cn(

View File

@ -41,6 +41,14 @@ export interface FieldMappingDisplay {
targetDisplayName?: string; // 서브 테이블 한글 컬럼명 (예: 사용자ID)
}
// 참조 관계 정보 (다른 테이블에서 이 테이블을 참조하는 경우)
export interface ReferenceInfo {
fromTable: string; // 참조하는 테이블명
fromColumn: string; // 참조하는 컬럼명
toColumn: string; // 참조되는 컬럼명 (이 테이블의 컬럼)
relationType: 'lookup' | 'join' | 'filter'; // 참조 유형
}
// 테이블 노드 데이터 인터페이스
export interface TableNodeData {
label: string;
@ -58,8 +66,11 @@ export interface TableNodeData {
// 포커스 시 강조할 컬럼 정보
highlightedColumns?: string[]; // 화면에서 사용하는 컬럼 (영문명)
joinColumns?: string[]; // 조인에 사용되는 컬럼
filterColumns?: string[]; // 필터링에 사용되는 FK 컬럼 (마스터-디테일 관계)
// 필드 매핑 정보 (조인 관계 표시용)
fieldMappings?: FieldMappingDisplay[]; // 서브 테이블일 때 조인 관계 표시
// 참조 관계 정보 (다른 테이블에서 이 테이블을 참조하는 경우)
referencedBy?: ReferenceInfo[]; // 이 테이블을 참조하는 관계들
}
// ========== 유틸리티 함수 ==========
@ -420,10 +431,11 @@ const ScreenPreview: React.FC<{ layoutSummary: ScreenLayoutSummary; screenType:
// ========== 테이블 노드 (하단) - 컬럼 목록 표시 (컴팩트) ==========
export const TableNode: React.FC<{ data: TableNodeData }> = ({ 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: 화면 → 메인테이블 연결용 */}
<Handle
type="target"
position={Position.Top}
id="top"
className="!h-2 !w-2 !border-2 !border-background !bg-emerald-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/* top source: 메인테이블 → 메인테이블 연결용 (위쪽으로 나가는 선) */}
<Handle
type="source"
position={Position.Top}
id="top_source"
style={{ top: -4 }}
className="!h-2 !w-2 !border-2 !border-background !bg-orange-500 opacity-0 transition-opacity group-hover:opacity-100"
/>
<Handle
type="target"
position={Position.Left}
@ -499,22 +520,65 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ 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: 메인테이블 ← 메인테이블 연결용 (아래에서 들어오는 선) */}
<Handle
type="target"
position={Position.Bottom}
id="bottom_target"
style={{ bottom: -4 }}
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"
}`}>
<Database className="h-3.5 w-3.5" />
<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>
{hasActiveColumns && (
<span className="rounded-full bg-white/20 px-1.5 py-0.5 text-[8px]">
<span className="rounded-full bg-white/20 px-1.5 py-0.5 text-[8px] shrink-0">
{displayColumns.length}
</span>
)}
</div>
{/* 필터링/참조 관계 표시 (포커스 시에만 표시, 헤더 아래 별도 영역) */}
{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 (
<div className="flex items-center gap-1 px-2 py-1 bg-violet-50 border-b border-violet-100 text-[9px]">
{filterRefs.length > 0 && (
<span
className="flex items-center gap-0.5 rounded bg-violet-500 px-1.5 py-0.5 text-white font-medium"
title={`마스터-디테일 필터링\n${filterRefs.map(r => `${r.fromTable}.${r.fromColumn || 'id'}${r.toColumn}`).join('\n')}`}
>
<Link2 className="h-2.5 w-2.5" />
<span></span>
</span>
)}
{filterRefs.length > 0 && (
<span className="text-violet-600 truncate">
{filterRefs.map(r => `${r.fromTable}.${r.fromColumn || 'id'}`).join(', ')}
</span>
)}
{lookupRefs.length > 0 && (
<span
className="flex items-center gap-0.5 rounded bg-amber-500 px-1.5 py-0.5 text-white font-medium"
title={`코드 참조 (lookup)\n${lookupRefs.map(r => `${r.fromTable}${r.toColumn}`).join('\n')}`}
>
{lookupRefs.length}
</span>
)}
</div>
);
})()}
{/* 컬럼 목록 - 컴팩트하게 (스크롤 가능) */}
<div className="p-1.5 transition-all duration-300 max-h-[150px] overflow-y-auto">
@ -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 (
<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" // 필터 컬럼: 보라색
: isHighlighted
? "bg-blue-100 border border-blue-300 shadow-sm"
: hasActiveColumns
@ -542,15 +614,19 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
opacity: hasActiveColumns ? 0 : 1,
}}
>
{/* PK/FK/조인 아이콘 */}
{/* PK/FK/조인/필터 아이콘 */}
{isJoinColumn && <Link2 className="h-2.5 w-2.5 text-orange-500" />}
{!isJoinColumn && col.isPrimaryKey && <Key className="h-2.5 w-2.5 text-amber-500" />}
{!isJoinColumn && col.isForeignKey && !col.isPrimaryKey && <Link2 className="h-2.5 w-2.5 text-blue-500" />}
{!isJoinColumn && !col.isPrimaryKey && !col.isForeignKey && <div className="w-2.5" />}
{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" />}
{/* 컬럼명 */}
<span className={`flex-1 truncate font-mono text-[9px] font-medium ${
isJoinColumn ? "text-orange-700" : isHighlighted ? "text-blue-700" : "text-slate-700"
isJoinColumn ? "text-orange-700"
: isFilterColumn ? "text-violet-700"
: isHighlighted ? "text-blue-700"
: "text-slate-700"
}`}>
{col.name}
</span>
@ -567,7 +643,12 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
<span className="rounded bg-orange-200 px-1 text-[7px] text-orange-700"></span>
</>
)}
{isHighlighted && !isJoinColumn && (
{isFilterColumn && !isJoinColumn && filterRefInfo && (
<span className="rounded bg-violet-200 px-1 text-[7px] text-violet-700">
{filterRefInfo.fromTable}.{filterRefInfo.fromColumn || 'id'}
</span>
)}
{isHighlighted && !isJoinColumn && !isFilterColumn && (
<span className="rounded bg-blue-200 px-1 text-[7px] text-blue-700"></span>
)}

View File

@ -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<VisualRelationType, { stroke: string; strokeLight: string; label: string }> = {
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<string, ReferenceInfo[]>();
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<string>(); // 중복 방지용
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<string, { columns: string[], displayNames: string[] }> = {};
// 모든 화면의 메인 테이블 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<string>();
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<string>();
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
<div className="h-full w-full">
{/* isViewReady가 false면 숨김 처리하여 깜빡임 방지 */}
<div className={`h-full w-full transition-opacity duration-0 ${isViewReady ? "opacity-100" : "opacity-0"}`}>
<ReactFlow
<ReactFlow
nodes={styledNodes}
edges={styledEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={handleNodeClick}
nodeTypes={nodeTypes}
minZoom={0.3}
maxZoom={1.5}
proOptions={{ hideAttribution: true }}
>
nodeTypes={nodeTypes}
minZoom={0.3}
maxZoom={1.5}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#e2e8f0" />
<Controls position="bottom-right" />
</ReactFlow>
<Controls position="bottom-right" />
</ReactFlow>
</div>
</div>
);

View File

@ -456,3 +456,5 @@ export default function DataFlowPanel({ groupId, screenId, screens = [] }: DataF

View File

@ -408,3 +408,5 @@ export default function FieldJoinPanel({ screenId, componentId, layoutId }: Fiel

View File

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