ERP-node/frontend/components/screen/ScreenRelationFlow.tsx

1408 lines
62 KiB
TypeScript
Raw Normal View History

"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 } from "./ScreenNode";
import {
getFieldJoins,
getDataFlows,
getTableRelations,
getMultipleScreenLayoutSummary,
getScreenGroup,
getScreenSubTables,
ScreenLayoutSummary,
ScreenSubTablesData,
} from "@/lib/api/screenGroup";
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
// 노드 타입 등록
const nodeTypes = {
screenNode: ScreenNode,
tableNode: TableNode,
};
// 레이아웃 상수
const SCREEN_Y = 50; // 화면 노드 Y 위치 (상단)
const TABLE_Y = 420; // 메인 테이블 노드 Y 위치 (중단) - 위로 이동
const SUB_TABLE_Y = 680; // 서브 테이블 노드 Y 위치 (하단) - 위로 이동
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);
// 그룹 또는 화면이 변경될 때 포커스 초기화
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 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,
},
});
}
// ========== 하단: 서브 테이블 노드들 (참조/조회용) ==========
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, // 기본적으로 흐리게 표시 (포커스 시에만 활성화)
},
});
}
}
// ========== 엣지: 연결선 생성 ==========
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,
},
});
}
});
// 메인 테이블 → 서브 테이블 연결선 생성 (점선 + 애니메이션)
// 화면별 서브 테이블 연결을 추적하기 위해 screenId 정보도 엣지 ID에 포함
Object.entries(subTablesData).forEach(([screenIdStr, screenSubData]) => {
const sourceScreenId = parseInt(screenIdStr);
const mainTable = screenSubData.mainTable;
if (!mainTable || !mainTableSet.has(mainTable)) return;
screenSubData.subTables.forEach((subTable) => {
// 서브 테이블 노드가 실제로 생성되었는지 확인
if (!subTableSet.has(subTable.tableName)) return;
// 화면별로 고유한 엣지 ID (같은 서브 테이블이라도 다른 화면에서 사용하면 별도 엣지)
const edgeId = `edge-main-sub-${sourceScreenId}-${mainTable}-${subTable.tableName}`;
const exists = newEdges.some((e) => e.id === edgeId);
if (exists) return;
// 관계 타입에 따른 라벨
let relationLabel = "참조";
if (subTable.relationType === "lookup") relationLabel = "조회";
else if (subTable.relationType === "source") relationLabel = "데이터 소스";
else if (subTable.relationType === "join") relationLabel = "조인";
newEdges.push({
id: edgeId,
source: `table-${mainTable}`,
target: `subtable-${subTable.tableName}`,
sourceHandle: "bottom",
targetHandle: "top",
type: "smoothstep",
label: relationLabel,
labelStyle: { fontSize: 9, fill: "#94a3b8", fontWeight: 500 }, // 기본 흐린 색상
labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
labelBgPadding: [3, 2] as [number, number],
markerEnd: { type: MarkerType.ArrowClosed, color: "#94a3b8" }, // 기본 흐린 색상
animated: false, // 기본: 애니메이션 비활성화 (포커스 시에만 활성화)
style: {
stroke: "#94a3b8", // 기본 흐린 색상
strokeWidth: 1,
strokeDasharray: "6,4", // 점선
opacity: 0.5, // 기본 투명도
},
// 화면 ID 정보를 data에 저장 (styledEdges에서 활용)
data: { sourceScreenId },
});
});
});
// 조인 관계 엣지 (테이블 간 - 1:N 관계 표시)
joins.forEach((join: any, idx: number) => {
if (join.save_table && join.join_table && join.save_table !== join.join_table) {
newEdges.push({
id: `edge-join-${idx}`,
source: `table-${join.save_table}`,
target: `table-${join.join_table}`,
sourceHandle: "right",
targetHandle: "left",
type: "smoothstep",
label: "1:N 관계",
labelStyle: { fontSize: 10, fill: "#6366f1", fontWeight: 500 },
labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
labelBgPadding: [4, 2] as [number, number],
markerEnd: { type: MarkerType.ArrowClosed, color: "#6366f1" },
style: { stroke: "#6366f1", strokeWidth: 1.5, strokeDasharray: "5,5" },
});
}
});
// 테이블 관계 엣지 (추가 관계)
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는 스타일링에만 영향을 미치므로 의존성에서 제외
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [screen, selectedGroup, setNodes, setEdges, loadTableColumns]);
// 데이터 로드 완료 시 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 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[] }> = {};
if (focusedSubTablesData) {
// screenTableMap에서 다른 화면들의 메인 테이블 확인
Object.entries(screenTableMap).forEach(([screenIdStr, mainTableName]) => {
const screenId = parseInt(screenIdStr);
if (screenId === focusedScreenId) return; // 자신의 메인 테이블은 제외
// 포커스된 화면의 subTables 중 이 메인 테이블을 참조하는지 확인
focusedSubTablesData.subTables.forEach((subTable) => {
if (subTable.fieldMappings) {
subTable.fieldMappings.forEach((mapping: any) => {
// mapping에 sourceTable 정보가 있는 경우 (parentDataMapping에서 설정)
if (mapping.sourceTable && mapping.sourceTable === mainTableName) {
if (!relatedMainTables[mainTableName]) {
relatedMainTables[mainTableName] = { columns: [], displayNames: [] };
}
if (mapping.sourceField && !relatedMainTables[mainTableName].columns.includes(mapping.sourceField)) {
relatedMainTables[mainTableName].columns.push(mapping.sourceField);
relatedMainTables[mainTableName].displayNames.push(mapping.sourceDisplayName || mapping.sourceField);
}
}
});
}
});
});
}
console.log('[DEBUG] relatedMainTables:', relatedMainTables);
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`] || [])];
// 서브 테이블 연결 정보에서도 추가 (포커스된 화면의 메인 테이블인 경우)
// relationType에 따라 다름:
// - reference, source: sourceField가 메인테이블 컬럼 (예: manager_id -> user_id, material -> material)
// - parentMapping, rightPanelRelation: targetField가 메인테이블 컬럼
// - lookup 등: targetField가 메인테이블 컬럼
console.log('[DEBUG] joinColumns before subTable processing:', {
tableName,
focusedMainTable: focusedSubTablesData?.mainTable,
subTables: focusedSubTablesData?.subTables?.map((st: any) => ({
tableName: st.tableName,
relationType: st.relationType,
fieldMappings: st.fieldMappings
}))
});
if (focusedSubTablesData && focusedSubTablesData.mainTable === tableName) {
// 디버그: subTables 처리 전 로그
if (tableName === 'customer_item_mapping') {
console.log('[DEBUG] Processing subTables for mainTable:', {
mainTable: tableName,
subTablesCount: focusedSubTablesData.subTables.length,
subTables: focusedSubTablesData.subTables.map(st => ({
tableName: st.tableName,
relationType: st.relationType,
fieldMappingsCount: st.fieldMappings?.length || 0,
fieldMappings: st.fieldMappings?.map(fm => ({
sourceField: fm.sourceField,
targetField: fm.targetField,
})),
})),
});
}
focusedSubTablesData.subTables.forEach((subTable, stIdx) => {
// 각 서브테이블 디버그 로그
if (tableName === 'customer_item_mapping') {
console.log(`[DEBUG] SubTable ${stIdx}:`, {
tableName: subTable.tableName,
relationType: subTable.relationType,
hasFieldMappings: !!subTable.fieldMappings,
fieldMappingsCount: subTable.fieldMappings?.length || 0,
});
}
if (subTable.fieldMappings) {
subTable.fieldMappings.forEach((mapping, mIdx) => {
// 각 매핑 디버그 로그
if (tableName === 'customer_item_mapping') {
console.log(`[DEBUG] SubTable ${stIdx} Mapping ${mIdx}:`, {
sourceField: mapping.sourceField,
targetField: mapping.targetField,
relationType: subTable.relationType,
});
}
// sourceTable이 있으면 parentDataMapping/rightPanelRelation에서 추가된 것이므로
// relationType과 관계없이 targetField가 메인테이블 컬럼
const hasSourceTable = 'sourceTable' in mapping && mapping.sourceTable;
if (hasSourceTable) {
// parentDataMapping/rightPanelRelation: targetField가 메인테이블 컬럼
if (tableName === 'customer_item_mapping') {
console.log('[DEBUG] Adding targetField to joinColumns (has sourceTable):', {
subTableName: subTable.tableName,
relationType: subTable.relationType,
sourceTable: mapping.sourceTable,
targetField: mapping.targetField,
alreadyIncludes: joinColumns.includes(mapping.targetField),
});
}
if (mapping.targetField && !joinColumns.includes(mapping.targetField)) {
joinColumns.push(mapping.targetField);
}
} else if (subTable.relationType === 'reference' || subTable.relationType === 'source') {
// reference, source (sourceTable 없는 경우): sourceField가 메인테이블 컬럼
if (mapping.sourceField && !joinColumns.includes(mapping.sourceField)) {
joinColumns.push(mapping.sourceField);
}
} else if (subTable.relationType === 'parentMapping' || subTable.relationType === 'rightPanelRelation') {
// parentMapping, rightPanelRelation: targetField가 메인테이블 컬럼
if (tableName === 'customer_item_mapping') {
console.log('[DEBUG] Adding targetField to joinColumns (parentMapping):', {
subTableName: subTable.tableName,
relationType: subTable.relationType,
targetField: mapping.targetField,
alreadyIncludes: joinColumns.includes(mapping.targetField),
});
}
if (mapping.targetField && !joinColumns.includes(mapping.targetField)) {
joinColumns.push(mapping.targetField);
}
} else {
// lookup 등: targetField가 메인테이블 컬럼
if (tableName === 'customer_item_mapping') {
console.log('[DEBUG] Adding targetField to joinColumns (else branch):', {
subTableName: subTable.tableName,
relationType: subTable.relationType,
targetField: mapping.targetField,
alreadyIncludes: joinColumns.includes(mapping.targetField),
});
}
if (mapping.targetField && !joinColumns.includes(mapping.targetField)) {
joinColumns.push(mapping.targetField);
}
}
});
}
});
}
// 연관 테이블인 경우: 참조되는 컬럼을 조인 컬럼으로 추가
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 && relatedTableInfo && focusedSubTablesData) {
focusedSubTablesData.subTables.forEach((subTable) => {
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,
});
}
});
}
});
}
return {
...node,
data: {
...node.data,
isFocused: isFocusedTable,
isRelated: isRelatedTable,
isFaded: focusedScreenId !== null && !isActiveTable,
highlightedColumns: isActiveTable ? highlightedColumns : [],
joinColumns: isActiveTable ? joinColumns : [],
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 }> = [];
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, // 서브 테이블 한글명
};
}
});
}
}
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 : [],
},
};
}
return node;
});
}, [nodes, selectedGroup, focusedScreenId, screenSubTableMap, subTablesDataMap, screenUsedColumnsMap, screenTableMap]);
// 포커스에 따른 엣지 스타일링 (그룹 모드 & 개별 화면 모드)
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;
// 연관 테이블 간 조인 엣지 생성 (parentDataMapping, rightPanelRelation)
const joinEdges: Edge[] = [];
if (focusedScreenId !== null) {
const focusedSubTablesData = subTablesDataMap[focusedScreenId];
const focusedMainTable = screenTableMap[focusedScreenId];
if (focusedSubTablesData) {
focusedSubTablesData.subTables.forEach((subTable) => {
// fieldMappings에 sourceTable이 있는 경우 처리 (parentMapping, rightPanelRelation 등)
if (subTable.fieldMappings) {
subTable.fieldMappings.forEach((mapping: any, idx: number) => {
const sourceTable = mapping.sourceTable;
if (!sourceTable) return;
// 연관 테이블 → 포커싱된 화면의 메인 테이블로 연결
// sourceTable(연관) → focusedMainTable(메인)
const edgeId = `edge-join-relation-${focusedScreenId}-${sourceTable}-${focusedMainTable}-${idx}`;
// 이미 존재하는 엣지인지 확인
if (joinEdges.some(e => e.id === edgeId)) return;
// 라벨 제거 - 조인 정보는 테이블 노드 내부에서 컬럼 옆에 표시
joinEdges.push({
id: edgeId,
source: `table-${sourceTable}`,
target: `table-${focusedMainTable}`,
type: 'smoothstep',
animated: true,
style: {
stroke: '#ea580c',
strokeWidth: 2,
strokeDasharray: '8,4',
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#ea580c',
width: 15,
height: 15,
},
});
});
}
});
}
}
// 기존 엣지 스타일링 + 조인 엣지 추가
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-")) {
// 포커스가 없으면 모든 화면-테이블 연결선 정상 표시
if (focusedScreenId === null) {
return edge; // 원본 그대로
}
const sourceId = parseInt(edge.source.replace("screen-", ""));
const isMyConnection = sourceId === focusedScreenId;
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,
},
};
}
// 메인 테이블 → 서브 테이블 연결선
// 기본: 흐리게 처리, 포커스된 화면의 서브 테이블만 강조
if (edge.source.startsWith("table-") && edge.target.startsWith("subtable-")) {
// 포커스가 없으면 모든 서브 테이블 연결선 흐리게 (기본 상태)
if (focusedScreenId === null) {
return {
...edge,
animated: false,
style: {
...edge.style,
stroke: "#d1d5db",
strokeWidth: 1,
strokeDasharray: "6,4",
opacity: 0.3,
},
labelStyle: {
...edge.labelStyle,
opacity: 0.3,
},
};
}
// 엣지 ID에서 화면 ID 추출: edge-main-sub-{screenId}-{mainTable}-{subTable}
const idParts = edge.id.split("-");
// edge-main-sub-1413-sales_order_mng-customer_mng 형식
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,
animated: isActive, // 활성화된 것만 애니메이션
style: {
...edge.style,
stroke: isActive ? "#f97316" : "#d1d5db",
strokeWidth: isActive ? 2 : 1,
strokeDasharray: "6,4", // 항상 점선
opacity: isActive ? 1 : 0.2,
},
labelStyle: {
...edge.labelStyle,
opacity: isActive ? 1 : 0.3,
},
};
}
return edge;
});
// 기존 엣지 + 조인 관계 엣지 합치기
return [...styledOriginalEdges, ...joinEdges];
}, [edges, selectedGroup, focusedScreenId, screen, screenSubTableMap, subTablesDataMap, screenTableMap]);
// 조건부 렌더링 (모든 훅 선언 후에 위치해야 함)
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}
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>
</div>
);
}
// 외부 래퍼 컴포넌트 (ReactFlowProvider 포함)
export function ScreenRelationFlow(props: ScreenRelationFlowProps) {
return (
<ReactFlowProvider>
<ScreenRelationFlowInner {...props} />
</ReactFlowProvider>
);
}