2025-09-05 11:30:27 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2025-09-05 16:19:31 +09:00
|
|
|
import React, { useState, useCallback, useEffect, useRef } from "react";
|
|
|
|
|
import toast from "react-hot-toast";
|
2025-09-05 11:30:27 +09:00
|
|
|
import {
|
|
|
|
|
ReactFlow,
|
|
|
|
|
Node,
|
|
|
|
|
Edge,
|
|
|
|
|
Controls,
|
|
|
|
|
Background,
|
|
|
|
|
MiniMap,
|
|
|
|
|
useNodesState,
|
|
|
|
|
useEdgesState,
|
|
|
|
|
addEdge,
|
|
|
|
|
Connection,
|
|
|
|
|
} from "@xyflow/react";
|
|
|
|
|
import "@xyflow/react/dist/style.css";
|
|
|
|
|
import { ScreenNode } from "./ScreenNode";
|
|
|
|
|
import { CustomEdge } from "./CustomEdge";
|
2025-09-05 16:19:31 +09:00
|
|
|
import { ScreenSelector } from "./ScreenSelector";
|
|
|
|
|
import { ConnectionSetupModal } from "./ConnectionSetupModal";
|
|
|
|
|
import { DataFlowAPI, ScreenDefinition, ColumnInfo, ScreenWithFields } from "@/lib/api/dataflow";
|
2025-09-05 11:30:27 +09:00
|
|
|
|
|
|
|
|
// 노드 및 엣지 타입 정의
|
|
|
|
|
const nodeTypes = {
|
|
|
|
|
screenNode: ScreenNode,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const edgeTypes = {
|
|
|
|
|
customEdge: CustomEdge,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface DataFlowDesignerProps {
|
|
|
|
|
companyCode: string;
|
|
|
|
|
onSave?: (relationships: any[]) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode, onSave }) => {
|
|
|
|
|
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
|
|
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
2025-09-05 16:19:31 +09:00
|
|
|
const [selectedFields, setSelectedFields] = useState<{
|
|
|
|
|
[screenId: string]: string[];
|
|
|
|
|
}>({});
|
|
|
|
|
const [selectionOrder, setSelectionOrder] = useState<string[]>([]);
|
|
|
|
|
const [loadingScreens, setLoadingScreens] = useState<Set<number>>(new Set());
|
|
|
|
|
const [pendingConnection, setPendingConnection] = useState<{
|
|
|
|
|
fromNode: { id: string; screenName: string; tableName: string };
|
|
|
|
|
toNode: { id: string; screenName: string; tableName: string };
|
|
|
|
|
fromField?: string;
|
|
|
|
|
toField?: string;
|
|
|
|
|
selectedFieldsData?: {
|
|
|
|
|
[screenId: string]: {
|
|
|
|
|
screenName: string;
|
|
|
|
|
fields: string[];
|
|
|
|
|
};
|
|
|
|
|
};
|
2025-09-05 11:30:27 +09:00
|
|
|
} | null>(null);
|
2025-09-05 16:19:31 +09:00
|
|
|
const [isOverNodeScrollArea, setIsOverNodeScrollArea] = useState(false);
|
|
|
|
|
const toastShownRef = useRef(false);
|
2025-09-05 11:30:27 +09:00
|
|
|
|
2025-09-05 16:19:31 +09:00
|
|
|
// 빈 onConnect 함수 (드래그 연결 비활성화)
|
|
|
|
|
const onConnect = useCallback(() => {
|
|
|
|
|
// 드래그로 연결하는 것을 방지
|
|
|
|
|
return;
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 필드 클릭 처리 (토글 방식, 최대 2개 화면만 허용)
|
|
|
|
|
const handleFieldClick = useCallback((screenId: string, fieldName: string) => {
|
|
|
|
|
setSelectedFields((prev) => {
|
|
|
|
|
const currentFields = prev[screenId] || [];
|
|
|
|
|
const isSelected = currentFields.includes(fieldName);
|
|
|
|
|
const selectedScreens = Object.keys(prev).filter((id) => prev[id] && prev[id].length > 0);
|
|
|
|
|
|
|
|
|
|
if (isSelected) {
|
|
|
|
|
// 선택 해제
|
|
|
|
|
const newFields = currentFields.filter((field) => field !== fieldName);
|
|
|
|
|
if (newFields.length === 0) {
|
|
|
|
|
const { [screenId]: _, ...rest } = prev;
|
|
|
|
|
// 선택 순서에서도 제거 (다음 렌더링에서)
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
setSelectionOrder((order) => order.filter((id) => id !== screenId));
|
|
|
|
|
}, 0);
|
|
|
|
|
return rest;
|
|
|
|
|
}
|
|
|
|
|
return { ...prev, [screenId]: newFields };
|
|
|
|
|
} else {
|
|
|
|
|
// 선택 추가 - 새로운 화면이고 이미 2개 화면이 선택되어 있으면 거부
|
|
|
|
|
if (!prev[screenId] && selectedScreens.length >= 2) {
|
|
|
|
|
// 토스트 중복 방지를 위한 ref 사용
|
|
|
|
|
if (!toastShownRef.current) {
|
|
|
|
|
toastShownRef.current = true;
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
toast.error("최대 2개의 화면에서만 필드를 선택할 수 있습니다.", {
|
|
|
|
|
duration: 3000,
|
|
|
|
|
position: "top-center",
|
|
|
|
|
});
|
|
|
|
|
// 3초 후 플래그 리셋
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
toastShownRef.current = false;
|
|
|
|
|
}, 3000);
|
|
|
|
|
}, 0);
|
|
|
|
|
}
|
|
|
|
|
return prev;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 새로운 화면이면 선택 순서에 추가, 기존 화면이면 맨 뒤로 이동 (다음 렌더링에서)
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
setSelectionOrder((order) => {
|
|
|
|
|
// 기존에 있던 화면이면 제거 후 맨 뒤에 추가 (순서 갱신)
|
|
|
|
|
const filteredOrder = order.filter((id) => id !== screenId);
|
|
|
|
|
return [...filteredOrder, screenId];
|
|
|
|
|
});
|
|
|
|
|
}, 0);
|
|
|
|
|
|
|
|
|
|
return { ...prev, [screenId]: [...currentFields, fieldName] };
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 선택된 필드가 변경될 때마다 기존 노드들 업데이트 및 selectionOrder 정리
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setNodes((prevNodes) =>
|
|
|
|
|
prevNodes.map((node) => ({
|
|
|
|
|
...node,
|
2025-09-05 11:30:27 +09:00
|
|
|
data: {
|
2025-09-05 16:19:31 +09:00
|
|
|
...node.data,
|
|
|
|
|
selectedFields: selectedFields[node.data.screen.screenId] || [],
|
2025-09-05 11:30:27 +09:00
|
|
|
},
|
2025-09-05 16:19:31 +09:00
|
|
|
})),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// selectionOrder에서 선택되지 않은 화면들 제거
|
|
|
|
|
const activeScreens = Object.keys(selectedFields).filter(
|
|
|
|
|
(screenId) => selectedFields[screenId] && selectedFields[screenId].length > 0,
|
|
|
|
|
);
|
|
|
|
|
setSelectionOrder((prev) => prev.filter((screenId) => activeScreens.includes(screenId)));
|
|
|
|
|
}, [selectedFields, setNodes]);
|
|
|
|
|
|
|
|
|
|
// 연결 가능한 상태인지 확인
|
|
|
|
|
const canCreateConnection = () => {
|
|
|
|
|
const selectedScreens = Object.keys(selectedFields).filter(
|
|
|
|
|
(screenId) => selectedFields[screenId] && selectedFields[screenId].length > 0,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 최소 2개의 서로 다른 테이블에서 필드가 선택되어야 함
|
|
|
|
|
return selectedScreens.length >= 2;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 필드 연결 설정 모달 열기
|
|
|
|
|
const openConnectionModal = () => {
|
|
|
|
|
const selectedScreens = Object.keys(selectedFields).filter(
|
|
|
|
|
(screenId) => selectedFields[screenId] && selectedFields[screenId].length > 0,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (selectedScreens.length < 2) return;
|
|
|
|
|
|
|
|
|
|
// 선택 순서에 따라 첫 번째와 두 번째 화면 설정
|
|
|
|
|
const orderedScreens = selectionOrder.filter((id) => selectedScreens.includes(id));
|
|
|
|
|
const firstScreenId = orderedScreens[0];
|
|
|
|
|
const secondScreenId = orderedScreens[1];
|
|
|
|
|
const firstNode = nodes.find((node) => node.data.screen.screenId === firstScreenId);
|
|
|
|
|
const secondNode = nodes.find((node) => node.data.screen.screenId === secondScreenId);
|
|
|
|
|
|
|
|
|
|
if (!firstNode || !secondNode) return;
|
|
|
|
|
|
|
|
|
|
setPendingConnection({
|
|
|
|
|
fromNode: {
|
|
|
|
|
id: firstNode.id,
|
|
|
|
|
screenName: firstNode.data.screen.screenName,
|
|
|
|
|
tableName: firstNode.data.screen.tableName,
|
|
|
|
|
},
|
|
|
|
|
toNode: {
|
|
|
|
|
id: secondNode.id,
|
|
|
|
|
screenName: secondNode.data.screen.screenName,
|
|
|
|
|
tableName: secondNode.data.screen.tableName,
|
|
|
|
|
},
|
|
|
|
|
// 선택된 모든 필드 정보를 선택 순서대로 전달
|
|
|
|
|
selectedFieldsData: (() => {
|
|
|
|
|
const orderedData: { [key: string]: { screenName: string; fields: string[] } } = {};
|
|
|
|
|
// selectionOrder 순서대로 데이터 구성 (첫 번째 선택이 먼저)
|
|
|
|
|
orderedScreens.forEach((screenId) => {
|
|
|
|
|
const node = nodes.find((n) => n.data.screen.screenId === screenId);
|
|
|
|
|
if (node && selectedFields[screenId]) {
|
|
|
|
|
orderedData[screenId] = {
|
|
|
|
|
screenName: node.data.screen.screenName,
|
|
|
|
|
fields: selectedFields[screenId],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return orderedData;
|
|
|
|
|
})(),
|
|
|
|
|
// 명시적인 순서 정보 전달
|
|
|
|
|
orderedScreenIds: orderedScreens,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 실제 화면 노드 추가
|
|
|
|
|
const addScreenNode = useCallback(
|
|
|
|
|
async (screen: ScreenDefinition) => {
|
|
|
|
|
try {
|
|
|
|
|
setLoadingScreens((prev) => new Set(prev).add(screen.screenId));
|
|
|
|
|
|
|
|
|
|
// 테이블 컬럼 정보 조회
|
|
|
|
|
const columns = await DataFlowAPI.getTableColumns(screen.tableName);
|
|
|
|
|
|
|
|
|
|
const newNode: Node = {
|
|
|
|
|
id: `screen-${screen.screenId}`,
|
|
|
|
|
type: "screenNode",
|
|
|
|
|
position: { x: Math.random() * 300, y: Math.random() * 200 },
|
|
|
|
|
data: {
|
|
|
|
|
screen: {
|
|
|
|
|
screenId: screen.screenId.toString(),
|
|
|
|
|
screenName: screen.screenName,
|
|
|
|
|
screenCode: screen.screenCode,
|
|
|
|
|
tableName: screen.tableName,
|
|
|
|
|
fields: columns.map((col) => ({
|
|
|
|
|
name: col.columnName || "unknown",
|
|
|
|
|
type: col.dataType || col.dbType || "UNKNOWN",
|
|
|
|
|
description:
|
|
|
|
|
col.columnLabel || col.displayName || col.description || col.columnName || "No description",
|
|
|
|
|
})),
|
|
|
|
|
},
|
|
|
|
|
onFieldClick: handleFieldClick,
|
|
|
|
|
onScrollAreaEnter: () => setIsOverNodeScrollArea(true),
|
|
|
|
|
onScrollAreaLeave: () => setIsOverNodeScrollArea(false),
|
|
|
|
|
selectedFields: selectedFields[screen.screenId] || [],
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
setNodes((nds) => nds.concat(newNode));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("화면 노드 추가 실패:", error);
|
|
|
|
|
alert("화면 정보를 불러오는데 실패했습니다.");
|
|
|
|
|
} finally {
|
|
|
|
|
setLoadingScreens((prev) => {
|
|
|
|
|
const newSet = new Set(prev);
|
|
|
|
|
newSet.delete(screen.screenId);
|
|
|
|
|
return newSet;
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-09-05 11:30:27 +09:00
|
|
|
},
|
2025-09-05 16:19:31 +09:00
|
|
|
[handleFieldClick, setNodes],
|
2025-09-05 11:30:27 +09:00
|
|
|
);
|
|
|
|
|
|
2025-09-05 16:19:31 +09:00
|
|
|
// 샘플 화면 노드 추가 (개발용)
|
2025-09-05 11:30:27 +09:00
|
|
|
const addSampleNode = useCallback(() => {
|
|
|
|
|
const newNode: Node = {
|
2025-09-05 16:19:31 +09:00
|
|
|
id: `sample-${Date.now()}`,
|
2025-09-05 11:30:27 +09:00
|
|
|
type: "screenNode",
|
|
|
|
|
position: { x: Math.random() * 300, y: Math.random() * 200 },
|
|
|
|
|
data: {
|
|
|
|
|
screen: {
|
2025-09-05 16:19:31 +09:00
|
|
|
screenId: `sample-${Date.now()}`,
|
2025-09-05 11:30:27 +09:00
|
|
|
screenName: `샘플 화면 ${nodes.length + 1}`,
|
2025-09-05 16:19:31 +09:00
|
|
|
screenCode: `SAMPLE${nodes.length + 1}`,
|
|
|
|
|
tableName: `sample_table_${nodes.length + 1}`,
|
2025-09-05 11:30:27 +09:00
|
|
|
fields: [
|
|
|
|
|
{ name: "id", type: "INTEGER", description: "고유 식별자" },
|
|
|
|
|
{ name: "name", type: "VARCHAR(100)", description: "이름" },
|
|
|
|
|
{ name: "code", type: "VARCHAR(50)", description: "코드" },
|
|
|
|
|
{ name: "created_date", type: "TIMESTAMP", description: "생성일시" },
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
onFieldClick: handleFieldClick,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
setNodes((nds) => nds.concat(newNode));
|
|
|
|
|
}, [nodes.length, handleFieldClick, setNodes]);
|
|
|
|
|
|
|
|
|
|
// 노드 전체 삭제
|
|
|
|
|
const clearNodes = useCallback(() => {
|
|
|
|
|
setNodes([]);
|
|
|
|
|
setEdges([]);
|
|
|
|
|
}, [setNodes, setEdges]);
|
|
|
|
|
|
2025-09-05 16:19:31 +09:00
|
|
|
// 현재 추가된 화면 ID 목록 가져오기
|
|
|
|
|
const getSelectedScreenIds = useCallback(() => {
|
|
|
|
|
return nodes
|
|
|
|
|
.filter((node) => node.id.startsWith("screen-"))
|
|
|
|
|
.map((node) => parseInt(node.id.replace("screen-", "")))
|
|
|
|
|
.filter((id) => !isNaN(id));
|
|
|
|
|
}, [nodes]);
|
|
|
|
|
|
|
|
|
|
// 연결 설정 확인
|
|
|
|
|
const handleConfirmConnection = useCallback(
|
|
|
|
|
(config: any) => {
|
|
|
|
|
if (!pendingConnection) return;
|
|
|
|
|
|
|
|
|
|
const newEdge = {
|
|
|
|
|
id: `edge-${Date.now()}`,
|
|
|
|
|
source: pendingConnection.fromNode.id,
|
|
|
|
|
target: pendingConnection.toNode.id,
|
|
|
|
|
type: "customEdge",
|
|
|
|
|
data: {
|
|
|
|
|
relationshipType: config.relationshipType,
|
|
|
|
|
connectionType: config.connectionType,
|
|
|
|
|
label: config.relationshipName,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
setEdges((eds) => [...eds, newEdge]);
|
|
|
|
|
setPendingConnection(null);
|
|
|
|
|
|
|
|
|
|
// TODO: 백엔드 API 호출하여 관계 저장
|
|
|
|
|
console.log("연결 설정:", config);
|
|
|
|
|
},
|
|
|
|
|
[pendingConnection, setEdges],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 연결 설정 취소
|
|
|
|
|
const handleCancelConnection = useCallback(() => {
|
|
|
|
|
setPendingConnection(null);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-09-05 11:30:27 +09:00
|
|
|
return (
|
|
|
|
|
<div className="data-flow-designer h-screen bg-gray-100">
|
|
|
|
|
<div className="flex h-full">
|
|
|
|
|
{/* 사이드바 */}
|
|
|
|
|
<div className="w-80 border-r border-gray-200 bg-white shadow-lg">
|
|
|
|
|
<div className="p-6">
|
|
|
|
|
<h2 className="mb-6 text-xl font-bold text-gray-800">데이터 흐름 관리</h2>
|
|
|
|
|
|
|
|
|
|
{/* 회사 정보 */}
|
|
|
|
|
<div className="mb-6 rounded-lg bg-blue-50 p-4">
|
|
|
|
|
<div className="text-sm font-medium text-blue-600">회사 코드</div>
|
|
|
|
|
<div className="text-lg font-bold text-blue-800">{companyCode}</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-09-05 16:19:31 +09:00
|
|
|
{/* 화면 선택기 */}
|
|
|
|
|
<ScreenSelector
|
|
|
|
|
companyCode={companyCode}
|
|
|
|
|
onScreenAdd={addScreenNode}
|
|
|
|
|
selectedScreens={getSelectedScreenIds()}
|
|
|
|
|
/>
|
|
|
|
|
|
2025-09-05 11:30:27 +09:00
|
|
|
{/* 컨트롤 버튼들 */}
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
<button
|
|
|
|
|
onClick={addSampleNode}
|
2025-09-05 16:19:31 +09:00
|
|
|
className="w-full rounded-lg bg-gray-500 p-3 font-medium text-white transition-colors hover:bg-gray-600"
|
2025-09-05 11:30:27 +09:00
|
|
|
>
|
2025-09-05 16:19:31 +09:00
|
|
|
+ 샘플 화면 추가 (개발용)
|
2025-09-05 11:30:27 +09:00
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
onClick={clearNodes}
|
|
|
|
|
className="w-full rounded-lg bg-red-500 p-3 font-medium text-white transition-colors hover:bg-red-600"
|
|
|
|
|
>
|
|
|
|
|
전체 삭제
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => onSave && onSave([])}
|
|
|
|
|
className="w-full rounded-lg bg-green-500 p-3 font-medium text-white transition-colors hover:bg-green-600"
|
|
|
|
|
>
|
|
|
|
|
저장
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 통계 정보 */}
|
|
|
|
|
<div className="mt-6 rounded-lg bg-gray-50 p-4">
|
|
|
|
|
<div className="mb-2 text-sm font-semibold text-gray-700">통계</div>
|
|
|
|
|
<div className="space-y-1 text-sm text-gray-600">
|
|
|
|
|
<div className="flex justify-between">
|
|
|
|
|
<span>화면 노드:</span>
|
|
|
|
|
<span className="font-medium">{nodes.length}개</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex justify-between">
|
|
|
|
|
<span>연결:</span>
|
|
|
|
|
<span className="font-medium">{edges.length}개</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 선택된 필드 정보 */}
|
2025-09-05 16:19:31 +09:00
|
|
|
{Object.keys(selectedFields).length > 0 && (
|
|
|
|
|
<div className="mt-6 space-y-4">
|
|
|
|
|
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
|
|
|
|
<div className="mb-3 text-sm font-semibold text-blue-800">선택된 필드</div>
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{[...new Set(selectionOrder)]
|
|
|
|
|
.filter((screenId) => selectedFields[screenId] && selectedFields[screenId].length > 0)
|
|
|
|
|
.map((screenId, index, filteredOrder) => {
|
|
|
|
|
const fields = selectedFields[screenId];
|
|
|
|
|
const node = nodes.find((n) => n.data.screen.screenId === screenId);
|
|
|
|
|
const screenName = node?.data.screen.screenName || screenId;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div key={`selected-${screenId}-${index}`}>
|
|
|
|
|
<div className="w-full min-w-0 rounded-lg border border-blue-300 bg-white p-3">
|
|
|
|
|
<div className="mb-2 flex flex-wrap items-center gap-2">
|
|
|
|
|
<div className="flex-shrink-0 rounded bg-blue-600 px-2 py-1 text-xs font-medium text-white">
|
|
|
|
|
{screenName}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-shrink-0 text-xs text-gray-500">ID: {screenId}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex w-full min-w-0 flex-wrap gap-1">
|
|
|
|
|
{fields.map((field, fieldIndex) => (
|
|
|
|
|
<div
|
|
|
|
|
key={`${screenId}-${field}-${fieldIndex}`}
|
|
|
|
|
className="max-w-full truncate rounded-full border border-blue-200 bg-blue-100 px-2 py-1 text-xs text-blue-800"
|
|
|
|
|
title={field}
|
|
|
|
|
>
|
|
|
|
|
{field}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/* 첫 번째 화면 다음에 화살표 표시 */}
|
|
|
|
|
{index === 0 && filteredOrder.length > 1 && (
|
|
|
|
|
<div className="flex justify-center py-2">
|
|
|
|
|
<div className="text-gray-400">↓</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-3 flex gap-2">
|
|
|
|
|
<button
|
|
|
|
|
onClick={openConnectionModal}
|
|
|
|
|
disabled={!canCreateConnection()}
|
|
|
|
|
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
|
|
|
|
|
canCreateConnection()
|
|
|
|
|
? "bg-blue-600 text-white hover:bg-blue-700"
|
|
|
|
|
: "cursor-not-allowed bg-gray-300 text-gray-500"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
필드 연결 설정
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setSelectedFields({});
|
|
|
|
|
setSelectionOrder([]);
|
|
|
|
|
}}
|
|
|
|
|
className="rounded bg-gray-200 px-3 py-1 text-xs font-medium text-gray-600 hover:bg-gray-300"
|
|
|
|
|
>
|
|
|
|
|
선택 초기화
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2025-09-05 11:30:27 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* React Flow 캔버스 */}
|
|
|
|
|
<div className="relative flex-1">
|
|
|
|
|
<ReactFlow
|
|
|
|
|
nodes={nodes}
|
|
|
|
|
edges={edges}
|
|
|
|
|
onNodesChange={onNodesChange}
|
|
|
|
|
onEdgesChange={onEdgesChange}
|
|
|
|
|
onConnect={onConnect}
|
|
|
|
|
nodeTypes={nodeTypes}
|
|
|
|
|
edgeTypes={edgeTypes}
|
|
|
|
|
fitView
|
|
|
|
|
attributionPosition="bottom-left"
|
2025-09-05 16:19:31 +09:00
|
|
|
panOnScroll={false}
|
|
|
|
|
zoomOnScroll={true}
|
|
|
|
|
zoomOnPinch={true}
|
|
|
|
|
panOnDrag={true}
|
2025-09-05 11:30:27 +09:00
|
|
|
>
|
|
|
|
|
<Controls />
|
|
|
|
|
<MiniMap
|
|
|
|
|
nodeColor={(node) => {
|
|
|
|
|
switch (node.type) {
|
|
|
|
|
case "screenNode":
|
|
|
|
|
return "#3B82F6";
|
|
|
|
|
default:
|
|
|
|
|
return "#6B7280";
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<Background variant="dots" gap={20} size={1} color="#E5E7EB" />
|
|
|
|
|
</ReactFlow>
|
|
|
|
|
|
|
|
|
|
{/* 안내 메시지 */}
|
|
|
|
|
{nodes.length === 0 && (
|
|
|
|
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
|
|
|
<div className="text-center text-gray-500">
|
|
|
|
|
<div className="mb-2 text-2xl">📊</div>
|
|
|
|
|
<div className="mb-1 text-lg font-medium">데이터 흐름 설계를 시작하세요</div>
|
2025-09-05 16:19:31 +09:00
|
|
|
<div className="text-sm">왼쪽 사이드바에서 화면을 선택하여 추가하세요</div>
|
2025-09-05 11:30:27 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-09-05 16:19:31 +09:00
|
|
|
|
|
|
|
|
{/* 연결 설정 모달 */}
|
|
|
|
|
<ConnectionSetupModal
|
|
|
|
|
isOpen={!!pendingConnection}
|
|
|
|
|
connection={pendingConnection}
|
|
|
|
|
onConfirm={handleConfirmConnection}
|
|
|
|
|
onCancel={handleCancelConnection}
|
|
|
|
|
/>
|
2025-09-05 11:30:27 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|