2271 lines
99 KiB
TypeScript
2271 lines
99 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import {
|
|
ReactFlow,
|
|
Controls,
|
|
Background,
|
|
BackgroundVariant,
|
|
Node,
|
|
Edge,
|
|
useNodesState,
|
|
useEdgesState,
|
|
MarkerType,
|
|
useReactFlow,
|
|
ReactFlowProvider,
|
|
} from "@xyflow/react";
|
|
import "@xyflow/react/dist/style.css";
|
|
|
|
import { ScreenDefinition } from "@/types/screen";
|
|
import { ScreenNode, TableNode, ScreenNodeData, TableNodeData, ReferenceInfo } from "./ScreenNode";
|
|
import {
|
|
getFieldJoins,
|
|
getDataFlows,
|
|
getTableRelations,
|
|
getMultipleScreenLayoutSummary,
|
|
getScreenGroup,
|
|
getScreenSubTables,
|
|
ScreenLayoutSummary,
|
|
ScreenSubTablesData,
|
|
SubTableInfo,
|
|
inferVisualRelationType,
|
|
VisualRelationType,
|
|
} from "@/lib/api/screenGroup";
|
|
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
|
|
import { ScreenSettingModal } from "./ScreenSettingModal";
|
|
import { TableSettingModal } from "./TableSettingModal";
|
|
|
|
// 관계 유형별 색상 정의
|
|
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: '#f97316', strokeLight: '#fdba74', label: '엔티티 조인' }, // orange-500 (기존 주황색)
|
|
};
|
|
|
|
// 노드 타입 등록
|
|
const nodeTypes = {
|
|
screenNode: ScreenNode,
|
|
tableNode: TableNode,
|
|
};
|
|
|
|
// 레이아웃 상수
|
|
const SCREEN_Y = 50; // 화면 노드 Y 위치 (상단)
|
|
const TABLE_Y = 420; // 메인 테이블 노드 Y 위치 (중단)
|
|
const SUB_TABLE_Y = 740; // 서브 테이블 노드 Y 위치 (하단) - 메인과 320px 간격
|
|
const NODE_WIDTH = 260; // 노드 너비
|
|
const NODE_GAP = 40; // 노드 간격
|
|
|
|
interface ScreenRelationFlowProps {
|
|
screen: ScreenDefinition | null;
|
|
selectedGroup?: { id: number; name: string } | null;
|
|
initialFocusedScreenId?: number | null;
|
|
}
|
|
|
|
// 노드 타입 (Record<string, unknown> 확장)
|
|
type ScreenNodeType = Node<ScreenNodeData & Record<string, unknown>>;
|
|
type TableNodeType = Node<TableNodeData & Record<string, unknown>>;
|
|
type AllNodeType = ScreenNodeType | TableNodeType;
|
|
|
|
// 내부 컴포넌트 (useReactFlow 사용 가능)
|
|
function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId }: ScreenRelationFlowProps) {
|
|
const [nodes, setNodes, onNodesChange] = useNodesState<AllNodeType>([]);
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [tableColumns, setTableColumns] = useState<Record<string, ColumnTypeInfo[]>>({});
|
|
|
|
// ReactFlow 인스턴스 (fitView 제어용)
|
|
const reactFlowInstance = useReactFlow();
|
|
|
|
// 데이터 로드 버전 (초기 로드 시에만 fitView 호출)
|
|
const [dataLoadVersion, setDataLoadVersion] = useState(0);
|
|
|
|
// 뷰 준비 상태 (fitView 완료 후 true로 설정하여 깜빡임 방지)
|
|
const [isViewReady, setIsViewReady] = useState(false);
|
|
|
|
|
|
// 그룹 내 포커스된 화면 ID (그룹 모드에서만 사용)
|
|
const [focusedScreenId, setFocusedScreenId] = useState<number | null>(null);
|
|
|
|
// 노드 설정 모달 상태
|
|
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
|
|
const [settingModalNode, setSettingModalNode] = useState<{
|
|
nodeType: "screen" | "table";
|
|
nodeId: string;
|
|
screenId: number;
|
|
screenName: string;
|
|
tableName?: string;
|
|
tableLabel?: string;
|
|
// 기존 설정 정보 (화면 디자이너에서 추출)
|
|
existingConfig?: {
|
|
joinColumnRefs?: Array<{
|
|
column: string;
|
|
refTable: string;
|
|
refTableLabel?: string;
|
|
refColumn: string;
|
|
}>;
|
|
filterColumns?: string[];
|
|
fieldMappings?: Array<{
|
|
targetField: string;
|
|
sourceField: string;
|
|
sourceTable?: string;
|
|
sourceDisplayName?: string;
|
|
}>;
|
|
referencedBy?: Array<{
|
|
fromTable: string;
|
|
fromTableLabel?: string;
|
|
fromColumn: string;
|
|
toColumn: string;
|
|
toColumnLabel?: string;
|
|
relationType: string;
|
|
}>;
|
|
columns?: Array<{
|
|
name: string;
|
|
originalName?: string;
|
|
type: string;
|
|
isPrimaryKey?: boolean;
|
|
isForeignKey?: boolean;
|
|
}>;
|
|
// 화면 노드용 테이블 정보
|
|
mainTable?: string;
|
|
filterTables?: Array<{
|
|
tableName: string;
|
|
tableLabel: string;
|
|
filterColumns: string[];
|
|
joinColumnRefs: Array<{
|
|
column: string;
|
|
refTable: string;
|
|
refTableLabel?: string;
|
|
refColumn: string;
|
|
}>;
|
|
}>;
|
|
};
|
|
} | null>(null);
|
|
|
|
// 강제 새로고침용 키 (설정 저장 후 시각화 재로딩)
|
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
|
|
// 그룹 또는 화면이 변경될 때 포커스 초기화
|
|
useEffect(() => {
|
|
setFocusedScreenId(null);
|
|
}, [selectedGroup?.id, screen?.screenId]);
|
|
|
|
// 외부에서 전달된 초기 포커스 ID 적용 (화면 이동 없이 강조만)
|
|
useEffect(() => {
|
|
if (initialFocusedScreenId !== undefined) {
|
|
setFocusedScreenId(initialFocusedScreenId);
|
|
}
|
|
}, [initialFocusedScreenId]);
|
|
|
|
// 화면 ID와 테이블명 매핑 (포커스 시 연결선 강조용)
|
|
const [screenTableMap, setScreenTableMap] = useState<Record<number, string>>({});
|
|
|
|
// 화면 ID별 서브 테이블 매핑 (포커스 시 서브 테이블 연결선 강조용)
|
|
const [screenSubTableMap, setScreenSubTableMap] = useState<Record<number, string[]>>({});
|
|
|
|
// 서브 테이블 데이터 저장 (조인 컬럼 정보 포함)
|
|
const [subTablesDataMap, setSubTablesDataMap] = useState<Record<number, ScreenSubTablesData>>({});
|
|
|
|
// 화면별 사용 컬럼 매핑 (화면 ID -> 테이블명 -> 사용 컬럼들)
|
|
const [screenUsedColumnsMap, setScreenUsedColumnsMap] = useState<Record<number, Record<string, string[]>>>({});
|
|
|
|
// 테이블 컬럼 정보 로드
|
|
const loadTableColumns = useCallback(
|
|
async (tableName: string): Promise<ColumnTypeInfo[]> => {
|
|
if (!tableName) return [];
|
|
if (tableColumns[tableName]) return tableColumns[tableName];
|
|
|
|
try {
|
|
const response = await getTableColumns(tableName);
|
|
if (response.success && response.data && response.data.columns) {
|
|
const columns = response.data.columns;
|
|
setTableColumns((prev) => ({ ...prev, [tableName]: columns }));
|
|
return columns;
|
|
}
|
|
} catch (error) {
|
|
console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error);
|
|
}
|
|
return [];
|
|
},
|
|
[tableColumns]
|
|
);
|
|
|
|
// 중복 useEffect 제거됨 (위에서 이미 선언)
|
|
|
|
// 데이터 로드 및 노드/엣지 생성
|
|
useEffect(() => {
|
|
// 그룹도 없고 화면도 없으면 빈 상태
|
|
if (!screen && !selectedGroup) {
|
|
setNodes([]);
|
|
setEdges([]);
|
|
return;
|
|
}
|
|
|
|
const loadRelations = async () => {
|
|
setLoading(true);
|
|
setIsViewReady(false); // 뷰 준비 상태 초기화
|
|
|
|
try {
|
|
let screenList: ScreenDefinition[] = [];
|
|
|
|
// ========== 그룹 선택 시: 그룹의 화면들 로드 ==========
|
|
if (selectedGroup) {
|
|
const groupRes = await getScreenGroup(selectedGroup.id);
|
|
if (groupRes.success && groupRes.data) {
|
|
const groupData = groupRes.data as any;
|
|
const groupScreens = groupData.screens || [];
|
|
|
|
// display_order 순으로 정렬
|
|
groupScreens.sort((a: any, b: any) => (a.display_order || 0) - (b.display_order || 0));
|
|
|
|
// screen_definitions 형식으로 변환 (table_name 포함)
|
|
screenList = groupScreens.map((gs: any) => ({
|
|
screenId: gs.screen_id,
|
|
screenName: gs.screen_name || `화면 ${gs.screen_id}`,
|
|
screenCode: gs.screen_code || "",
|
|
tableName: gs.table_name || "", // 테이블명 포함
|
|
companyCode: groupData.company_code,
|
|
isActive: "Y",
|
|
createdDate: new Date(),
|
|
updatedDate: new Date(),
|
|
screenRole: gs.screen_role, // screen_role 추가
|
|
displayOrder: gs.display_order, // display_order 추가
|
|
} as ScreenDefinition & { screenRole?: string; displayOrder?: number }));
|
|
}
|
|
} else if (screen) {
|
|
// 기존 방식: 선택된 화면 중심
|
|
screenList = [screen];
|
|
}
|
|
|
|
if (screenList.length === 0) {
|
|
setNodes([]);
|
|
setEdges([]);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// 화면-테이블 매핑 저장 (포커스 시 연결선 강조용)
|
|
const newScreenTableMap: Record<number, string> = {};
|
|
screenList.forEach((scr: any) => {
|
|
if (scr.tableName) {
|
|
newScreenTableMap[scr.screenId] = scr.tableName;
|
|
}
|
|
});
|
|
setScreenTableMap(newScreenTableMap);
|
|
|
|
// 관계 데이터 로드 (첫 번째 화면 기준)
|
|
const [joinsRes, flowsRes, relationsRes] = await Promise.all([
|
|
getFieldJoins(screenList[0].screenId).catch(() => ({ success: false, data: [] })),
|
|
getDataFlows().catch(() => ({ success: false, data: [] })),
|
|
getTableRelations({ screen_id: screenList[0].screenId }).catch(() => ({ success: false, data: [] })),
|
|
]);
|
|
|
|
const joins = joinsRes.success ? joinsRes.data || [] : [];
|
|
const flows = flowsRes.success ? flowsRes.data || [] : [];
|
|
const relations = relationsRes.success ? relationsRes.data || [] : [];
|
|
|
|
// 데이터 흐름에서 연결된 화면들 추가
|
|
flows.forEach((flow: any) => {
|
|
if (flow.source_screen_id === screen.screenId && flow.target_screen_id) {
|
|
const exists = screenList.some((s) => s.screenId === flow.target_screen_id);
|
|
if (!exists) {
|
|
screenList.push({
|
|
screenId: flow.target_screen_id,
|
|
screenName: flow.target_screen_name || `화면 ${flow.target_screen_id}`,
|
|
screenCode: "",
|
|
tableName: "",
|
|
companyCode: screen.companyCode,
|
|
isActive: "Y",
|
|
createdDate: new Date(),
|
|
updatedDate: new Date(),
|
|
} as ScreenDefinition);
|
|
}
|
|
}
|
|
});
|
|
|
|
// 화면 레이아웃 요약 정보 로드
|
|
const screenIds = screenList.map((s) => s.screenId);
|
|
let layoutSummaries: Record<number, ScreenLayoutSummary> = {};
|
|
let subTablesData: Record<number, ScreenSubTablesData> = {};
|
|
try {
|
|
// 레이아웃 요약과 서브 테이블 정보 병렬 로드
|
|
const [layoutRes, subTablesRes] = await Promise.all([
|
|
getMultipleScreenLayoutSummary(screenIds),
|
|
getScreenSubTables(screenIds),
|
|
]);
|
|
|
|
if (layoutRes.success && layoutRes.data) {
|
|
// API 응답이 Record 형태 (screenId -> summary)
|
|
layoutSummaries = layoutRes.data as Record<number, ScreenLayoutSummary>;
|
|
}
|
|
|
|
if (subTablesRes.success && subTablesRes.data) {
|
|
subTablesData = subTablesRes.data as Record<number, ScreenSubTablesData>;
|
|
// 서브 테이블 데이터 저장 (조인 컬럼 정보 포함)
|
|
setSubTablesDataMap(subTablesData);
|
|
}
|
|
} catch (e) {
|
|
console.error("레이아웃 요약/서브 테이블 로드 실패:", e);
|
|
}
|
|
|
|
// 화면별 사용 컬럼 정보 추출 (layoutSummaries에서)
|
|
const usedColumnsMap: Record<number, Record<string, string[]>> = {};
|
|
screenList.forEach((screenItem) => {
|
|
const layout = layoutSummaries[screenItem.screenId];
|
|
if (layout && layout.layoutItems) {
|
|
const mainTable = screenItem.tableName;
|
|
if (mainTable) {
|
|
// layoutItems에서 사용 컬럼과 조인 컬럼 추출
|
|
const allUsedColumns: string[] = [];
|
|
const allJoinColumns: string[] = [];
|
|
|
|
layout.layoutItems.forEach((item) => {
|
|
// usedColumns 배열에서 추출 (columns_config에서 가져온 컬럼명)
|
|
if (item.usedColumns && Array.isArray(item.usedColumns)) {
|
|
item.usedColumns.forEach((col) => {
|
|
if (col && !allUsedColumns.includes(col)) {
|
|
allUsedColumns.push(col);
|
|
}
|
|
});
|
|
}
|
|
// joinColumns 배열에서 추출 (isEntityJoin = true인 컬럼)
|
|
if (item.joinColumns && Array.isArray(item.joinColumns)) {
|
|
item.joinColumns.forEach((col) => {
|
|
if (col && !allJoinColumns.includes(col)) {
|
|
allJoinColumns.push(col);
|
|
}
|
|
});
|
|
}
|
|
// 하위 호환성: bindField도 사용 컬럼에 추가
|
|
if (item.bindField && !allUsedColumns.includes(item.bindField)) {
|
|
allUsedColumns.push(item.bindField);
|
|
}
|
|
});
|
|
|
|
if (!usedColumnsMap[screenItem.screenId]) {
|
|
usedColumnsMap[screenItem.screenId] = {};
|
|
}
|
|
// 사용 컬럼과 조인 컬럼을 별도 키로 저장
|
|
usedColumnsMap[screenItem.screenId][mainTable] = allUsedColumns;
|
|
usedColumnsMap[screenItem.screenId][`${mainTable}__join`] = allJoinColumns;
|
|
}
|
|
}
|
|
});
|
|
setScreenUsedColumnsMap(usedColumnsMap);
|
|
|
|
// ========== 상단: 화면 노드들 ==========
|
|
const screenNodes: ScreenNodeType[] = [];
|
|
const screenStartX = 50;
|
|
|
|
// screen_role 레이블 매핑
|
|
const getRoleLabel = (role?: string) => {
|
|
if (!role || role === "member") return "화면";
|
|
const roleMap: Record<string, string> = {
|
|
main_list: "메인 그리드",
|
|
register_form: "등록 폼",
|
|
popup: "팝업",
|
|
detail: "상세",
|
|
};
|
|
return roleMap[role] || role;
|
|
};
|
|
|
|
screenList.forEach((scr: any, idx) => {
|
|
const isMain = screen && scr.screenId === screen.screenId;
|
|
const summary = layoutSummaries[scr.screenId];
|
|
const roleLabel = getRoleLabel(scr.screenRole);
|
|
|
|
// 포커스 여부 결정 (그룹 모드 & 개별 화면 모드 모두 지원)
|
|
const isInGroup = !!selectedGroup;
|
|
let isFocused: boolean;
|
|
let isFaded: boolean;
|
|
|
|
if (isInGroup) {
|
|
// 그룹 모드: 클릭한 화면만 포커스
|
|
isFocused = focusedScreenId === scr.screenId;
|
|
isFaded = focusedScreenId !== null && !isFocused;
|
|
} else {
|
|
// 개별 화면 모드: 메인 화면(선택된 화면)만 포커스, 연결 화면은 흐리게
|
|
isFocused = isMain;
|
|
isFaded = !isMain && screenList.length > 1;
|
|
}
|
|
|
|
screenNodes.push({
|
|
id: `screen-${scr.screenId}`,
|
|
type: "screenNode",
|
|
position: { x: screenStartX + idx * (NODE_WIDTH + NODE_GAP), y: SCREEN_Y },
|
|
data: {
|
|
label: scr.screenName,
|
|
subLabel: selectedGroup ? `${roleLabel} (#${scr.displayOrder || idx + 1})` : (isMain ? "메인 화면" : "연결 화면"),
|
|
type: "screen",
|
|
isMain: selectedGroup ? idx === 0 : isMain,
|
|
tableName: scr.tableName,
|
|
layoutSummary: summary,
|
|
// 화면 포커스 관련 속성 (그룹 모드 & 개별 모드 공통)
|
|
isInGroup,
|
|
isFocused,
|
|
isFaded,
|
|
screenRole: scr.screenRole,
|
|
},
|
|
});
|
|
});
|
|
|
|
// ========== 중단: 메인 테이블 노드들 ==========
|
|
const tableNodes: TableNodeType[] = [];
|
|
const mainTableSet = new Set<string>();
|
|
const subTableSet = new Set<string>();
|
|
|
|
// 모든 화면의 메인 테이블 추가
|
|
screenList.forEach((scr) => {
|
|
if (scr.tableName) {
|
|
mainTableSet.add(scr.tableName);
|
|
}
|
|
});
|
|
|
|
// 조인된 테이블들 (screen_field_joins에서)
|
|
joins.forEach((join: any) => {
|
|
if (join.save_table) mainTableSet.add(join.save_table);
|
|
if (join.join_table) mainTableSet.add(join.join_table);
|
|
});
|
|
|
|
// 테이블 관계에서 추가
|
|
relations.forEach((rel: any) => {
|
|
if (rel.table_name) mainTableSet.add(rel.table_name);
|
|
});
|
|
|
|
// 서브 테이블 수집 (componentConfig에서 추출된 테이블들)
|
|
// 서브 테이블은 메인 테이블과 다른 테이블들
|
|
// 화면별 서브 테이블 매핑도 함께 구축
|
|
const newScreenSubTableMap: Record<number, string[]> = {};
|
|
|
|
Object.entries(subTablesData).forEach(([screenIdStr, screenSubData]) => {
|
|
const screenId = parseInt(screenIdStr);
|
|
const subTableNames: string[] = [];
|
|
|
|
screenSubData.subTables.forEach((subTable) => {
|
|
// 메인 테이블에 없는 것만 서브 테이블로 추가
|
|
if (!mainTableSet.has(subTable.tableName)) {
|
|
subTableSet.add(subTable.tableName);
|
|
subTableNames.push(subTable.tableName);
|
|
}
|
|
});
|
|
|
|
if (subTableNames.length > 0) {
|
|
newScreenSubTableMap[screenId] = subTableNames;
|
|
}
|
|
});
|
|
|
|
// 화면별 서브 테이블 매핑 저장
|
|
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);
|
|
|
|
// 화면 노드들의 총 너비 계산
|
|
const screenTotalWidth = screenList.length * NODE_WIDTH + (screenList.length - 1) * NODE_GAP;
|
|
const screenCenterX = screenStartX + screenTotalWidth / 2;
|
|
|
|
// 메인 테이블 노드들의 총 너비 계산
|
|
const mainTableTotalWidth = mainTableList.length * NODE_WIDTH + (mainTableList.length - 1) * NODE_GAP;
|
|
const mainTableStartX = screenCenterX - mainTableTotalWidth / 2;
|
|
|
|
// 각 테이블이 어떤 화면들의 메인 테이블인지 매핑
|
|
const tableToScreensMap = new Map<string, string[]>();
|
|
screenList.forEach((scr: any) => {
|
|
if (scr.tableName) {
|
|
const screens = tableToScreensMap.get(scr.tableName) || [];
|
|
screens.push(scr.screenName);
|
|
tableToScreensMap.set(scr.tableName, screens);
|
|
}
|
|
});
|
|
|
|
for (let idx = 0; idx < mainTableList.length; idx++) {
|
|
const tableName = mainTableList[idx];
|
|
// mainTableSet에 있는 테이블은 모두 해당 화면의 "메인 테이블"
|
|
const linkedScreens = tableToScreensMap.get(tableName) || [];
|
|
|
|
// 컬럼 정보 로드
|
|
let columns: ColumnTypeInfo[] = [];
|
|
try {
|
|
columns = await loadTableColumns(tableName);
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
|
|
// 컬럼 정보를 PK/FK 표시와 함께 변환 (전체 컬럼 저장, 표시는 TableNode에서 제한)
|
|
const formattedColumns = columns.map((col) => ({
|
|
name: col.displayName || col.columnName || "",
|
|
originalName: col.columnName || "", // 영문 컬럼명 (필터링용)
|
|
type: col.dataType || "",
|
|
isPrimaryKey: col.isPrimaryKey || col.columnName === "id",
|
|
isForeignKey: !!col.referenceTable || (col.columnName?.includes("_id") && col.columnName !== "id"),
|
|
}));
|
|
|
|
// 여러 화면이 같은 테이블 사용하면 "공통 메인 테이블", 아니면 "메인 테이블"
|
|
const subLabel = linkedScreens.length > 1
|
|
? `메인 테이블 (${linkedScreens.length}개 화면)`
|
|
: "메인 테이블";
|
|
|
|
// 이 테이블을 참조하는 관계들
|
|
tableNodes.push({
|
|
id: `table-${tableName}`,
|
|
type: "tableNode",
|
|
position: { x: mainTableStartX + idx * (NODE_WIDTH + NODE_GAP), y: TABLE_Y },
|
|
data: {
|
|
label: tableName,
|
|
subLabel: subLabel,
|
|
isMain: true, // mainTableSet의 모든 테이블은 메인
|
|
columns: formattedColumns,
|
|
// referencedBy, filterColumns, saveInfos는 styledNodes에서 포커스 상태에 따라 동적으로 설정
|
|
},
|
|
});
|
|
}
|
|
|
|
// ========== 하단: 서브 테이블 노드들 (참조/조회용) ==========
|
|
const subTableList = Array.from(subTableSet);
|
|
|
|
if (subTableList.length > 0) {
|
|
// 서브 테이블 노드들의 총 너비 계산
|
|
const subTableTotalWidth = subTableList.length * NODE_WIDTH + (subTableList.length - 1) * NODE_GAP;
|
|
const subTableStartX = screenCenterX - subTableTotalWidth / 2;
|
|
|
|
for (let idx = 0; idx < subTableList.length; idx++) {
|
|
const tableName = subTableList[idx];
|
|
|
|
// 컬럼 정보 로드
|
|
let columns: ColumnTypeInfo[] = [];
|
|
try {
|
|
columns = await loadTableColumns(tableName);
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
|
|
// 컬럼 정보를 PK/FK 표시와 함께 변환 (전체 컬럼 저장, 표시는 TableNode에서 제한)
|
|
const formattedColumns = columns.map((col) => ({
|
|
name: col.displayName || col.columnName || "",
|
|
originalName: col.columnName || "", // 영문 컬럼명 (필터링용)
|
|
type: col.dataType || "",
|
|
isPrimaryKey: col.isPrimaryKey || col.columnName === "id",
|
|
isForeignKey: !!col.referenceTable || (col.columnName?.includes("_id") && col.columnName !== "id"),
|
|
}));
|
|
|
|
// 서브 테이블의 관계 타입 결정
|
|
let relationType = "참조";
|
|
Object.values(subTablesData).forEach((screenSubData) => {
|
|
const matchedSub = screenSubData.subTables.find((st) => st.tableName === tableName);
|
|
if (matchedSub) {
|
|
if (matchedSub.relationType === "lookup") relationType = "조회";
|
|
else if (matchedSub.relationType === "source") relationType = "데이터 소스";
|
|
else if (matchedSub.relationType === "join") relationType = "조인";
|
|
}
|
|
});
|
|
|
|
tableNodes.push({
|
|
id: `subtable-${tableName}`,
|
|
type: "tableNode",
|
|
position: { x: subTableStartX + idx * (NODE_WIDTH + NODE_GAP), y: SUB_TABLE_Y },
|
|
data: {
|
|
label: tableName,
|
|
subLabel: `서브 테이블 (${relationType})`,
|
|
isMain: false,
|
|
columns: formattedColumns,
|
|
isFaded: true, // 기본적으로 흐리게 표시 (포커스 시에만 활성화)
|
|
// referencedBy, filterColumns, saveInfos는 styledNodes에서 포커스 상태에 따라 동적으로 설정
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// ========== 엣지: 연결선 생성 ==========
|
|
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({
|
|
id: `edge-screen-flow-${i}`,
|
|
source: `screen-${currentScreen.screenId}`,
|
|
target: `screen-${nextScreen.screenId}`,
|
|
sourceHandle: "right",
|
|
targetHandle: "left",
|
|
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,
|
|
style: { stroke: "#0ea5e9", strokeWidth: 2 },
|
|
});
|
|
}
|
|
}
|
|
|
|
// 각 화면 → 해당 메인 테이블 연결선 생성 (실선)
|
|
// 모든 화면-테이블 연결은 동일한 스타일 (각 화면의 메인 테이블이므로)
|
|
screenList.forEach((scr) => {
|
|
if (scr.tableName && mainTableSet.has(scr.tableName)) {
|
|
newEdges.push({
|
|
id: `edge-screen-table-${scr.screenId}`,
|
|
source: `screen-${scr.screenId}`,
|
|
target: `table-${scr.tableName}`,
|
|
sourceHandle: "bottom",
|
|
targetHandle: "top",
|
|
type: "smoothstep",
|
|
animated: true, // 모든 메인 테이블 연결은 애니메이션
|
|
style: {
|
|
stroke: "#3b82f6",
|
|
strokeWidth: 2,
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
// 필터링 관계일 때 화면 → 필터 대상 테이블 연결선 추가 (점선)
|
|
// rightPanelRelation (split-panel-layout의 마스터-디테일) 관계일 때
|
|
// + 필터 대상 테이블의 조인 관계도 함께 표시
|
|
const filterJoinEdgeSet = new Set<string>(); // 필터 테이블의 조인선 중복 방지
|
|
|
|
Object.entries(subTablesData).forEach(([screenIdStr, screenSubData]) => {
|
|
const sourceScreenId = parseInt(screenIdStr);
|
|
|
|
screenSubData.subTables.forEach((subTable) => {
|
|
// rightPanelRelation (필터 관계)이고, 해당 테이블이 존재하는 경우
|
|
// 메인 테이블이든 서브 테이블이든 상관없이 연결선 추가
|
|
if (subTable.relationType === 'rightPanelRelation') {
|
|
// 테이블 노드 ID 결정: 메인 테이블 영역 또는 서브 테이블 영역
|
|
const isFilterTargetMainTable = mainTableSet.has(subTable.tableName);
|
|
const isFilterTargetSubTable = subTableSet.has(subTable.tableName);
|
|
|
|
if (!isFilterTargetMainTable && !isFilterTargetSubTable) return; // 노드가 없으면 스킵
|
|
|
|
const targetNodeId = isFilterTargetMainTable
|
|
? `table-${subTable.tableName}`
|
|
: `subtable-${subTable.tableName}`;
|
|
|
|
// 화면 → 필터 대상 테이블 연결선
|
|
newEdges.push({
|
|
id: `edge-screen-filter-${sourceScreenId}-${subTable.tableName}`,
|
|
source: `screen-${sourceScreenId}`,
|
|
target: targetNodeId,
|
|
sourceHandle: "bottom",
|
|
targetHandle: "top",
|
|
type: "smoothstep",
|
|
animated: true,
|
|
style: {
|
|
stroke: "#3b82f6",
|
|
strokeWidth: 2,
|
|
strokeDasharray: "5,5", // 점선으로 필터 관계 표시
|
|
},
|
|
data: {
|
|
sourceScreenId,
|
|
},
|
|
});
|
|
|
|
// 필터 대상 테이블의 조인 관계 (joinColumnRefs)도 조인선으로 표시
|
|
// 예: customer_item_mapping → item_info (품목 ID가 item_info.item_number 참조)
|
|
if (subTable.joinColumnRefs && subTable.joinColumnRefs.length > 0) {
|
|
subTable.joinColumnRefs.forEach((joinRef) => {
|
|
const refTable = joinRef.refTable;
|
|
if (!refTable) return;
|
|
|
|
// 참조 테이블이 메인 테이블 또는 서브 테이블에 있는지 확인
|
|
const isRefMainTable = mainTableSet.has(refTable);
|
|
const isRefSubTable = subTableSet.has(refTable);
|
|
|
|
if (!isRefMainTable && !isRefSubTable) return;
|
|
|
|
// 중복 체크 (같은 화면에서 같은 조인 관계 중복 방지)
|
|
const joinKey = `${sourceScreenId}-${subTable.tableName}-${refTable}`;
|
|
if (filterJoinEdgeSet.has(joinKey)) return;
|
|
filterJoinEdgeSet.add(joinKey);
|
|
|
|
// 소스/타겟 노드 ID 결정
|
|
const sourceNodeId = isFilterTargetMainTable
|
|
? `table-${subTable.tableName}`
|
|
: `subtable-${subTable.tableName}`;
|
|
const refTargetNodeId = isRefMainTable
|
|
? `table-${refTable}`
|
|
: `subtable-${refTable}`;
|
|
|
|
// 조인선 추가 (초기 스타일 - styledEdges에서 포커싱에 따라 스타일 결정)
|
|
newEdges.push({
|
|
id: `edge-filter-join-${sourceScreenId}-${subTable.tableName}-${refTable}`,
|
|
source: sourceNodeId,
|
|
target: refTargetNodeId,
|
|
sourceHandle: "bottom",
|
|
targetHandle: "bottom_target",
|
|
type: "smoothstep",
|
|
animated: false,
|
|
style: {
|
|
stroke: RELATION_COLORS.join.strokeLight, // 초기값 (연한색)
|
|
strokeWidth: 1.5,
|
|
strokeDasharray: "6,4",
|
|
opacity: 0.3,
|
|
},
|
|
markerEnd: {
|
|
type: MarkerType.ArrowClosed,
|
|
color: RELATION_COLORS.join.strokeLight
|
|
},
|
|
data: {
|
|
sourceScreenId,
|
|
isFilterJoin: true,
|
|
visualRelationType: 'join',
|
|
},
|
|
});
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// 메인 테이블 → 서브 테이블 연결선 생성 (점선)
|
|
// 메인 테이블 → 메인 테이블 연결선도 생성 (점선, 연한 주황색)
|
|
// 화면별 서브 테이블 연결을 추적하기 위해 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) => {
|
|
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);
|
|
|
|
// 방안 C: 필터 관계는 선 없이 뱃지로만 표시 (겹침 방지)
|
|
if (visualRelationType === 'filter') {
|
|
return; // 필터선 생성 건너뛰기
|
|
}
|
|
|
|
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}`;
|
|
const exists = newEdges.some((e) => e.id === edgeId);
|
|
if (exists) return;
|
|
|
|
// 관계 유형 결정 (스타일링용)
|
|
const visualRelationType = inferVisualRelationType(subTable);
|
|
const relationColor = RELATION_COLORS[visualRelationType];
|
|
|
|
// 메인-서브 조인선 (메인-메인과 동일한 스타일, 라벨 없음)
|
|
newEdges.push({
|
|
id: edgeId,
|
|
source: `table-${mainTable}`,
|
|
target: `subtable-${subTable.tableName}`,
|
|
sourceHandle: "bottom",
|
|
targetHandle: "top",
|
|
type: "smoothstep",
|
|
markerEnd: {
|
|
type: MarkerType.ArrowClosed,
|
|
color: relationColor.strokeLight
|
|
},
|
|
animated: false,
|
|
style: {
|
|
stroke: relationColor.strokeLight,
|
|
strokeWidth: 1.5,
|
|
strokeDasharray: "8,4",
|
|
opacity: 0.5,
|
|
},
|
|
data: {
|
|
sourceScreenId,
|
|
visualRelationType,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
// 조인 관계 엣지 (screen_field_joins 기반 - 라벨 없이 통일된 스타일)
|
|
joins.forEach((join: any, idx: number) => {
|
|
if (join.save_table && join.join_table && join.save_table !== join.join_table) {
|
|
newEdges.push({
|
|
id: `edge-join-db-${idx}`,
|
|
source: `table-${join.save_table}`,
|
|
target: `table-${join.join_table}`,
|
|
sourceHandle: "bottom",
|
|
targetHandle: "bottom_target",
|
|
type: "smoothstep",
|
|
markerEnd: {
|
|
type: MarkerType.ArrowClosed,
|
|
color: RELATION_COLORS.join.strokeLight
|
|
},
|
|
animated: false,
|
|
style: {
|
|
stroke: RELATION_COLORS.join.strokeLight,
|
|
strokeWidth: 1.5,
|
|
strokeDasharray: "8,4",
|
|
opacity: 0.5,
|
|
},
|
|
data: { visualRelationType: 'join' },
|
|
});
|
|
}
|
|
});
|
|
|
|
// 테이블 관계 엣지 (추가 관계)
|
|
relations.forEach((rel: any, idx: number) => {
|
|
if (rel.table_name && rel.table_name !== screen.tableName) {
|
|
// 화면 → 연결 테이블
|
|
const edgeExists = newEdges.some(
|
|
(e) => e.source === `screen-${screen.screenId}` && e.target === `table-${rel.table_name}`
|
|
);
|
|
if (!edgeExists) {
|
|
newEdges.push({
|
|
id: `edge-rel-${idx}`,
|
|
source: `screen-${screen.screenId}`,
|
|
target: `table-${rel.table_name}`,
|
|
sourceHandle: "bottom",
|
|
targetHandle: "top",
|
|
type: "smoothstep",
|
|
label: rel.relation_type === "join" ? "조인" : rel.crud_operations || "",
|
|
labelStyle: { fontSize: 9, fill: "#10b981" },
|
|
labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
|
|
labelBgPadding: [3, 2] as [number, number],
|
|
style: { stroke: "#10b981", strokeWidth: 1.5 },
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// 데이터 흐름 엣지 (화면 간)
|
|
flows
|
|
.filter((flow: any) => flow.source_screen_id === screen.screenId)
|
|
.forEach((flow: any, idx: number) => {
|
|
if (flow.target_screen_id) {
|
|
newEdges.push({
|
|
id: `edge-flow-${idx}`,
|
|
source: `screen-${screen.screenId}`,
|
|
target: `screen-${flow.target_screen_id}`,
|
|
sourceHandle: "right",
|
|
targetHandle: "left",
|
|
type: "smoothstep",
|
|
animated: true,
|
|
label: flow.flow_label || flow.flow_type || "이동",
|
|
labelStyle: { fontSize: 10, fill: "#8b5cf6", fontWeight: 500 },
|
|
labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
|
|
labelBgPadding: [4, 2] as [number, number],
|
|
markerEnd: { type: MarkerType.ArrowClosed, color: "#8b5cf6" },
|
|
style: { stroke: "#8b5cf6", strokeWidth: 2 },
|
|
});
|
|
}
|
|
});
|
|
|
|
// 최종 노드 배열 합치기
|
|
const allNodes: AllNodeType[] = [...screenNodes, ...tableNodes];
|
|
|
|
// 테이블이 없으면 안내 노드 추가
|
|
if (tableNodes.length === 0) {
|
|
allNodes.push({
|
|
id: "hint-table",
|
|
type: "tableNode",
|
|
position: { x: 50, y: TABLE_Y },
|
|
data: {
|
|
label: "연결된 테이블 없음",
|
|
subLabel: "화면에 테이블을 설정하세요",
|
|
isMain: false,
|
|
columns: [],
|
|
},
|
|
});
|
|
}
|
|
|
|
setNodes(allNodes);
|
|
setEdges(newEdges);
|
|
|
|
// 데이터 로드 완료 후 버전 증가 (fitView 트리거용)
|
|
setDataLoadVersion((prev) => prev + 1);
|
|
} catch (error) {
|
|
console.error("관계 데이터 로드 실패:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
loadRelations();
|
|
// focusedScreenId는 스타일링에만 영향을 미치므로 의존성에서 제외
|
|
// refreshKey: 설정 저장 후 강제 새로고침용
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [screen, selectedGroup, setNodes, setEdges, loadTableColumns, refreshKey]);
|
|
|
|
// 데이터 로드 완료 시 fitView 호출 (초기 로드 시에만)
|
|
useEffect(() => {
|
|
if (dataLoadVersion > 0 && nodes.length > 0) {
|
|
// setTimeout으로 노드 렌더링 완료 후 fitView 호출
|
|
const timer = setTimeout(() => {
|
|
// duration: 0으로 설정하여 애니메이션 없이 즉시 이동
|
|
reactFlowInstance.fitView({ padding: 0.2, duration: 0 });
|
|
// fitView 완료 후 뷰 표시
|
|
setIsViewReady(true);
|
|
}, 50);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [dataLoadVersion, reactFlowInstance, nodes.length]);
|
|
|
|
// 노드 클릭 핸들러 (그룹 모드에서 화면 포커스) - 조건부 return 전에 선언해야 함
|
|
const handleNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
|
|
// 그룹 모드가 아니면 무시
|
|
if (!selectedGroup) return;
|
|
|
|
// 화면 노드만 처리
|
|
if (node.id.startsWith("screen-")) {
|
|
const screenId = parseInt(node.id.replace("screen-", ""));
|
|
// 이미 포커스된 화면을 다시 클릭하면 포커스 해제
|
|
setFocusedScreenId((prev) => (prev === screenId ? null : screenId));
|
|
}
|
|
}, [selectedGroup]);
|
|
|
|
// 테이블 정보에서 조인/필터 정보 추출 (더블클릭 핸들러용)
|
|
const getTableExistingConfig = useCallback((tableName: string) => {
|
|
// subTablesDataMap에서 서브 테이블 정보 찾기
|
|
for (const screenId in subTablesDataMap) {
|
|
const screenSubTables = subTablesDataMap[parseInt(screenId)];
|
|
if (screenSubTables?.subTables) {
|
|
const subTable = screenSubTables.subTables.find(st => st.tableName === tableName);
|
|
if (subTable) {
|
|
return {
|
|
joinColumnRefs: subTable.joinColumnRefs,
|
|
filterColumns: subTable.filterColumns,
|
|
fieldMappings: subTable.fieldMappings?.map(m => ({
|
|
targetField: m.targetField,
|
|
sourceField: m.sourceField,
|
|
sourceTable: m.sourceTable,
|
|
sourceDisplayName: m.sourceDisplayName,
|
|
})),
|
|
columns: [], // 컬럼 정보는 노드에서 가져옴
|
|
};
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}, [subTablesDataMap]);
|
|
|
|
// 노드 우클릭 핸들러 (설정 모달 열기)
|
|
const handleNodeContextMenu = useCallback((event: React.MouseEvent, node: Node) => {
|
|
// 기본 컨텍스트 메뉴 방지
|
|
event.preventDefault();
|
|
|
|
// 화면 노드 우클릭
|
|
if (node.id.startsWith("screen-")) {
|
|
const screenId = parseInt(node.id.replace("screen-", ""));
|
|
const nodeData = node.data as ScreenNodeData;
|
|
const mainTable = screenTableMap[screenId];
|
|
|
|
// 해당 화면의 서브 테이블 (필터 테이블) 정보
|
|
// 1. screenSubTableMap에서 가져오기
|
|
const screenSubTables = screenSubTableMap[screenId] || [];
|
|
|
|
// 2. edges에서 필터 테이블 찾기 (edge-screen-filter-{screenId}-{tableName})
|
|
const filterTableNamesFromEdges = edges
|
|
.filter(e => e.id.startsWith(`edge-screen-filter-${screenId}-`))
|
|
.map(e => {
|
|
const match = e.id.match(/edge-screen-filter-\d+-(.+)/);
|
|
return match ? match[1] : null;
|
|
})
|
|
.filter((name): name is string => name !== null);
|
|
|
|
// 모든 필터 테이블 합치기 (중복 제거)
|
|
const allFilterTableNames = [...new Set([...screenSubTables, ...filterTableNamesFromEdges])];
|
|
|
|
const filterTables = allFilterTableNames.map(tableName => {
|
|
// subTablesDataMap에서 해당 테이블 정보 찾기
|
|
const subTableData = subTablesDataMap[screenId]?.subTables?.find(
|
|
st => st.tableName === tableName
|
|
);
|
|
|
|
// 또는 nodes에서 테이블 노드 정보 찾기
|
|
const tableNode = nodes.find(n =>
|
|
n.id === `table-${tableName}` || n.id === `subtable-${tableName}`
|
|
);
|
|
const tableNodeData = tableNode?.data as TableNodeData | undefined;
|
|
|
|
return {
|
|
tableName,
|
|
tableLabel: subTableData?.tableLabel || tableNodeData?.label || tableName,
|
|
filterColumns: subTableData?.filterColumns || tableNodeData?.filterColumns || [],
|
|
joinColumnRefs: subTableData?.joinColumnRefs || tableNodeData?.joinColumnRefs || [],
|
|
};
|
|
});
|
|
|
|
setSettingModalNode({
|
|
nodeType: "screen",
|
|
nodeId: node.id,
|
|
screenId: screenId,
|
|
screenName: nodeData.label || `화면 ${screenId}`,
|
|
tableName: mainTable,
|
|
tableLabel: nodeData.subLabel,
|
|
// 화면의 테이블 정보 전달
|
|
existingConfig: {
|
|
mainTable: mainTable,
|
|
filterTables: filterTables,
|
|
},
|
|
});
|
|
setIsSettingModalOpen(true);
|
|
return;
|
|
}
|
|
|
|
// 메인 테이블 노드 더블클릭
|
|
if (node.id.startsWith("table-") && !node.id.startsWith("table-sub-")) {
|
|
const tableName = node.id.replace("table-", "");
|
|
const nodeData = node.data as TableNodeData;
|
|
|
|
// 이 테이블을 사용하는 화면 찾기
|
|
const screenId = Object.entries(screenTableMap).find(
|
|
([_, tbl]) => tbl === tableName
|
|
)?.[0];
|
|
|
|
// 백엔드에서 받은 데이터에서 기존 설정 정보 추출
|
|
const existingConfigFromData = getTableExistingConfig(tableName);
|
|
|
|
setSettingModalNode({
|
|
nodeType: "table",
|
|
nodeId: node.id,
|
|
screenId: screenId ? parseInt(screenId) : 0,
|
|
screenName: nodeData.subLabel || tableName,
|
|
tableName: tableName,
|
|
tableLabel: nodeData.label,
|
|
// 기존 설정 정보 전달
|
|
existingConfig: existingConfigFromData || {
|
|
joinColumnRefs: nodeData.joinColumnRefs,
|
|
filterColumns: nodeData.filterColumns,
|
|
fieldMappings: nodeData.fieldMappings?.map(m => ({
|
|
targetField: m.targetField,
|
|
sourceField: m.sourceField,
|
|
sourceTable: m.sourceTable,
|
|
sourceDisplayName: m.sourceDisplayName,
|
|
})),
|
|
referencedBy: nodeData.referencedBy?.map(r => ({
|
|
fromTable: r.fromTable,
|
|
fromTableLabel: r.fromTableLabel,
|
|
fromColumn: r.fromColumn,
|
|
toColumn: r.toColumn,
|
|
toColumnLabel: r.toColumnLabel,
|
|
relationType: r.relationType,
|
|
})),
|
|
columns: nodeData.columns,
|
|
},
|
|
});
|
|
setIsSettingModalOpen(true);
|
|
return;
|
|
}
|
|
|
|
// 서브 테이블 노드 더블클릭
|
|
if (node.id.startsWith("subtable-")) {
|
|
const tableName = node.id.replace("subtable-", "");
|
|
const nodeData = node.data as TableNodeData;
|
|
|
|
// 이 서브 테이블을 사용하는 화면 찾기
|
|
const screenId = Object.entries(screenSubTableMap).find(
|
|
([_, tables]) => tables.includes(tableName)
|
|
)?.[0];
|
|
|
|
// 백엔드에서 받은 데이터에서 기존 설정 정보 추출
|
|
const existingConfigFromData = getTableExistingConfig(tableName);
|
|
|
|
setSettingModalNode({
|
|
nodeType: "table",
|
|
nodeId: node.id,
|
|
screenId: screenId ? parseInt(screenId) : 0,
|
|
screenName: nodeData.subLabel || tableName,
|
|
tableName: tableName,
|
|
tableLabel: nodeData.label,
|
|
// 기존 설정 정보 전달
|
|
existingConfig: existingConfigFromData || {
|
|
joinColumnRefs: nodeData.joinColumnRefs,
|
|
filterColumns: nodeData.filterColumns,
|
|
fieldMappings: nodeData.fieldMappings?.map(m => ({
|
|
targetField: m.targetField,
|
|
sourceField: m.sourceField,
|
|
sourceTable: m.sourceTable,
|
|
sourceDisplayName: m.sourceDisplayName,
|
|
})),
|
|
referencedBy: nodeData.referencedBy?.map(r => ({
|
|
fromTable: r.fromTable,
|
|
fromTableLabel: r.fromTableLabel,
|
|
fromColumn: r.fromColumn,
|
|
toColumn: r.toColumn,
|
|
toColumnLabel: r.toColumnLabel,
|
|
relationType: r.relationType,
|
|
})),
|
|
columns: nodeData.columns,
|
|
},
|
|
});
|
|
setIsSettingModalOpen(true);
|
|
return;
|
|
}
|
|
}, [screenTableMap, screenSubTableMap, subTablesDataMap, edges, nodes, getTableExistingConfig]);
|
|
|
|
// 설정 모달 닫기 및 새로고침
|
|
const handleSettingModalClose = useCallback(() => {
|
|
setIsSettingModalOpen(false);
|
|
setSettingModalNode(null);
|
|
}, []);
|
|
|
|
// 시각화 새로고침 (설정 저장 후 호출)
|
|
const handleRefreshVisualization = useCallback(() => {
|
|
// 강제 새로고침: refreshKey 증가로 useEffect 재실행
|
|
setRefreshKey(prev => prev + 1);
|
|
}, []);
|
|
|
|
// 포커스에 따른 노드 스타일링 (그룹 모드에서 화면 클릭 시)
|
|
const styledNodes = React.useMemo(() => {
|
|
// 그룹 모드가 아니면 원본 반환
|
|
if (!selectedGroup) return nodes;
|
|
|
|
// 포커스된 화면의 서브 테이블 목록 (포커스가 없으면 빈 배열)
|
|
const focusedSubTables = focusedScreenId !== null
|
|
? (screenSubTableMap[focusedScreenId] || [])
|
|
: [];
|
|
|
|
// 포커스된 화면의 서브 테이블 데이터 (조인 컬럼 정보 포함)
|
|
const focusedSubTablesData = focusedScreenId !== null
|
|
? subTablesDataMap[focusedScreenId]
|
|
: null;
|
|
|
|
// 포커스된 화면의 사용 컬럼 정보
|
|
const focusedUsedColumns = focusedScreenId !== null
|
|
? screenUsedColumnsMap[focusedScreenId]
|
|
: null;
|
|
|
|
// 연관 테이블 정보 추출 (parentDataMapping, rightPanelRelation 등으로 연결된 테이블)
|
|
// { tableName: { columns: [sourceField1, sourceField2], displayNames: [displayName1, displayName2] } }
|
|
const relatedTablesMap: Record<string, { columns: string[], displayNames: string[] }> = {};
|
|
if (focusedSubTablesData) {
|
|
focusedSubTablesData.subTables.forEach((subTable) => {
|
|
// parentDataMapping, rightPanelRelation 타입의 연결에서 sourceTable 추출
|
|
if ((subTable.relationType === 'parentMapping' || subTable.relationType === 'rightPanelRelation')
|
|
&& subTable.fieldMappings) {
|
|
subTable.fieldMappings.forEach((mapping) => {
|
|
// sourceTable이 명시되어 있으면 그 테이블에 sourceField 추가
|
|
// sourceTable이 없으면 subTable.tableName을 sourceTable로 간주하지 않음
|
|
// mapping에서 sourceTable 정보가 필요함 - 현재는 tableName에서 추론
|
|
// parentDataMapping의 경우: tableName이 targetTable이고, sourceTable은 별도 정보 필요
|
|
|
|
// 임시: subTables의 tableName과 다른 메인 테이블들을 확인
|
|
// fieldMappings에서 sourceField가 다른 테이블에 있다면 그 테이블이 연관 테이블
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// 모든 화면의 메인 테이블과 그에 연결된 조인 정보 매핑
|
|
// 포커스된 화면이 다른 메인 테이블을 참조하는 경우 해당 테이블도 강조
|
|
const relatedMainTables: Record<string, { columns: string[], displayNames: string[] }> = {};
|
|
|
|
// 모든 화면의 메인 테이블 Set (빠른 조회용)
|
|
const allMainTableSet = new Set(Object.values(screenTableMap));
|
|
|
|
if (focusedSubTablesData) {
|
|
// 포커스된 화면의 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) => {
|
|
// 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);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 3. 필터 대상 테이블의 joinColumnRefs가 있으면 해당 참조 테이블도 활성화
|
|
// 예: customer_item_mapping → item_info (품목 ID → item_info.item_number)
|
|
if (subTable.relationType === 'rightPanelRelation' && subTable.joinColumnRefs) {
|
|
subTable.joinColumnRefs.forEach((joinRef) => {
|
|
const refTable = joinRef.refTable;
|
|
if (refTable && allMainTableSet.has(refTable) && refTable !== focusedSubTablesData.mainTable) {
|
|
if (!relatedMainTables[refTable]) {
|
|
relatedMainTables[refTable] = { columns: [], displayNames: [] };
|
|
}
|
|
// 참조 테이블의 컬럼도 추가 (조인 관계 표시용)
|
|
if (joinRef.refColumn && !relatedMainTables[refTable].columns.includes(joinRef.refColumn)) {
|
|
relatedMainTables[refTable].columns.push(joinRef.refColumn);
|
|
relatedMainTables[refTable].displayNames.push(joinRef.columnLabel || joinRef.refColumn);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
return nodes.map((node) => {
|
|
// 화면 노드 스타일링 (포커스가 있을 때만)
|
|
if (node.id.startsWith("screen-")) {
|
|
if (focusedScreenId === null) {
|
|
// 포커스 없음: 모든 화면 정상 표시
|
|
return {
|
|
...node,
|
|
data: {
|
|
...node.data,
|
|
isFocused: false,
|
|
isFaded: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
const screenId = parseInt(node.id.replace("screen-", ""));
|
|
const isFocused = screenId === focusedScreenId;
|
|
const isFaded = !isFocused;
|
|
|
|
return {
|
|
...node,
|
|
data: {
|
|
...node.data,
|
|
isFocused,
|
|
isFaded,
|
|
},
|
|
};
|
|
}
|
|
|
|
// 메인 테이블 노드 스타일링
|
|
if (node.id.startsWith("table-")) {
|
|
const tableName = node.id.replace("table-", "");
|
|
|
|
// 포커스된 화면의 메인 테이블인지 확인
|
|
const isFocusedTable = focusedScreenId !== null && screenTableMap[focusedScreenId] === tableName;
|
|
|
|
// 연관 테이블인지 확인 (다른 화면의 메인 테이블이지만 포커스된 화면에서 참조하는 테이블)
|
|
const isRelatedTable = relatedMainTables[tableName] !== undefined;
|
|
const relatedTableInfo = relatedMainTables[tableName];
|
|
|
|
// 조인 컬럼 추출
|
|
// 1. columns_config에서 isEntityJoin=true인 컬럼 (__join 키)
|
|
// 2. 서브 테이블 연결 시 fieldMappings에서 메인테이블 컬럼 추출
|
|
let joinColumns: string[] = [...(focusedUsedColumns?.[`${tableName}__join`] || [])];
|
|
|
|
// 서브 테이블 연결 정보에서도 추가 (포커스된 화면의 메인 테이블인 경우)
|
|
if (focusedSubTablesData && focusedSubTablesData.mainTable === tableName) {
|
|
focusedSubTablesData.subTables.forEach((subTable) => {
|
|
if (subTable.fieldMappings) {
|
|
subTable.fieldMappings.forEach((mapping) => {
|
|
const hasSourceTable = 'sourceTable' in mapping && mapping.sourceTable;
|
|
|
|
if (hasSourceTable) {
|
|
if (mapping.targetField && !joinColumns.includes(mapping.targetField)) {
|
|
joinColumns.push(mapping.targetField);
|
|
}
|
|
} else if (subTable.relationType === 'reference' || subTable.relationType === 'source') {
|
|
if (mapping.sourceField && !joinColumns.includes(mapping.sourceField)) {
|
|
joinColumns.push(mapping.sourceField);
|
|
}
|
|
} else if (subTable.relationType === 'parentMapping' || subTable.relationType === 'rightPanelRelation') {
|
|
if (mapping.targetField && !joinColumns.includes(mapping.targetField)) {
|
|
joinColumns.push(mapping.targetField);
|
|
}
|
|
} else {
|
|
if (mapping.targetField && !joinColumns.includes(mapping.targetField)) {
|
|
joinColumns.push(mapping.targetField);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// 연관 테이블인 경우: 참조되는 컬럼을 조인 컬럼으로 추가
|
|
if (isRelatedTable && relatedTableInfo) {
|
|
relatedTableInfo.columns.forEach((col) => {
|
|
if (!joinColumns.includes(col)) {
|
|
joinColumns.push(col);
|
|
}
|
|
});
|
|
}
|
|
|
|
// 사용 컬럼 추출 (조인 컬럼 제외)
|
|
const allUsedColumns = focusedUsedColumns?.[tableName] || [];
|
|
const highlightedColumns = allUsedColumns.filter(col => !joinColumns.includes(col));
|
|
|
|
// 테이블 활성화 여부: 포커스된 화면의 메인 테이블 OR 연관 테이블
|
|
const isActiveTable = isFocusedTable || isRelatedTable;
|
|
|
|
// 메인테이블용 fieldMappings 생성 (조인 컬럼 옆에 연관 테이블 컬럼명 표시)
|
|
// 예: 거래처 ID ← 거래처 코드 (메인테이블 컬럼 ← 연관테이블 컬럼)
|
|
let mainTableFieldMappings: Array<{ sourceField: string; targetField: string; sourceDisplayName?: string; targetDisplayName?: string }> = [];
|
|
if (isFocusedTable && focusedSubTablesData) {
|
|
focusedSubTablesData.subTables.forEach((subTable) => {
|
|
if (subTable.fieldMappings) {
|
|
subTable.fieldMappings.forEach((mapping) => {
|
|
// 메인테이블에서는:
|
|
// - sourceField/targetField 중 메인테이블 컬럼이 targetField (표시할 컬럼)
|
|
// - 연관테이블 컬럼이 sourceField (← 뒤에 표시할 참조 컬럼)
|
|
if (subTable.relationType === 'source' && mapping.sourceTable) {
|
|
// parentDataMapping 스타일: targetField = 메인테이블, sourceField = 연관테이블
|
|
mainTableFieldMappings.push({
|
|
sourceField: mapping.sourceField, // 연관 테이블 컬럼
|
|
targetField: mapping.targetField, // 메인 테이블 컬럼
|
|
sourceDisplayName: mapping.sourceDisplayName || mapping.sourceField,
|
|
targetDisplayName: mapping.targetDisplayName || mapping.targetField,
|
|
});
|
|
} else if (subTable.relationType === 'parentMapping' || subTable.relationType === 'rightPanelRelation') {
|
|
mainTableFieldMappings.push({
|
|
sourceField: mapping.sourceField,
|
|
targetField: mapping.targetField,
|
|
sourceDisplayName: mapping.sourceDisplayName || mapping.sourceField,
|
|
targetDisplayName: mapping.targetDisplayName || mapping.targetField,
|
|
});
|
|
} else if (subTable.relationType === 'reference' || subTable.relationType === 'source') {
|
|
// reference/source: sourceField = 메인테이블, targetField = 서브테이블
|
|
// 메인테이블 표시: sourceField ← targetDisplayName
|
|
mainTableFieldMappings.push({
|
|
sourceField: mapping.targetField, // 연관 테이블 컬럼 (표시용)
|
|
targetField: mapping.sourceField, // 메인 테이블 컬럼
|
|
sourceDisplayName: mapping.targetDisplayName || mapping.targetField,
|
|
targetDisplayName: mapping.sourceDisplayName || mapping.sourceField,
|
|
});
|
|
} else {
|
|
// lookup: targetField = 메인테이블, sourceField = 서브테이블
|
|
mainTableFieldMappings.push({
|
|
sourceField: mapping.sourceField,
|
|
targetField: mapping.targetField,
|
|
sourceDisplayName: mapping.sourceDisplayName || mapping.sourceField,
|
|
targetDisplayName: mapping.targetDisplayName || mapping.targetField,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// 연관 테이블용 fieldMappings 생성
|
|
let relatedTableFieldMappings: Array<{ sourceField: string; targetField: string; sourceDisplayName?: string; targetDisplayName?: string }> = [];
|
|
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) {
|
|
// 이 테이블이 sourceTable인 경우: sourceField가 이 테이블의 컬럼
|
|
relatedTableFieldMappings.push({
|
|
sourceField: mapping.targetField, // 메인 테이블 컬럼 (참조 표시용)
|
|
targetField: mapping.sourceField, // 연관 테이블 컬럼 (표시할 컬럼)
|
|
sourceDisplayName: mapping.targetDisplayName || mapping.targetField,
|
|
targetDisplayName: mapping.sourceDisplayName || mapping.sourceField,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// 중복 제거
|
|
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[] = [];
|
|
|
|
// 조인 컬럼 참조 정보 수집
|
|
let focusedJoinColumnRefs: Array<{ column: string; refTable: string; refColumn: string }> = [];
|
|
|
|
// 포커싱된 화면 기준 저장 정보
|
|
let focusedSaveInfos: Array<{ saveType: string; componentType: string; isMainTable: boolean; sourceScreenId?: number }> = [];
|
|
|
|
if (focusedScreenId !== null && focusedSubTablesData) {
|
|
// 포커스된 화면에서 이 테이블이 rightPanelRelation의 서브테이블인 경우
|
|
focusedSubTablesData.subTables.forEach((subTable) => {
|
|
if (subTable.tableName === tableName && subTable.relationType === 'rightPanelRelation') {
|
|
// 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',
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// 포커싱된 화면 기준 저장 정보 추출
|
|
if (focusedSubTablesData.saveTables) {
|
|
focusedSubTablesData.saveTables.forEach((st) => {
|
|
if (st.tableName === tableName) {
|
|
focusedSaveInfos.push({
|
|
saveType: st.saveType,
|
|
componentType: st.componentType,
|
|
isMainTable: st.isMainTable,
|
|
sourceScreenId: focusedSubTablesData.screenId,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
...node,
|
|
data: {
|
|
...node.data,
|
|
isFocused: isFocusedTable,
|
|
isRelated: isRelatedTable,
|
|
isFaded: focusedScreenId !== null && !isActiveTable,
|
|
highlightedColumns: isActiveTable ? highlightedColumns : [],
|
|
joinColumns: isActiveTable ? joinColumns : [],
|
|
joinColumnRefs: focusedJoinColumnRefs.length > 0 ? focusedJoinColumnRefs : undefined, // 조인 컬럼 참조 정보
|
|
filterColumns: focusedFilterColumns, // 포커스 상태에서만 표시
|
|
referencedBy: focusedReferencedBy.length > 0 ? focusedReferencedBy : undefined, // 포커스 상태에서만 표시
|
|
saveInfos: focusedSaveInfos.length > 0 ? focusedSaveInfos : undefined, // 포커스 상태에서만 표시
|
|
fieldMappings: isFocusedTable ? mainTableFieldMappings : (isRelatedTable ? relatedTableFieldMappings : []),
|
|
},
|
|
};
|
|
}
|
|
|
|
// 서브 테이블 노드 스타일링
|
|
// 기본: 흐리게, 포커스된 화면의 서브 테이블만 활성화
|
|
if (node.id.startsWith("subtable-")) {
|
|
const subTableName = node.id.replace("subtable-", "");
|
|
const isActiveSubTable = focusedSubTables.includes(subTableName);
|
|
|
|
// 조인 컬럼 추출 (서브 테이블 측의 컬럼)
|
|
// relationType에 따라 다름:
|
|
// - reference: targetField가 서브테이블 컬럼 (예: manager_id -> user_id)
|
|
// - lookup 등: sourceField가 서브테이블 컬럼
|
|
let subTableJoinColumns: string[] = [];
|
|
if (isActiveSubTable && focusedSubTablesData) {
|
|
const subTableInfo = focusedSubTablesData.subTables.find(st => st.tableName === subTableName);
|
|
if (subTableInfo?.fieldMappings) {
|
|
subTableInfo.fieldMappings.forEach((mapping) => {
|
|
// reference, source, parentMapping, rightPanelRelation 타입: targetField가 서브테이블의 컬럼 (조인 키)
|
|
// lookup 타입: sourceField가 서브테이블의 컬럼
|
|
if (subTableInfo.relationType === 'reference' || subTableInfo.relationType === 'source' ||
|
|
subTableInfo.relationType === 'parentMapping' || subTableInfo.relationType === 'rightPanelRelation') {
|
|
// reference, source, parentMapping, rightPanelRelation: 메인테이블 컬럼(sourceField) -> 서브테이블 컬럼(targetField)
|
|
if (mapping.targetField && !subTableJoinColumns.includes(mapping.targetField)) {
|
|
subTableJoinColumns.push(mapping.targetField);
|
|
}
|
|
} else {
|
|
// lookup 등: sourceField가 서브테이블 컬럼
|
|
if (mapping.sourceField && !subTableJoinColumns.includes(mapping.sourceField)) {
|
|
subTableJoinColumns.push(mapping.sourceField);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// 디버깅 로그
|
|
console.log(`서브테이블 ${subTableName} (${subTableInfo?.relationType}):`, {
|
|
fieldMappings: subTableInfo?.fieldMappings,
|
|
extractedJoinColumns: subTableJoinColumns
|
|
});
|
|
}
|
|
|
|
// 서브 테이블의 highlightedColumns도 추가 (화면에서 서브테이블 컬럼을 직접 사용하는 경우)
|
|
// joinColumns와 별개로 표시 (조인 키 외 사용 컬럼)
|
|
const subTableHighlightedColumns: string[] = [];
|
|
|
|
// 필드 매핑 정보 추출 (조인 관계 표시용)
|
|
// reference, source, parentMapping, rightPanelRelation 타입: sourceField = 메인테이블 컬럼, targetField = 서브테이블 컬럼
|
|
// lookup 타입: sourceField = 서브테이블 컬럼, targetField = 메인테이블 컬럼 (swap 필요)
|
|
let displayFieldMappings: Array<{ sourceField: string; targetField: string; sourceDisplayName?: string; targetDisplayName?: string }> = [];
|
|
|
|
// 포커싱된 화면 기준 저장 정보 (서브 테이블)
|
|
let subTableSaveInfos: Array<{ saveType: string; componentType: string; isMainTable: boolean; sourceScreenId?: number }> = [];
|
|
|
|
if (isActiveSubTable && focusedSubTablesData) {
|
|
const subTableInfo = focusedSubTablesData.subTables.find(st => st.tableName === subTableName);
|
|
if (subTableInfo?.fieldMappings) {
|
|
displayFieldMappings = subTableInfo.fieldMappings.map((mapping) => {
|
|
if (subTableInfo.relationType === 'reference' || subTableInfo.relationType === 'source' ||
|
|
subTableInfo.relationType === 'parentMapping' || subTableInfo.relationType === 'rightPanelRelation') {
|
|
// reference, source, parentMapping, rightPanelRelation: 백엔드에서 sourceField = 메인테이블, targetField = 서브테이블
|
|
// 표시: 서브테이블 컬럼(targetField) ← 메인테이블 한글명(sourceDisplayName)
|
|
return {
|
|
sourceField: mapping.sourceField, // 메인 테이블 컬럼 (참조)
|
|
targetField: mapping.targetField, // 서브 테이블 컬럼 (표시)
|
|
sourceDisplayName: mapping.sourceDisplayName || mapping.sourceField, // 메인 테이블 한글명
|
|
targetDisplayName: mapping.targetDisplayName || mapping.targetField,
|
|
};
|
|
} else {
|
|
// lookup 등: 백엔드 fieldMappings가 reference/source와 반대
|
|
// 백엔드: sourceField = 서브테이블 컬럼, targetField = 메인테이블 컬럼
|
|
// 프론트엔드 표시: 서브테이블 컬럼(sourceField) ← 메인테이블 컬럼(targetField)
|
|
// 그래서 swap 필요!
|
|
return {
|
|
sourceField: mapping.targetField, // 메인 테이블 컬럼 (참조)
|
|
targetField: mapping.sourceField, // 서브 테이블 컬럼 (표시)
|
|
sourceDisplayName: mapping.targetDisplayName || mapping.targetField, // 메인 테이블 한글명
|
|
targetDisplayName: mapping.sourceDisplayName || mapping.sourceField, // 서브 테이블 한글명
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
// 서브 테이블에 대한 저장 정보 추출
|
|
if (focusedSubTablesData.saveTables) {
|
|
focusedSubTablesData.saveTables.forEach((st) => {
|
|
if (st.tableName === subTableName) {
|
|
subTableSaveInfos.push({
|
|
saveType: st.saveType,
|
|
componentType: st.componentType,
|
|
isMainTable: st.isMainTable,
|
|
sourceScreenId: focusedSubTablesData.screenId,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
...node,
|
|
style: {
|
|
...node.style,
|
|
opacity: isActiveSubTable ? 1 : 0.3,
|
|
filter: isActiveSubTable ? "none" : "grayscale(80%)",
|
|
},
|
|
data: {
|
|
...node.data,
|
|
isFocused: isActiveSubTable,
|
|
isFaded: !isActiveSubTable,
|
|
highlightedColumns: isActiveSubTable ? subTableHighlightedColumns : [],
|
|
joinColumns: isActiveSubTable ? subTableJoinColumns : [],
|
|
fieldMappings: isActiveSubTable ? displayFieldMappings : [],
|
|
saveInfos: subTableSaveInfos.length > 0 ? subTableSaveInfos : undefined, // 포커스 상태에서만 표시
|
|
},
|
|
};
|
|
}
|
|
|
|
return node;
|
|
});
|
|
}, [nodes, selectedGroup, focusedScreenId, screenSubTableMap, subTablesDataMap, screenUsedColumnsMap, screenTableMap, tableColumns]);
|
|
|
|
// 포커스에 따른 엣지 스타일링 (그룹 모드 & 개별 화면 모드)
|
|
const styledEdges = React.useMemo(() => {
|
|
// 개별 화면 모드: 메인 화면의 연결선만 강조
|
|
if (!selectedGroup && screen) {
|
|
const mainScreenId = screen.screenId;
|
|
|
|
return edges.map((edge) => {
|
|
// 화면 간 연결선
|
|
if (edge.source.startsWith("screen-") && edge.target.startsWith("screen-")) {
|
|
const sourceId = parseInt(edge.source.replace("screen-", ""));
|
|
const targetId = parseInt(edge.target.replace("screen-", ""));
|
|
const isConnected = sourceId === mainScreenId || targetId === mainScreenId;
|
|
|
|
return {
|
|
...edge,
|
|
animated: isConnected,
|
|
style: {
|
|
...edge.style,
|
|
stroke: isConnected ? "#8b5cf6" : "#d1d5db",
|
|
strokeWidth: isConnected ? 2 : 1,
|
|
opacity: isConnected ? 1 : 0.3,
|
|
},
|
|
};
|
|
}
|
|
|
|
// 화면-테이블 연결선
|
|
if (edge.source.startsWith("screen-") && edge.target.startsWith("table-")) {
|
|
const sourceId = parseInt(edge.source.replace("screen-", ""));
|
|
const isMyConnection = sourceId === mainScreenId;
|
|
|
|
return {
|
|
...edge,
|
|
animated: isMyConnection,
|
|
style: {
|
|
...edge.style,
|
|
stroke: isMyConnection ? "#3b82f6" : "#d1d5db",
|
|
strokeWidth: isMyConnection ? 2 : 1,
|
|
strokeDasharray: isMyConnection ? undefined : "5,5",
|
|
opacity: isMyConnection ? 1 : 0.3,
|
|
},
|
|
};
|
|
}
|
|
|
|
return edge;
|
|
});
|
|
}
|
|
|
|
// 그룹 모드가 아니면 원본 반환
|
|
if (!selectedGroup) return edges;
|
|
|
|
// 연관 테이블 간 조인 엣지 생성 (메인 테이블 간 조인 관계)
|
|
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) => {
|
|
// 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);
|
|
|
|
// 메인 테이블 간 조인 연결선 - 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이 메인 테이블인 경우만 메인-메인 조인선 추가
|
|
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);
|
|
|
|
// 방안 C: 필터 관계는 선 없이 뱃지로만 표시 (겹침 방지)
|
|
if (visualRelationType === 'filter') {
|
|
return; // 필터선 생성 건너뛰기
|
|
}
|
|
|
|
const relationColor = RELATION_COLORS[visualRelationType];
|
|
|
|
joinEdges.push({
|
|
id: edgeId,
|
|
source: sourceNodeId,
|
|
target: targetNodeId,
|
|
sourceHandle: 'bottom', // 고정: 서브테이블 구간 통과
|
|
targetHandle: 'bottom_target', // 고정: 서브테이블 구간 통과
|
|
type: 'smoothstep',
|
|
animated: true,
|
|
style: {
|
|
stroke: relationColor.stroke, // 관계 유형별 색상
|
|
strokeWidth: 2,
|
|
strokeDasharray: '8,4',
|
|
},
|
|
markerEnd: {
|
|
type: MarkerType.ArrowClosed,
|
|
color: relationColor.stroke,
|
|
width: 15,
|
|
height: 15,
|
|
},
|
|
data: {
|
|
visualRelationType,
|
|
},
|
|
});
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// 기존 엣지 스타일링 + 조인 엣지 추가
|
|
const styledOriginalEdges = edges.map((edge) => {
|
|
// 화면 간 연결선 (1, 2, 3 라벨)
|
|
if (edge.source.startsWith("screen-") && edge.target.startsWith("screen-")) {
|
|
// 포커스가 없으면 모든 화면 간 연결선 정상 표시
|
|
if (focusedScreenId === null) {
|
|
return edge; // 원본 그대로
|
|
}
|
|
|
|
// 포커스된 화면과 연결된 화면 간 선만 활성화
|
|
const sourceId = parseInt(edge.source.replace("screen-", ""));
|
|
const targetId = parseInt(edge.target.replace("screen-", ""));
|
|
const isConnected = sourceId === focusedScreenId || targetId === focusedScreenId;
|
|
|
|
return {
|
|
...edge,
|
|
animated: isConnected,
|
|
style: {
|
|
...edge.style,
|
|
stroke: isConnected ? "#8b5cf6" : "#d1d5db",
|
|
strokeWidth: isConnected ? 2 : 1,
|
|
opacity: isConnected ? 1 : 0.3,
|
|
},
|
|
};
|
|
}
|
|
|
|
// 화면-테이블 연결선
|
|
if (edge.source.startsWith("screen-") && edge.target.startsWith("table-")) {
|
|
const sourceId = parseInt(edge.source.replace("screen-", ""));
|
|
const isMyConnection = sourceId === focusedScreenId;
|
|
|
|
// 필터 연결선 (edge-screen-filter-)은 포커싱 시에만 표시
|
|
const isFilterEdge = edge.id.startsWith("edge-screen-filter-");
|
|
|
|
if (isFilterEdge) {
|
|
// 포커스가 없거나 다른 화면 포커스 시 숨김
|
|
if (focusedScreenId === null || !isMyConnection) {
|
|
return {
|
|
...edge,
|
|
animated: false,
|
|
style: {
|
|
...edge.style,
|
|
stroke: "transparent",
|
|
strokeWidth: 0,
|
|
opacity: 0,
|
|
},
|
|
};
|
|
}
|
|
|
|
// 포커싱된 화면의 필터 연결선은 표시
|
|
return {
|
|
...edge,
|
|
animated: true,
|
|
style: {
|
|
...edge.style,
|
|
stroke: "#3b82f6",
|
|
strokeWidth: 2,
|
|
strokeDasharray: "5,5",
|
|
opacity: 1,
|
|
},
|
|
};
|
|
}
|
|
|
|
// 메인 테이블 연결선 (edge-screen-table-)은 기존 로직
|
|
// 포커스가 없으면 모든 화면-테이블 연결선 정상 표시
|
|
if (focusedScreenId === null) {
|
|
return edge; // 원본 그대로
|
|
}
|
|
|
|
return {
|
|
...edge,
|
|
animated: isMyConnection,
|
|
style: {
|
|
...edge.style,
|
|
stroke: isMyConnection ? "#3b82f6" : "#d1d5db",
|
|
strokeWidth: isMyConnection ? 2 : 1,
|
|
strokeDasharray: isMyConnection ? undefined : "5,5",
|
|
opacity: isMyConnection ? 1 : 0.3,
|
|
},
|
|
};
|
|
}
|
|
|
|
// 메인 테이블 → 서브 테이블 연결선 (메인-메인과 동일한 스타일)
|
|
// 규격: bottom → top 고정 (아래로 문어발처럼 뻗어나감)
|
|
if (edge.source.startsWith("table-") && edge.target.startsWith("subtable-")) {
|
|
// 관계 유형별 색상 결정
|
|
const visualRelationType = (edge.data as any)?.visualRelationType as VisualRelationType || 'join';
|
|
const relationColor = RELATION_COLORS[visualRelationType];
|
|
|
|
// 포커스가 없으면 모든 서브 테이블 연결선 흐리게 (기본 상태)
|
|
if (focusedScreenId === null) {
|
|
return {
|
|
...edge,
|
|
sourceHandle: "bottom",
|
|
targetHandle: "top",
|
|
animated: false,
|
|
style: {
|
|
...edge.style,
|
|
stroke: relationColor.strokeLight,
|
|
strokeWidth: 1.5,
|
|
strokeDasharray: "8,4",
|
|
opacity: 0.4,
|
|
},
|
|
markerEnd: {
|
|
type: MarkerType.ArrowClosed,
|
|
color: relationColor.strokeLight,
|
|
},
|
|
};
|
|
}
|
|
|
|
// 엣지 ID에서 화면 ID 추출: edge-main-sub-{screenId}-{mainTable}-{subTable}
|
|
const idParts = edge.id.split("-");
|
|
const edgeScreenId = idParts.length >= 4 ? parseInt(idParts[3]) : null;
|
|
|
|
// 포커스된 화면의 서브 테이블 연결인지 확인
|
|
const isMySubTable = edgeScreenId === focusedScreenId;
|
|
|
|
// 대체 방법: screenSubTableMap 사용
|
|
const focusedSubTables = focusedScreenId ? screenSubTableMap[focusedScreenId] || [] : [];
|
|
const subTableName = edge.target.replace("subtable-", "");
|
|
const isMySubTableByMap = focusedSubTables.includes(subTableName);
|
|
|
|
const isActive = isMySubTable || isMySubTableByMap;
|
|
|
|
return {
|
|
...edge,
|
|
sourceHandle: "bottom",
|
|
targetHandle: "top",
|
|
animated: isActive,
|
|
style: {
|
|
...edge.style,
|
|
stroke: isActive ? relationColor.stroke : relationColor.strokeLight,
|
|
strokeWidth: isActive ? 2.5 : 1.5,
|
|
strokeDasharray: "8,4",
|
|
opacity: isActive ? 1 : 0.3,
|
|
},
|
|
markerEnd: {
|
|
type: MarkerType.ArrowClosed,
|
|
color: isActive ? relationColor.stroke : relationColor.strokeLight,
|
|
},
|
|
};
|
|
}
|
|
|
|
// 필터 조인 엣지 (필터 대상 테이블 → 조인 참조 테이블)
|
|
// 규격: 해당 화면이 포커싱됐을 때만 활성화
|
|
if (edge.id.startsWith("edge-filter-join-")) {
|
|
const edgeSourceScreenId = (edge.data as any)?.sourceScreenId;
|
|
|
|
// 포커스가 없으면 흐리게 표시
|
|
if (focusedScreenId === null) {
|
|
return {
|
|
...edge,
|
|
animated: false,
|
|
style: {
|
|
...edge.style,
|
|
stroke: RELATION_COLORS.join.strokeLight,
|
|
strokeWidth: 1.5,
|
|
strokeDasharray: "6,4",
|
|
opacity: 0.3,
|
|
},
|
|
markerEnd: {
|
|
type: MarkerType.ArrowClosed,
|
|
color: RELATION_COLORS.join.strokeLight,
|
|
},
|
|
};
|
|
}
|
|
|
|
// 포커스된 화면과 일치하는지 확인
|
|
const isMyConnection = edgeSourceScreenId === focusedScreenId;
|
|
|
|
if (!isMyConnection) {
|
|
// 다른 화면의 필터 조인 엣지는 숨김
|
|
return {
|
|
...edge,
|
|
hidden: true,
|
|
};
|
|
}
|
|
|
|
// 내 화면의 필터 조인 엣지는 활성화
|
|
return {
|
|
...edge,
|
|
animated: true,
|
|
style: {
|
|
...edge.style,
|
|
stroke: RELATION_COLORS.join.stroke,
|
|
strokeWidth: 2,
|
|
strokeDasharray: "6,4",
|
|
opacity: 1,
|
|
},
|
|
markerEnd: {
|
|
type: MarkerType.ArrowClosed,
|
|
color: RELATION_COLORS.join.stroke,
|
|
},
|
|
};
|
|
}
|
|
|
|
// 메인 테이블 → 메인 테이블 연결선 (서브테이블 구간 통과)
|
|
// 규격: bottom → bottom_target 고정 (아래쪽 서브테이블 선 구간을 통해 연결)
|
|
// edge-main-main-*, edge-join-db-* 모두 동일한 스타일 적용
|
|
const isMainToMainJoin = edge.source.startsWith("table-") &&
|
|
edge.target.startsWith("table-") &&
|
|
(edge.id.startsWith("edge-main-main-") || edge.id.startsWith("edge-join-db-"));
|
|
if (isMainToMainJoin) {
|
|
// 관계 유형별 색상 결정
|
|
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, nodes, selectedGroup, focusedScreenId, screen, screenSubTableMap, subTablesDataMap, screenTableMap]);
|
|
|
|
// 그룹의 화면 목록 (데이터 흐름 설정용) - 모든 조건부 return 전에 선언해야 함
|
|
const groupScreensList = React.useMemo(() => {
|
|
if (!selectedGroup) return [];
|
|
// nodes에서 screen- 으로 시작하는 노드들 추출
|
|
return nodes
|
|
.filter(n => n.id.startsWith("screen-"))
|
|
.map(n => ({
|
|
screen_id: parseInt(n.id.replace("screen-", "")),
|
|
screen_name: (n.data as ScreenNodeData).label || `화면 ${n.id}`,
|
|
}));
|
|
}, [selectedGroup, nodes]);
|
|
|
|
// 조건부 렌더링 (모든 훅 선언 후에 위치해야 함)
|
|
if (!screen && !selectedGroup) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
|
<div className="text-center">
|
|
<p className="text-sm">그룹 또는 화면을 선택하면</p>
|
|
<p className="text-sm">데이터 관계가 시각화됩니다</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<div className="text-sm text-muted-foreground">로딩 중...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full w-full">
|
|
{/* isViewReady가 false면 숨김 처리하여 깜빡임 방지 */}
|
|
<div className={`h-full w-full transition-opacity duration-0 ${isViewReady ? "opacity-100" : "opacity-0"}`}>
|
|
<ReactFlow
|
|
nodes={styledNodes}
|
|
edges={styledEdges}
|
|
onNodesChange={onNodesChange}
|
|
onEdgesChange={onEdgesChange}
|
|
onNodeClick={handleNodeClick}
|
|
onNodeContextMenu={handleNodeContextMenu}
|
|
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>
|
|
</div>
|
|
|
|
{/* 화면 노드 설정 모달 */}
|
|
{settingModalNode && settingModalNode.nodeType === "screen" && (
|
|
<ScreenSettingModal
|
|
isOpen={isSettingModalOpen}
|
|
onClose={handleSettingModalClose}
|
|
screenId={settingModalNode.screenId}
|
|
screenName={settingModalNode.screenName}
|
|
groupId={selectedGroup?.id}
|
|
mainTable={settingModalNode.existingConfig?.mainTable}
|
|
mainTableLabel={settingModalNode.tableLabel}
|
|
filterTables={settingModalNode.existingConfig?.filterTables}
|
|
fieldMappings={settingModalNode.existingConfig?.fieldMappings}
|
|
componentCount={0}
|
|
onSaveSuccess={handleRefreshVisualization}
|
|
/>
|
|
)}
|
|
|
|
{/* 테이블 노드 설정 모달 */}
|
|
{settingModalNode && settingModalNode.nodeType === "table" && (
|
|
<TableSettingModal
|
|
isOpen={isSettingModalOpen}
|
|
onClose={handleSettingModalClose}
|
|
tableName={settingModalNode.tableName || ""}
|
|
tableLabel={settingModalNode.tableLabel}
|
|
screenId={settingModalNode.screenId}
|
|
joinColumnRefs={settingModalNode.existingConfig?.joinColumnRefs}
|
|
referencedBy={settingModalNode.existingConfig?.referencedBy}
|
|
columns={settingModalNode.existingConfig?.columns}
|
|
filterColumns={settingModalNode.existingConfig?.filterColumns}
|
|
onSaveSuccess={handleRefreshVisualization}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 외부 래퍼 컴포넌트 (ReactFlowProvider 포함)
|
|
export function ScreenRelationFlow(props: ScreenRelationFlowProps) {
|
|
return (
|
|
<ReactFlowProvider>
|
|
<ScreenRelationFlowInner {...props} />
|
|
</ReactFlowProvider>
|
|
);
|
|
}
|