774 lines
30 KiB
TypeScript
774 lines
30 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import {
|
|
ReactFlow,
|
|
Controls,
|
|
Background,
|
|
BackgroundVariant,
|
|
Node,
|
|
Edge,
|
|
useNodesState,
|
|
useEdgesState,
|
|
MarkerType,
|
|
useReactFlow,
|
|
ReactFlowProvider,
|
|
} from "@xyflow/react";
|
|
import "@xyflow/react/dist/style.css";
|
|
|
|
import { ScreenDefinition } from "@/types/screen";
|
|
import { ScreenNode, TableNode, ScreenNodeData, TableNodeData } 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 = 520; // 메인 테이블 노드 Y 위치 (중단)
|
|
const SUB_TABLE_Y = 780; // 서브 테이블 노드 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[]>>({});
|
|
|
|
|
|
// 그룹 내 포커스된 화면 ID (그룹 모드에서만 사용)
|
|
const [focusedScreenId, setFocusedScreenId] = useState<number | null>(null);
|
|
|
|
// 외부에서 전달된 초기 포커스 ID 적용 (화면 이동 없이 강조만)
|
|
useEffect(() => {
|
|
if (initialFocusedScreenId !== undefined && initialFocusedScreenId !== null) {
|
|
setFocusedScreenId(initialFocusedScreenId);
|
|
}
|
|
}, [initialFocusedScreenId]);
|
|
|
|
// 화면 ID와 테이블명 매핑 (포커스 시 연결선 강조용)
|
|
const [screenTableMap, setScreenTableMap] = useState<Record<number, 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]
|
|
);
|
|
|
|
// 그룹 변경 시 focusedScreenId 초기화
|
|
useEffect(() => {
|
|
setFocusedScreenId(null);
|
|
}, [selectedGroup?.id, screen?.screenId]);
|
|
|
|
// 데이터 로드 및 노드/엣지 생성
|
|
useEffect(() => {
|
|
// 그룹도 없고 화면도 없으면 빈 상태
|
|
if (!screen && !selectedGroup) {
|
|
setNodes([]);
|
|
setEdges([]);
|
|
return;
|
|
}
|
|
|
|
const loadRelations = async () => {
|
|
setLoading(true);
|
|
|
|
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>;
|
|
}
|
|
} catch (e) {
|
|
console.error("레이아웃 요약/서브 테이블 로드 실패:", e);
|
|
}
|
|
|
|
// ========== 상단: 화면 노드들 ==========
|
|
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에서 추출된 테이블들)
|
|
// 서브 테이블은 메인 테이블과 다른 테이블들
|
|
Object.values(subTablesData).forEach((screenSubData) => {
|
|
screenSubData.subTables.forEach((subTable) => {
|
|
// 메인 테이블에 없는 것만 서브 테이블로 추가
|
|
if (!mainTableSet.has(subTable.tableName)) {
|
|
subTableSet.add(subTable.tableName);
|
|
}
|
|
});
|
|
});
|
|
|
|
// 메인 테이블 노드 배치 (화면들의 중앙 아래에 배치)
|
|
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 primaryTableName = screen?.tableName || (screenList.length > 0 ? screenList[0].tableName : null);
|
|
|
|
for (let idx = 0; idx < mainTableList.length; idx++) {
|
|
const tableName = mainTableList[idx];
|
|
const isPrimaryTable = tableName === primaryTableName;
|
|
|
|
// 컬럼 정보 로드
|
|
let columns: ColumnTypeInfo[] = [];
|
|
try {
|
|
columns = await loadTableColumns(tableName);
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
|
|
// 컬럼 정보를 PK/FK 표시와 함께 변환
|
|
const formattedColumns = columns.slice(0, 8).map((col) => ({
|
|
name: col.displayName || col.columnName || "",
|
|
type: col.dataType || "",
|
|
isPrimaryKey: col.isPrimaryKey || col.columnName === "id",
|
|
isForeignKey: !!col.referenceTable || (col.columnName?.includes("_id") && col.columnName !== "id"),
|
|
}));
|
|
|
|
tableNodes.push({
|
|
id: `table-${tableName}`,
|
|
type: "tableNode",
|
|
position: { x: mainTableStartX + idx * (NODE_WIDTH + NODE_GAP), y: TABLE_Y },
|
|
data: {
|
|
label: tableName,
|
|
subLabel: isPrimaryTable ? "메인 테이블" : "조인 테이블",
|
|
isMain: isPrimaryTable,
|
|
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 표시와 함께 변환
|
|
const formattedColumns = columns.slice(0, 5).map((col) => ({
|
|
name: col.displayName || 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,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
// ========== 엣지: 연결선 생성 ==========
|
|
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, idx) => {
|
|
if (scr.tableName && mainTableSet.has(scr.tableName)) {
|
|
const isMain = screen ? scr.screenId === screen.screenId : idx === 0;
|
|
newEdges.push({
|
|
id: `edge-screen-table-${scr.screenId}`,
|
|
source: `screen-${scr.screenId}`,
|
|
target: `table-${scr.tableName}`,
|
|
sourceHandle: "bottom",
|
|
targetHandle: "top",
|
|
type: "smoothstep",
|
|
animated: isMain, // 메인 화면만 애니메이션
|
|
style: {
|
|
stroke: isMain ? "#3b82f6" : "#94a3b8",
|
|
strokeWidth: isMain ? 2 : 1.5,
|
|
strokeDasharray: isMain ? undefined : "5,5", // 보조 연결은 점선
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
// 메인 테이블 → 서브 테이블 연결선 생성 (점선)
|
|
Object.values(subTablesData).forEach((screenSubData) => {
|
|
const mainTable = screenSubData.mainTable;
|
|
if (!mainTable || !mainTableSet.has(mainTable)) return;
|
|
|
|
screenSubData.subTables.forEach((subTable) => {
|
|
// 서브 테이블 노드가 실제로 생성되었는지 확인
|
|
if (!subTableSet.has(subTable.tableName)) return;
|
|
|
|
// 중복 엣지 방지
|
|
const edgeId = `edge-main-sub-${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: "#f97316", fontWeight: 500 },
|
|
labelBgStyle: { fill: "white", stroke: "#e2e8f0", strokeWidth: 1 },
|
|
labelBgPadding: [3, 2] as [number, number],
|
|
markerEnd: { type: MarkerType.ArrowClosed, color: "#f97316" },
|
|
style: {
|
|
stroke: "#f97316",
|
|
strokeWidth: 1.5,
|
|
strokeDasharray: "6,4", // 점선
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
// 조인 관계 엣지 (테이블 간 - 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);
|
|
} catch (error) {
|
|
console.error("관계 데이터 로드 실패:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
loadRelations();
|
|
// focusedScreenId는 스타일링에만 영향을 미치므로 의존성에서 제외
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [screen, selectedGroup, setNodes, setEdges, loadTableColumns]);
|
|
|
|
// 노드 클릭 핸들러 (그룹 모드에서 화면 포커스) - 조건부 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 || focusedScreenId === null) return nodes;
|
|
|
|
return nodes.map((node) => {
|
|
if (node.id.startsWith("screen-")) {
|
|
const screenId = parseInt(node.id.replace("screen-", ""));
|
|
const isFocused = screenId === focusedScreenId;
|
|
const isFaded = !isFocused;
|
|
|
|
return {
|
|
...node,
|
|
data: {
|
|
...node.data,
|
|
isFocused,
|
|
isFaded,
|
|
},
|
|
};
|
|
}
|
|
return node;
|
|
});
|
|
}, [nodes, selectedGroup, focusedScreenId]);
|
|
|
|
// 포커스에 따른 엣지 스타일링 (그룹 모드 & 개별 화면 모드)
|
|
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 || focusedScreenId === null) return edges;
|
|
|
|
return edges.map((edge) => {
|
|
// 화면 간 연결선 (1, 2, 3 라벨)
|
|
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 === 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;
|
|
|
|
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;
|
|
});
|
|
}, [edges, selectedGroup, focusedScreenId, screen]);
|
|
|
|
// 조건부 렌더링 (모든 훅 선언 후에 위치해야 함)
|
|
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">
|
|
<ReactFlow
|
|
nodes={styledNodes}
|
|
edges={styledEdges}
|
|
onNodesChange={onNodesChange}
|
|
onEdgesChange={onEdgesChange}
|
|
onNodeClick={handleNodeClick}
|
|
nodeTypes={nodeTypes}
|
|
fitView
|
|
fitViewOptions={{ padding: 0.2 }}
|
|
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>
|
|
);
|
|
}
|
|
|
|
// 외부 래퍼 컴포넌트 (ReactFlowProvider 포함)
|
|
export function ScreenRelationFlow(props: ScreenRelationFlowProps) {
|
|
return (
|
|
<ReactFlowProvider>
|
|
<ScreenRelationFlowInner {...props} />
|
|
</ReactFlowProvider>
|
|
);
|
|
}
|