"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 확장) 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>({}); // 그룹 내 포커스된 화면 ID (그룹 모드에서만 사용) const [focusedScreenId, setFocusedScreenId] = useState(null); // 외부에서 전달된 초기 포커스 ID 적용 (화면 이동 없이 강조만) useEffect(() => { if (initialFocusedScreenId !== undefined && initialFocusedScreenId !== null) { setFocusedScreenId(initialFocusedScreenId); } }, [initialFocusedScreenId]); // 화면 ID와 테이블명 매핑 (포커스 시 연결선 강조용) const [screenTableMap, setScreenTableMap] = 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] ); // 그룹 변경 시 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 = {}; 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; } } 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 = { 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에서 추출된 테이블들) // 서브 테이블은 메인 테이블과 다른 테이블들 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 (

그룹 또는 화면을 선택하면

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

); } if (loading) { return (
로딩 중...
); } return (
); } // 외부 래퍼 컴포넌트 (ReactFlowProvider 포함) export function ScreenRelationFlow(props: ScreenRelationFlowProps) { return ( ); }