import { useState, useRef, useCallback, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { Circle, Square, Diamond, ArrowRight, Trash2, ZoomIn, ZoomOut, RotateCcw, Save, Play, StopCircle, Timer, Mail, MessageSquare, GitBranch, Layers, Move, } from "lucide-react"; // ═══════════════════════════════════════════════════ // TIPOS BPMN // ═══════════════════════════════════════════════════ export type BpmnElementType = | "startEvent" | "endEvent" | "task" | "userTask" | "serviceTask" | "timerEvent" | "messageEvent" | "exclusiveGateway" | "parallelGateway" | "inclusiveGateway" | "subProcess" | "lane"; export interface BpmnNode { id: string; type: BpmnElementType; x: number; y: number; width: number; height: number; label: string; color?: string; } export interface BpmnConnection { id: string; sourceId: string; targetId: string; label?: string; } export interface BpmnDiagramData { nodes: BpmnNode[]; connections: BpmnConnection[]; } // ═══════════════════════════════════════════════════ // PALETA DE ELEMENTOS BPMN // ═══════════════════════════════════════════════════ const BPMN_PALETTE: { category: string; items: { type: BpmnElementType; label: string; icon: any }[]; }[] = [ { category: "Eventos", items: [ { type: "startEvent", label: "Início", icon: Play }, { type: "endEvent", label: "Fim", icon: StopCircle }, { type: "timerEvent", label: "Timer", icon: Timer }, { type: "messageEvent", label: "Mensagem", icon: Mail }, ], }, { category: "Atividades", items: [ { type: "task", label: "Tarefa", icon: Square }, { type: "userTask", label: "Tarefa Usuário", icon: MessageSquare }, { type: "serviceTask", label: "Tarefa Serviço", icon: Layers }, { type: "subProcess", label: "Sub-Processo", icon: GitBranch }, ], }, { category: "Gateways", items: [ { type: "exclusiveGateway", label: "Exclusivo (XOR)", icon: Diamond }, { type: "parallelGateway", label: "Paralelo (AND)", icon: Diamond }, { type: "inclusiveGateway", label: "Inclusivo (OR)", icon: Diamond }, ], }, ]; const DEFAULT_SIZES: Record = { startEvent: { w: 40, h: 40 }, endEvent: { w: 40, h: 40 }, timerEvent: { w: 40, h: 40 }, messageEvent: { w: 40, h: 40 }, task: { w: 140, h: 60 }, userTask: { w: 140, h: 60 }, serviceTask: { w: 140, h: 60 }, subProcess: { w: 160, h: 80 }, exclusiveGateway: { w: 50, h: 50 }, parallelGateway: { w: 50, h: 50 }, inclusiveGateway: { w: 50, h: 50 }, lane: { w: 600, h: 200 }, }; // ═══════════════════════════════════════════════════ // RENDERIZADORES SVG DOS ELEMENTOS // ═══════════════════════════════════════════════════ function renderBpmnElement( node: BpmnNode, isSelected: boolean, isConnecting: boolean ) { const { x, y, width, height, type, label } = node; const cx = x + width / 2; const cy = y + height / 2; const strokeColor = isSelected ? "#3b82f6" : isConnecting ? "#f59e0b" : "#64748b"; const strokeWidth = isSelected ? 2.5 : 1.5; switch (type) { case "startEvent": return ( {label} ); case "endEvent": return ( {label} ); case "timerEvent": return ( {label} ); case "messageEvent": return ( {label} ); case "task": case "userTask": case "serviceTask": const taskFill = type === "userTask" ? "#eff6ff" : type === "serviceTask" ? "#f0fdf4" : "#fff"; const taskStroke = type === "userTask" ? "#3b82f6" : type === "serviceTask" ? "#22c55e" : strokeColor; const iconChar = type === "userTask" ? "👤" : type === "serviceTask" ? "⚙️" : ""; return ( {iconChar && ( {iconChar} )} {label.length > 18 ? label.slice(0, 18) + "…" : label} ); case "subProcess": return ( {label.length > 20 ? label.slice(0, 20) + "…" : label} ); case "exclusiveGateway": case "parallelGateway": case "inclusiveGateway": { const gFill = type === "exclusiveGateway" ? "#fef9c3" : type === "parallelGateway" ? "#cffafe" : "#fce7f3"; const gStroke = type === "exclusiveGateway" ? "#eab308" : type === "parallelGateway" ? "#06b6d4" : "#ec4899"; return ( {type === "exclusiveGateway" && ( <> )} {type === "parallelGateway" && ( <> )} {type === "inclusiveGateway" && ( )} {label} ); } default: return null; } } function getConnectionPoint(node: BpmnNode, side: "left" | "right" | "top" | "bottom") { const cx = node.x + node.width / 2; const cy = node.y + node.height / 2; switch (side) { case "right": return { x: node.x + node.width, y: cy }; case "left": return { x: node.x, y: cy }; case "bottom": return { x: cx, y: node.y + node.height }; case "top": return { x: cx, y: node.y }; } } function getBestConnectionPoints(source: BpmnNode, target: BpmnNode) { const scx = source.x + source.width / 2; const scy = source.y + source.height / 2; const tcx = target.x + target.width / 2; const tcy = target.y + target.height / 2; const dx = tcx - scx; const dy = tcy - scy; let sourceSide: "left" | "right" | "top" | "bottom"; let targetSide: "left" | "right" | "top" | "bottom"; if (Math.abs(dx) > Math.abs(dy)) { sourceSide = dx > 0 ? "right" : "left"; targetSide = dx > 0 ? "left" : "right"; } else { sourceSide = dy > 0 ? "bottom" : "top"; targetSide = dy > 0 ? "top" : "bottom"; } return { source: getConnectionPoint(source, sourceSide), target: getConnectionPoint(target, targetSide), }; } // ═══════════════════════════════════════════════════ // COMPONENTE PRINCIPAL // ═══════════════════════════════════════════════════ interface BpmnDiagramProps { initialData?: BpmnDiagramData; onSave?: (data: BpmnDiagramData) => void; processName?: string; } export default function BpmnDiagram({ initialData, onSave, processName }: BpmnDiagramProps) { const [nodes, setNodes] = useState(initialData?.nodes || []); const [connections, setConnections] = useState(initialData?.connections || []); const [selectedNodeId, setSelectedNodeId] = useState(null); const [connectingFrom, setConnectingFrom] = useState(null); const [dragging, setDragging] = useState<{ nodeId: string; offsetX: number; offsetY: number } | null>(null); const [editingLabel, setEditingLabel] = useState(null); const [zoom, setZoom] = useState(1); const [pan, setPan] = useState({ x: 0, y: 0 }); const svgRef = useRef(null); const [mode, setMode] = useState<"select" | "connect">("select"); const generateId = () => `bpmn_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const addNode = useCallback((type: BpmnElementType) => { const size = DEFAULT_SIZES[type]; const newNode: BpmnNode = { id: generateId(), type, x: 200 + Math.random() * 300, y: 100 + Math.random() * 200, width: size.w, height: size.h, label: BPMN_PALETTE.flatMap(c => c.items).find(i => i.type === type)?.label || type, }; setNodes(prev => [...prev, newNode]); setSelectedNodeId(newNode.id); }, []); const deleteSelected = useCallback(() => { if (!selectedNodeId) return; setNodes(prev => prev.filter(n => n.id !== selectedNodeId)); setConnections(prev => prev.filter(c => c.sourceId !== selectedNodeId && c.targetId !== selectedNodeId)); setSelectedNodeId(null); }, [selectedNodeId]); const handleSvgMouseDown = useCallback((e: React.MouseEvent) => { const target = e.target as SVGElement; const nodeG = target.closest("[data-node-id]"); if (nodeG) { const nodeId = nodeG.getAttribute("data-node-id")!; const node = nodes.find(n => n.id === nodeId); if (!node) return; if (mode === "connect") { if (!connectingFrom) { setConnectingFrom(nodeId); } else if (connectingFrom !== nodeId) { const exists = connections.some( c => (c.sourceId === connectingFrom && c.targetId === nodeId) ); if (!exists) { setConnections(prev => [...prev, { id: generateId(), sourceId: connectingFrom, targetId: nodeId, }]); } setConnectingFrom(null); } return; } setSelectedNodeId(nodeId); const svgRect = svgRef.current!.getBoundingClientRect(); const mouseX = (e.clientX - svgRect.left - pan.x) / zoom; const mouseY = (e.clientY - svgRect.top - pan.y) / zoom; setDragging({ nodeId, offsetX: mouseX - node.x, offsetY: mouseY - node.y }); } else { setSelectedNodeId(null); setConnectingFrom(null); } }, [nodes, mode, connectingFrom, connections, zoom, pan]); const handleSvgMouseMove = useCallback((e: React.MouseEvent) => { if (!dragging) return; const svgRect = svgRef.current!.getBoundingClientRect(); const mouseX = (e.clientX - svgRect.left - pan.x) / zoom; const mouseY = (e.clientY - svgRect.top - pan.y) / zoom; setNodes(prev => prev.map(n => n.id === dragging.nodeId ? { ...n, x: Math.max(0, mouseX - dragging.offsetX), y: Math.max(0, mouseY - dragging.offsetY) } : n )); }, [dragging, zoom, pan]); const handleSvgMouseUp = useCallback(() => { setDragging(null); }, []); const handleLabelChange = useCallback((nodeId: string, newLabel: string) => { setNodes(prev => prev.map(n => n.id === nodeId ? { ...n, label: newLabel } : n)); }, []); const handleSave = useCallback(() => { onSave?.({ nodes, connections }); }, [nodes, connections, onSave]); const resetView = useCallback(() => { setZoom(1); setPan({ x: 0, y: 0 }); }, []); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Delete" || e.key === "Backspace") { if (editingLabel) return; deleteSelected(); } if (e.key === "Escape") { setSelectedNodeId(null); setConnectingFrom(null); setMode("select"); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [deleteSelected, editingLabel]); const selectedNode = nodes.find(n => n.id === selectedNodeId); return (
{/* Toolbar */}
{processName || "Diagramador BPMN"} {nodes.length} elementos {connections.length} conexões
{Math.round(zoom * 100)}%
{selectedNodeId && ( )}
{/* Painel lateral de elementos */}
{BPMN_PALETTE.map(category => (

{category.category}

{category.items.map(item => { const Icon = item.icon; return ( ); })}
))} {mode === "connect" && (
Modo conexão: clique no elemento de origem e depois no destino.
)} {connectingFrom && (
Conectando de: {nodes.find(n => n.id === connectingFrom)?.label}
)}
{/* Canvas SVG */}
{/* Grid */} {/* Conexões */} {connections.map(conn => { const source = nodes.find(n => n.id === conn.sourceId); const target = nodes.find(n => n.id === conn.targetId); if (!source || !target) return null; const pts = getBestConnectionPoints(source, target); const midX = (pts.source.x + pts.target.x) / 2; const midY = (pts.source.y + pts.target.y) / 2; return ( {conn.label && ( {conn.label} )} ); })} {/* Nós */} {nodes.map(node => ( { e.stopPropagation(); setEditingLabel(node.id); setSelectedNodeId(node.id); }} > {renderBpmnElement(node, selectedNodeId === node.id, connectingFrom === node.id)} {/* Pontos de conexão ao selecionar */} {selectedNodeId === node.id && mode === "select" && ( <> {(["left", "right", "top", "bottom"] as const).map(side => { const pt = getConnectionPoint(node, side); return ( ); })} )} ))}
{/* Painel de propriedades */} {selectedNode && (

Propriedades

handleLabelChange(selectedNode.id, e.target.value)} className="h-8 text-sm" data-testid="input-node-label" />
{BPMN_PALETTE.flatMap(c => c.items).find(i => i.type === selectedNode.type)?.label || selectedNode.type}
{connections .filter(c => c.sourceId === selectedNode.id || c.targetId === selectedNode.id) .map(c => { const other = c.sourceId === selectedNode.id ? nodes.find(n => n.id === c.targetId) : nodes.find(n => n.id === c.sourceId); const dir = c.sourceId === selectedNode.id ? "→" : "←"; return (
{dir} {other?.label || "?"}
); })} {connections.filter(c => c.sourceId === selectedNode.id || c.targetId === selectedNode.id).length === 0 && (

Sem conexões

)}
)}
); }