210 lines
6.8 KiB
TypeScript
210 lines
6.8 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import React, { useState, useCallback } from "react";
|
||
|
|
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";
|
||
|
|
|
||
|
|
// 노드 및 엣지 타입 정의
|
||
|
|
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([]);
|
||
|
|
const [selectedField, setSelectedField] = useState<{
|
||
|
|
screenId: string;
|
||
|
|
fieldName: string;
|
||
|
|
} | null>(null);
|
||
|
|
|
||
|
|
// 노드 연결 처리
|
||
|
|
const onConnect = useCallback(
|
||
|
|
(params: Connection) => {
|
||
|
|
const newEdge = {
|
||
|
|
...params,
|
||
|
|
type: "customEdge",
|
||
|
|
data: {
|
||
|
|
relationshipType: "one-to-one",
|
||
|
|
connectionType: "simple-key",
|
||
|
|
label: "1:1 연결",
|
||
|
|
},
|
||
|
|
};
|
||
|
|
setEdges((eds) => addEdge(newEdge, eds));
|
||
|
|
},
|
||
|
|
[setEdges],
|
||
|
|
);
|
||
|
|
|
||
|
|
// 필드 클릭 처리
|
||
|
|
const handleFieldClick = useCallback((screenId: string, fieldName: string) => {
|
||
|
|
setSelectedField({ screenId, fieldName });
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 샘플 화면 노드 추가
|
||
|
|
const addSampleNode = useCallback(() => {
|
||
|
|
const newNode: Node = {
|
||
|
|
id: `screen-${Date.now()}`,
|
||
|
|
type: "screenNode",
|
||
|
|
position: { x: Math.random() * 300, y: Math.random() * 200 },
|
||
|
|
data: {
|
||
|
|
screen: {
|
||
|
|
screenId: `screen-${Date.now()}`,
|
||
|
|
screenName: `샘플 화면 ${nodes.length + 1}`,
|
||
|
|
screenCode: `SCREEN${nodes.length + 1}`,
|
||
|
|
tableName: `table_${nodes.length + 1}`,
|
||
|
|
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]);
|
||
|
|
|
||
|
|
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>
|
||
|
|
|
||
|
|
{/* 컨트롤 버튼들 */}
|
||
|
|
<div className="space-y-3">
|
||
|
|
<button
|
||
|
|
onClick={addSampleNode}
|
||
|
|
className="w-full rounded-lg bg-blue-500 p-3 font-medium text-white transition-colors hover:bg-blue-600"
|
||
|
|
>
|
||
|
|
+ 샘플 화면 추가
|
||
|
|
</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>
|
||
|
|
|
||
|
|
{/* 선택된 필드 정보 */}
|
||
|
|
{selectedField && (
|
||
|
|
<div className="mt-6 rounded-lg border border-yellow-200 bg-yellow-50 p-4">
|
||
|
|
<div className="mb-2 text-sm font-semibold text-yellow-800">선택된 필드</div>
|
||
|
|
<div className="text-sm text-yellow-700">
|
||
|
|
<div>화면: {selectedField.screenId}</div>
|
||
|
|
<div>필드: {selectedField.fieldName}</div>
|
||
|
|
</div>
|
||
|
|
<button
|
||
|
|
onClick={() => setSelectedField(null)}
|
||
|
|
className="mt-2 text-xs text-yellow-600 hover:text-yellow-800"
|
||
|
|
>
|
||
|
|
선택 해제
|
||
|
|
</button>
|
||
|
|
</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"
|
||
|
|
>
|
||
|
|
<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>
|
||
|
|
<div className="text-sm">왼쪽 사이드바에서 "샘플 화면 추가" 버튼을 클릭하세요</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|