324 lines
8.8 KiB
TypeScript
324 lines
8.8 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 플로우 편집기 페이지
|
|
* - React Flow 기반 비주얼 플로우 편집
|
|
* - 단계 추가/수정/삭제
|
|
* - 단계 연결 생성/삭제
|
|
* - 조건 설정
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import { useParams, useRouter } from "next/navigation";
|
|
import ReactFlow, {
|
|
Node,
|
|
Edge,
|
|
addEdge,
|
|
Connection,
|
|
useNodesState,
|
|
useEdgesState,
|
|
Background,
|
|
Controls,
|
|
MiniMap,
|
|
Panel,
|
|
} from "reactflow";
|
|
import "reactflow/dist/style.css";
|
|
import { ArrowLeft, Plus, Save, Play, Settings, Trash2 } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import {
|
|
getFlowDefinition,
|
|
getFlowSteps,
|
|
getFlowConnections,
|
|
createFlowStep,
|
|
updateFlowStep,
|
|
deleteFlowStep,
|
|
createFlowConnection,
|
|
deleteFlowConnection,
|
|
getAllStepCounts,
|
|
} from "@/lib/api/flow";
|
|
import { FlowDefinition, FlowStep, FlowStepConnection, FlowNodeData } from "@/types/flow";
|
|
import { FlowNodeComponent } from "@/components/flow/FlowNodeComponent";
|
|
import { FlowStepPanel } from "@/components/flow/FlowStepPanel";
|
|
import { FlowConditionBuilder } from "@/components/flow/FlowConditionBuilder";
|
|
|
|
// 커스텀 노드 타입 등록
|
|
const nodeTypes = {
|
|
flowStep: FlowNodeComponent,
|
|
};
|
|
|
|
export default function FlowEditorPage() {
|
|
const params = useParams();
|
|
const router = useRouter();
|
|
const { toast } = useToast();
|
|
const flowId = Number(params.id);
|
|
|
|
// 상태
|
|
const [flowDefinition, setFlowDefinition] = useState<FlowDefinition | null>(null);
|
|
const [steps, setSteps] = useState<FlowStep[]>([]);
|
|
const [connections, setConnections] = useState<FlowStepConnection[]>([]);
|
|
const [selectedStep, setSelectedStep] = useState<FlowStep | null>(null);
|
|
const [stepCounts, setStepCounts] = useState<Record<number, number>>({});
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// React Flow 상태
|
|
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
|
|
|
// 플로우 데이터 로드
|
|
const loadFlowData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
// 플로우 정의 로드
|
|
const flowRes = await getFlowDefinition(flowId);
|
|
if (flowRes.success && flowRes.data) {
|
|
setFlowDefinition(flowRes.data);
|
|
}
|
|
|
|
// 단계 로드
|
|
const stepsRes = await getFlowSteps(flowId);
|
|
if (stepsRes.success && stepsRes.data) {
|
|
setSteps(stepsRes.data);
|
|
}
|
|
|
|
// 연결 로드
|
|
const connectionsRes = await getFlowConnections(flowId);
|
|
if (connectionsRes.success && connectionsRes.data) {
|
|
setConnections(connectionsRes.data);
|
|
}
|
|
|
|
// 데이터 카운트 로드
|
|
const countsRes = await getAllStepCounts(flowId);
|
|
if (countsRes.success && countsRes.data) {
|
|
const counts: Record<number, number> = {};
|
|
countsRes.data.forEach((item) => {
|
|
counts[item.stepId] = item.count;
|
|
});
|
|
setStepCounts(counts);
|
|
}
|
|
} catch (error: any) {
|
|
toast({
|
|
title: "로딩 실패",
|
|
description: error.message,
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadFlowData();
|
|
}, [flowId]);
|
|
|
|
// React Flow 노드/엣지 변환
|
|
useEffect(() => {
|
|
if (steps.length === 0) return;
|
|
|
|
// 노드 생성
|
|
const newNodes: Node<FlowNodeData>[] = steps.map((step) => ({
|
|
id: String(step.id),
|
|
type: "flowStep",
|
|
position: { x: step.positionX, y: step.positionY },
|
|
data: {
|
|
id: step.id,
|
|
label: step.stepName,
|
|
stepOrder: step.stepOrder,
|
|
tableName: step.tableName,
|
|
count: stepCounts[step.id] || 0,
|
|
condition: step.conditionJson,
|
|
},
|
|
}));
|
|
|
|
// 엣지 생성
|
|
const newEdges: Edge[] = connections.map((conn) => ({
|
|
id: String(conn.id),
|
|
source: String(conn.fromStepId),
|
|
target: String(conn.toStepId),
|
|
label: conn.label,
|
|
type: "smoothstep",
|
|
animated: true,
|
|
}));
|
|
|
|
setNodes(newNodes);
|
|
setEdges(newEdges);
|
|
}, [steps, connections, stepCounts]);
|
|
|
|
// 노드 추가
|
|
const handleAddStep = async () => {
|
|
const newStepOrder = steps.length + 1;
|
|
const newStep = {
|
|
stepName: `단계 ${newStepOrder}`,
|
|
stepOrder: newStepOrder,
|
|
color: "#3B82F6",
|
|
positionX: 100 + newStepOrder * 250,
|
|
positionY: 100,
|
|
};
|
|
|
|
try {
|
|
const response = await createFlowStep(flowId, newStep);
|
|
if (response.success && response.data) {
|
|
toast({
|
|
title: "단계 추가",
|
|
description: "새로운 단계가 추가되었습니다.",
|
|
});
|
|
loadFlowData();
|
|
}
|
|
} catch (error: any) {
|
|
toast({
|
|
title: "추가 실패",
|
|
description: error.message,
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
// 노드 위치 업데이트
|
|
const handleNodeDragStop = useCallback(
|
|
async (event: any, node: Node) => {
|
|
const step = steps.find((s) => s.id === Number(node.id));
|
|
if (!step) return;
|
|
|
|
try {
|
|
await updateFlowStep(step.id, {
|
|
positionX: Math.round(node.position.x),
|
|
positionY: Math.round(node.position.y),
|
|
});
|
|
} catch (error: any) {
|
|
console.error("위치 업데이트 실패:", error);
|
|
}
|
|
},
|
|
[steps],
|
|
);
|
|
|
|
// 연결 생성
|
|
const handleConnect = useCallback(
|
|
async (connection: Connection) => {
|
|
if (!connection.source || !connection.target) return;
|
|
|
|
try {
|
|
const response = await createFlowConnection({
|
|
flowDefinitionId: flowId,
|
|
fromStepId: Number(connection.source),
|
|
toStepId: Number(connection.target),
|
|
});
|
|
|
|
if (response.success) {
|
|
toast({
|
|
title: "연결 생성",
|
|
description: "단계가 연결되었습니다.",
|
|
});
|
|
loadFlowData();
|
|
}
|
|
} catch (error: any) {
|
|
toast({
|
|
title: "연결 실패",
|
|
description: error.message,
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
},
|
|
[flowId],
|
|
);
|
|
|
|
// 노드 클릭
|
|
const handleNodeClick = useCallback(
|
|
(event: React.MouseEvent, node: Node) => {
|
|
const step = steps.find((s) => s.id === Number(node.id));
|
|
if (step) {
|
|
setSelectedStep(step);
|
|
}
|
|
},
|
|
[steps],
|
|
);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="container mx-auto p-6">
|
|
<p>로딩 중...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!flowDefinition) {
|
|
return (
|
|
<div className="container mx-auto p-6">
|
|
<p>플로우를 찾을 수 없습니다.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-screen flex-col">
|
|
{/* 헤더 */}
|
|
<div className="border-b bg-white p-4">
|
|
<div className="container mx-auto flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Button variant="ghost" size="sm" onClick={() => router.push("/admin/flow-management")}>
|
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
목록으로
|
|
</Button>
|
|
<div>
|
|
<h1 className="text-xl font-bold">{flowDefinition.name}</h1>
|
|
<p className="text-muted-foreground text-sm">테이블: {flowDefinition.tableName}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={handleAddStep}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
단계 추가
|
|
</Button>
|
|
<Button size="sm" onClick={() => loadFlowData()}>
|
|
<Save className="mr-2 h-4 w-4" />
|
|
새로고침
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 편집기 */}
|
|
<div className="relative flex-1">
|
|
<ReactFlow
|
|
nodes={nodes}
|
|
edges={edges}
|
|
onNodesChange={onNodesChange}
|
|
onEdgesChange={onEdgesChange}
|
|
onConnect={handleConnect}
|
|
onNodeClick={handleNodeClick}
|
|
onNodeDragStop={handleNodeDragStop}
|
|
nodeTypes={nodeTypes}
|
|
fitView
|
|
className="bg-gray-50"
|
|
>
|
|
<Background />
|
|
<Controls />
|
|
<MiniMap />
|
|
|
|
<Panel position="top-right" className="rounded bg-white p-4 shadow">
|
|
<div className="space-y-2 text-sm">
|
|
<div>
|
|
<strong>총 단계:</strong> {steps.length}개
|
|
</div>
|
|
<div>
|
|
<strong>연결:</strong> {connections.length}개
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
</ReactFlow>
|
|
</div>
|
|
|
|
{/* 사이드 패널 */}
|
|
{selectedStep && (
|
|
<FlowStepPanel
|
|
step={selectedStep}
|
|
flowId={flowId}
|
|
onClose={() => setSelectedStep(null)}
|
|
onUpdate={loadFlowData}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|