import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { addRoadmapItemDependency, deleteRoadmapDependency, listInitiativeRoadmapDependencies, } from '../api/roadmap.js' import { ROADMAP_ITEM_TYPE_LABELS } from '../constants/status.js' import { gatePath } from '../utils/routes.js' import { computeGateGraphLayout } from '../plan/gateGraphLayout.js' import { applyManualGatePositions, clampDesignerZoom, clearManualGatePositions, DESIGNER_ZOOM_STEP, loadManualGatePositions, storeManualGatePositions, } from '../plan/gateGraphDesigner.js' import { LoadingState } from './LoadingState.jsx' import { ErrorState } from './ErrorState.jsx' import { EmptyState } from './EmptyState.jsx' const EDGE_TYPE_LABELS = { requires: 'Voraussetzung', blocks: 'Blockiert', related: 'Bezug', } const CONNECT_HINTS = { requires: 'Schritt 1: Voraussetzung wählen · Schritt 2: abhängiges Gate', blocks: 'Schritt 1: Blocker wählen · Schritt 2: geblocktes Gate', related: 'Schritt 1: erstes Gate · Schritt 2: zweites Gate', } function edgePath(edge) { const midY = (edge.y1 + edge.y2) / 2 return `M ${edge.x1} ${edge.y1} C ${edge.x1} ${midY}, ${edge.x2} ${midY}, ${edge.x2} ${edge.y2}` } function truncateTitle(title, max = 28) { if (!title || title.length <= max) return title || 'Gate' return `${title.slice(0, max - 1)}…` } /** * @param {DOMRect} rect * @param {{ x: number, y: number }} pan * @param {number} zoom * @param {number} clientX * @param {number} clientY */ function clientToGraphPoint(rect, pan, zoom, clientX, clientY) { return { x: (clientX - rect.left - pan.x) / zoom, y: (clientY - rect.top - pan.y) / zoom, } } export function GateGraphDesigner({ initiativeId, items, canManage = false, onGraphChanged, }) { const navigate = useNavigate() const viewportRef = useRef(null) const [dependencies, setDependencies] = useState([]) const [manualPositions, setManualPositions] = useState(() => loadManualGatePositions(initiativeId), ) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [busy, setBusy] = useState(false) const [tool, setTool] = useState('pan') const [edgeType, setEdgeType] = useState('requires') const [connectFromId, setConnectFromId] = useState(null) const [selectedEdgeId, setSelectedEdgeId] = useState(null) const [pan, setPan] = useState({ x: 24, y: 24 }) const [zoom, setZoom] = useState(1) const [draggingNodeId, setDraggingNodeId] = useState(null) const dragOffsetRef = useRef({ x: 0, y: 0 }) const panStartRef = useRef(null) const reloadDependencies = useCallback(async () => { if (!initiativeId) { setDependencies([]) return } const data = await listInitiativeRoadmapDependencies(initiativeId) setDependencies(Array.isArray(data) ? data : []) }, [initiativeId]) useEffect(() => { if (!initiativeId) { setDependencies([]) setLoading(false) return undefined } let cancelled = false setLoading(true) setError(null) reloadDependencies() .catch((err) => { if (!cancelled) { setError(err?.message || 'Abhängigkeiten konnten nicht geladen werden.') } }) .finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, [initiativeId, reloadDependencies]) useEffect(() => { setManualPositions(loadManualGatePositions(initiativeId)) setConnectFromId(null) setSelectedEdgeId(null) }, [initiativeId]) const baseLayout = useMemo( () => computeGateGraphLayout(items, dependencies), [items, dependencies], ) const layout = useMemo( () => applyManualGatePositions(baseLayout, manualPositions), [baseLayout, manualPositions], ) async function notifyGraphChanged() { await reloadDependencies() onGraphChanged?.() } async function handleCreateEdge(fromId, toId) { if (!fromId || !toId || fromId === toId) return setBusy(true) setError(null) try { let fromItemId = fromId let toItemId = toId if (edgeType === 'requires') { fromItemId = toId toItemId = fromId } await addRoadmapItemDependency(fromItemId, { to_item_id: toItemId, dependency_type: edgeType, }) setConnectFromId(null) await notifyGraphChanged() } catch (err) { setError(err?.message || 'Kante konnte nicht angelegt werden.') } finally { setBusy(false) } } async function handleDeleteEdge(edgeId) { if (!edgeId || !canManage) return setBusy(true) setError(null) try { await deleteRoadmapDependency(edgeId) setSelectedEdgeId(null) await notifyGraphChanged() } catch (err) { setError(err?.message || 'Kante konnte nicht entfernt werden.') } finally { setBusy(false) } } function handleNodeClick(nodeId, event) { event.stopPropagation() if (busy) return if (tool === 'connect' && canManage) { if (!connectFromId) { setConnectFromId(nodeId) setSelectedEdgeId(null) return } if (connectFromId === nodeId) { setConnectFromId(null) return } handleCreateEdge(connectFromId, nodeId) return } if (event.detail === 2 && tool === 'pan') { navigate(gatePath(nodeId)) } } function handleNodePointerDown(node, event) { if (!canManage || tool !== 'pan' || event.button !== 0) return event.stopPropagation() const rect = viewportRef.current?.getBoundingClientRect() if (!rect) return const point = clientToGraphPoint(rect, pan, zoom, event.clientX, event.clientY) dragOffsetRef.current = { x: point.x - node.x, y: point.y - node.y } setDraggingNodeId(node.id) setSelectedEdgeId(null) } function handleViewportPointerDown(event) { if (event.button !== 0) return const target = event.target if (target.closest?.('.gate-map-view__node')) return if (target.classList?.contains('gate-map-view__edge')) return setSelectedEdgeId(null) if (tool === 'connect') { setConnectFromId(null) return } if (tool !== 'pan' || draggingNodeId) return panStartRef.current = { startX: event.clientX, startY: event.clientY, panX: pan.x, panY: pan.y, } } useEffect(() => { function handlePointerMove(event) { if (draggingNodeId && canManage) { const rect = viewportRef.current?.getBoundingClientRect() if (!rect) return const point = clientToGraphPoint(rect, pan, zoom, event.clientX, event.clientY) const nextX = Math.max(0, point.x - dragOffsetRef.current.x) const nextY = Math.max(0, point.y - dragOffsetRef.current.y) setManualPositions((prev) => { const next = { ...prev, [draggingNodeId]: { x: nextX, y: nextY } } storeManualGatePositions(initiativeId, next) return next }) return } if (panStartRef.current) { const deltaX = event.clientX - panStartRef.current.startX const deltaY = event.clientY - panStartRef.current.startY setPan({ x: panStartRef.current.panX + deltaX, y: panStartRef.current.panY + deltaY, }) } } function handlePointerUp() { setDraggingNodeId(null) panStartRef.current = null } window.addEventListener('pointermove', handlePointerMove) window.addEventListener('pointerup', handlePointerUp) return () => { window.removeEventListener('pointermove', handlePointerMove) window.removeEventListener('pointerup', handlePointerUp) } }, [canManage, draggingNodeId, initiativeId, pan, zoom]) function handleWheel(event) { event.preventDefault() const direction = event.deltaY > 0 ? -1 : 1 setZoom((current) => clampDesignerZoom(current + direction * DESIGNER_ZOOM_STEP)) } function handleResetLayout() { clearManualGatePositions(initiativeId) setManualPositions({}) setPan({ x: 24, y: 24 }) setZoom(1) } if (!items.length) { return ( ) } if (loading) { return } return (

Zielzustände (Designer)

Zielzustands-Graph modellieren — Pan/Zoom, Knoten verschieben, Kanten verbinden. Verify und Kriterien bleiben auf der Gate-Detailseite.

{error && }
{canManage && ( )}
{canManage && tool === 'connect' && ( )}
{Math.round(zoom * 100)}%
{canManage && selectedEdgeId && ( )} {canManage && ( )}
{tool === 'connect' && canManage && (

{CONNECT_HINTS[edgeType] || 'Zwei Gates nacheinander anklicken.'} {connectFromId ? ' — erstes Gate gewählt, zweites wählen.' : ''}

)} {!canManage && (

Nur Ansicht — zum Modellieren fehlt die Berechtigung „Plan verwalten“.

)}
{layout.edges.map((edge) => ( { event.stopPropagation() if (canManage) setSelectedEdgeId(edge.id) }} /> ))} {layout.nodes.map((node) => { const item = node.item const isConnectSource = connectFromId === node.id const nodeClass = 'gate-map-view__node gate-map-view__node--' + (item.status || 'planned') + (isConnectSource ? ' gate-graph-designer__node--connect-source' : '') + (draggingNodeId === node.id ? ' gate-graph-designer__node--dragging' : '') return ( handleNodePointerDown(node, event)} onClick={(event) => handleNodeClick(node.id, event)} > {truncateTitle(item.title)} {ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type || 'Gate'} ) })}
{canManage && (

Doppelklick auf Gate öffnet Details. Layout-Positionen werden lokal gespeichert.

)}
) }