"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 = { 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; company_code?: string } | null; initialFocusedScreenId?: number | null; } // 노드 타입 (Record 확장) type ScreenNodeType = Node>; type TableNodeType = Node>; type AllNodeType = ScreenNodeType | TableNodeType; // 내부 컴포넌트 (useReactFlow 사용 가능) function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId }: ScreenRelationFlowProps) { const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [loading, setLoading] = useState(false); const [tableColumns, setTableColumns] = useState>({}); // ReactFlow 인스턴스 (fitView 제어용) const reactFlowInstance = useReactFlow(); // 데이터 로드 버전 (초기 로드 시에만 fitView 호출) const [dataLoadVersion, setDataLoadVersion] = useState(0); // 뷰 준비 상태 (fitView 완료 후 true로 설정하여 깜빡임 방지) const [isViewReady, setIsViewReady] = useState(false); // 그룹 내 포커스된 화면 ID (그룹 모드에서만 사용) const [focusedScreenId, setFocusedScreenId] = useState(null); // 노드 설정 모달 상태 const [isSettingModalOpen, setIsSettingModalOpen] = useState(false); const [settingModalNode, setSettingModalNode] = useState<{ nodeType: "screen" | "table"; nodeId: string; screenId: number; screenName: string; tableName?: string; tableLabel?: string; companyCode?: 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>({}); // 화면 ID별 서브 테이블 매핑 (포커스 시 서브 테이블 연결선 강조용) const [screenSubTableMap, setScreenSubTableMap] = useState>({}); // 서브 테이블 데이터 저장 (조인 컬럼 정보 포함) const [subTablesDataMap, setSubTablesDataMap] = useState>({}); // 화면별 사용 컬럼 매핑 (화면 ID -> 테이블명 -> 사용 컬럼들) const [screenUsedColumnsMap, setScreenUsedColumnsMap] = useState>>({}); // 테이블 컬럼 정보 로드 const loadTableColumns = useCallback( async (tableName: string): Promise => { 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 = {}; 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 = {}; let subTablesData: Record = {}; 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; } if (subTablesRes.success && subTablesRes.data) { subTablesData = subTablesRes.data as Record; // 서브 테이블 데이터 저장 (조인 컬럼 정보 포함) setSubTablesDataMap(subTablesData); } } catch (e) { console.error("레이아웃 요약/서브 테이블 로드 실패:", e); } // 화면별 사용 컬럼 정보 추출 (layoutSummaries에서) const usedColumnsMap: Record> = {}; 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 = { 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(); const subTableSet = new Set(); // 모든 화면의 메인 테이블 추가 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 = {}; 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(); 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(); 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(); // 필터 테이블의 조인선 중복 방지 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(); // 중복 방지용 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; // 필터 키 매핑 정보 추출 (leftColumn → foreignKey) let filterKeyMapping: { mainTableColumn: string; mainTableColumnLabel?: string; filterTableColumn: string; filterTableColumnLabel?: string; } | undefined = undefined; if (subTableData?.leftColumn && subTableData?.foreignKey) { // 메인 테이블 컬럼 한글명 조회 const mainTable = subTablesDataMap[screenId]?.mainTable; const mainTableCols = mainTable ? tableColumns[mainTable] : []; const mainColInfo = mainTableCols?.find(c => c.columnName === subTableData.leftColumn); // 필터 테이블 컬럼 한글명 조회 const filterTableCols = tableColumns[tableName] || []; const filterColInfo = filterTableCols?.find(c => c.columnName === subTableData.foreignKey); filterKeyMapping = { mainTableColumn: subTableData.leftColumn, mainTableColumnLabel: mainColInfo?.displayName, filterTableColumn: subTableData.foreignKey, filterTableColumnLabel: filterColInfo?.displayName, }; } return { tableName, tableLabel: subTableData?.tableLabel || tableNodeData?.label || tableName, filterColumns: subTableData?.filterColumns || tableNodeData?.filterColumns || [], filterKeyMapping, joinColumnRefs: subTableData?.joinColumnRefs || tableNodeData?.joinColumnRefs || [], }; }); // 화면의 모든 서브 테이블에서 fieldMappings 추출 const screenSubTablesData = subTablesDataMap[screenId]; const allFieldMappings: Array<{ targetField: string; sourceField: string; sourceTable?: string; sourceDisplayName?: string; componentType?: string; }> = []; if (screenSubTablesData?.subTables) { screenSubTablesData.subTables.forEach((subTable) => { if (subTable.fieldMappings) { subTable.fieldMappings.forEach((mapping) => { allFieldMappings.push({ targetField: mapping.targetField, sourceField: mapping.sourceField, sourceTable: mapping.sourceTable || subTable.tableName, sourceDisplayName: mapping.sourceDisplayName, componentType: subTable.relationType, }); }); } }); } setSettingModalNode({ nodeType: "screen", nodeId: node.id, screenId: screenId, screenName: nodeData.label || `화면 ${screenId}`, tableName: mainTable, tableLabel: nodeData.subLabel, companyCode: selectedGroup?.company_code, // 프리뷰용 회사 코드 // 화면의 테이블 정보 전달 existingConfig: { mainTable: mainTable, filterTables: filterTables, fieldMappings: allFieldMappings, }, }); 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 = {}; 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 = {}; // 모든 화면의 메인 테이블 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(); 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(); 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 (

그룹 또는 화면을 선택하면

데이터 관계가 시각화됩니다

); } if (loading) { return (
로딩 중...
); } return (
{/* isViewReady가 false면 숨김 처리하여 깜빡임 방지 */}
{/* 화면 노드 설정 모달 */} {settingModalNode && settingModalNode.nodeType === "screen" && ( )} {/* 테이블 노드 설정 모달 */} {settingModalNode && settingModalNode.nodeType === "table" && ( )}
); } // 외부 래퍼 컴포넌트 (ReactFlowProvider 포함) export function ScreenRelationFlow(props: ScreenRelationFlowProps) { return ( ); }