import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { addRoadmapItemDependency, deleteRoadmapDependency, listInitiativeRoadmapCriteriaProgress, listInitiativeRoadmapDependencies, } from '../api/roadmap.js' import { ROADMAP_ITEM_TYPE_LABELS } from '../constants/status.js' import { computeGateGraphLayout, GATE_NODE_HEIGHT, GATE_NODE_WIDTH } from '../plan/gateGraphLayout.js' import { applyManualGatePositions, clampDesignerZoom, clearManualGatePositions, DESIGNER_ZOOM_STEP, loadManualGatePositions, storeManualGatePositions, } from '../plan/gateGraphDesigner.js' import { DESIGNER_EDGE_KINDS, DESIGNER_EDGE_LABELS, edgeKindNeedsGroupKey, resolveDependencyEndpoints } from '../plan/gateGraphTopology.js' import { LoadingState } from './LoadingState.jsx' import { ErrorState } from './ErrorState.jsx' import { EmptyState } from './EmptyState.jsx' import { Modal } from './Modal.jsx' import { RoadmapItemForm } from './RoadmapItemForm.jsx' import { GateDesignerNodePanel } from './GateDesignerNodePanel.jsx' 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)}…` } function clientToGraphPoint(rect, pan, zoom, clientX, clientY) { return { x: (clientX - rect.left - pan.x) / zoom, y: (clientY - rect.top - pan.y) / zoom, } } function nodeAtPoint(nodes, point) { for (let i = nodes.length - 1; i >= 0; i -= 1) { const node = nodes[i] if ( point.x >= node.x && point.x <= node.x + node.width && point.y >= node.y && point.y <= node.y + node.height ) { return node } } return null } export function GateGraphDesigner({ initiativeId, items, canManage = false, busy: busyProp = false, onCreate, onGraphChanged, graphState, }) { const viewportRef = useRef(null) const [dependencies, setDependencies] = useState([]) const [criteriaProgress, setCriteriaProgress] = useState({}) const [manualPositions, setManualPositions] = useState(() => loadManualGatePositions(initiativeId), ) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [busyLocal, setBusyLocal] = useState(false) const [edgeType, setEdgeType] = useState('requires') const [groupKey, setGroupKey] = useState('phase-1') const [selectedEdgeId, setSelectedEdgeId] = useState(null) const [selectedNodeId, setSelectedNodeId] = useState(null) const [showCreate, setShowCreate] = useState(false) const [createPosition, setCreatePosition] = useState(null) const [edgeDrag, setEdgeDrag] = 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 busy = busyProp || busyLocal const reloadDependencies = useCallback(async () => { if (!initiativeId) { setDependencies([]) return } const data = await listInitiativeRoadmapDependencies(initiativeId) setDependencies(Array.isArray(data) ? data : []) }, [initiativeId]) useEffect(() => { if (!initiativeId) { setDependencies([]) setCriteriaProgress({}) setLoading(false) return undefined } let cancelled = false setLoading(true) setError(null) Promise.all([ reloadDependencies(), listInitiativeRoadmapCriteriaProgress(initiativeId).catch(() => ({})), ]) .then(([_, progress]) => { if (!cancelled) setCriteriaProgress(progress || {}) }) .catch((err) => { if (!cancelled) { setError(err?.message || 'Abhängigkeiten konnten nicht geladen werden.') } }) .finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, [initiativeId, reloadDependencies, items]) useEffect(() => { setManualPositions(loadManualGatePositions(initiativeId)) setSelectedEdgeId(null) setSelectedNodeId(null) }, [initiativeId]) const baseLayout = useMemo( () => computeGateGraphLayout(items, dependencies), [items, dependencies], ) const layout = useMemo( () => applyManualGatePositions(baseLayout, manualPositions), [baseLayout, manualPositions], ) const selectedItem = useMemo( () => items.find((item) => item.id === selectedNodeId) || null, [items, selectedNodeId], ) async function notifyGraphChanged() { await reloadDependencies() onGraphChanged?.() } async function handleCreateEdge(sourceId, targetId) { if (!sourceId || !targetId || sourceId === targetId) return if (edgeKindNeedsGroupKey(edgeType) && !groupKey.trim()) { setError('Parallelgruppe benötigt einen Schlüssel (group_key).') return } setBusyLocal(true) setError(null) try { const { from_item_id: fromItemId, to_item_id: toItemId } = resolveDependencyEndpoints( edgeType, sourceId, targetId, ) const body = { to_item_id: toItemId, dependency_type: edgeType, } if (edgeKindNeedsGroupKey(edgeType)) { body.group_key = groupKey.trim() } await addRoadmapItemDependency(fromItemId, body) await notifyGraphChanged() } catch (err) { setError(err?.message || 'Kante konnte nicht angelegt werden.') } finally { setBusyLocal(false) } } const handleCreateEdgeRef = useRef(handleCreateEdge) handleCreateEdgeRef.current = handleCreateEdge async function handleDeleteEdge(edgeId) { if (!edgeId || !canManage) return setBusyLocal(true) setError(null) try { await deleteRoadmapDependency(edgeId) setSelectedEdgeId(null) await notifyGraphChanged() } catch (err) { setError(err?.message || 'Kante konnte nicht entfernt werden.') } finally { setBusyLocal(false) } } function openCreateAt(point) { setCreatePosition({ x: Math.max(0, point.x - GATE_NODE_WIDTH / 2), y: Math.max(0, point.y - GATE_NODE_HEIGHT / 2), }) setShowCreate(true) } async function handleCreateGateSubmit(payload) { if (!onCreate) return setBusyLocal(true) setError(null) try { const created = await onCreate(payload) if (created?.id && createPosition) { setManualPositions((prev) => { const next = { ...prev, [created.id]: createPosition } storeManualGatePositions(initiativeId, next) return next }) setSelectedNodeId(created.id) } setShowCreate(false) setCreatePosition(null) onGraphChanged?.() } catch (err) { setError(err?.message || 'Gate konnte nicht angelegt werden.') } finally { setBusyLocal(false) } } function handleNodeClick(nodeId, event) { event.stopPropagation() if (busy || edgeDrag) return setSelectedNodeId(nodeId) setSelectedEdgeId(null) } function handlePortPointerDown(node, event) { if (!canManage || event.button !== 0 || busy) return event.stopPropagation() const rect = viewportRef.current?.getBoundingClientRect() if (!rect) return const point = clientToGraphPoint(rect, pan, zoom, event.clientX, event.clientY) setEdgeDrag({ fromNodeId: node.id, fromX: node.x + node.width / 2, fromY: node.y + node.height, toX: point.x, toY: point.y, }) setSelectedNodeId(null) setSelectedEdgeId(null) } function handleNodePointerDown(node, event) { if (!canManage || event.button !== 0) return if (event.target.classList?.contains('gate-graph-designer__port')) 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) setSelectedNodeId(null) if (draggingNodeId || edgeDrag) return panStartRef.current = { startX: event.clientX, startY: event.clientY, panX: pan.x, panY: pan.y, } } function handleViewportDoubleClick(event) { if (!canManage || !onCreate) return const rect = viewportRef.current?.getBoundingClientRect() if (!rect) return const point = clientToGraphPoint(rect, pan, zoom, event.clientX, event.clientY) openCreateAt(point) } useEffect(() => { function handlePointerMove(event) { if (edgeDrag) { const rect = viewportRef.current?.getBoundingClientRect() if (!rect) return const point = clientToGraphPoint(rect, pan, zoom, event.clientX, event.clientY) setEdgeDrag((current) => current ? { ...current, toX: point.x, toY: point.y } : null, ) return } 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(event) { if (edgeDrag) { const rect = viewportRef.current?.getBoundingClientRect() if (rect) { const point = clientToGraphPoint(rect, pan, zoom, event.clientX, event.clientY) const targetNode = nodeAtPoint(layout.nodes, point) if (targetNode && targetNode.id !== edgeDrag.fromNodeId) { handleCreateEdgeRef.current(edgeDrag.fromNodeId, targetNode.id) } } setEdgeDrag(null) } setDraggingNodeId(null) panStartRef.current = null } window.addEventListener('pointermove', handlePointerMove) window.addEventListener('pointerup', handlePointerUp) return () => { window.removeEventListener('pointermove', handlePointerMove) window.removeEventListener('pointerup', handlePointerUp) } }, [canManage, draggingNodeId, edgeDrag, initiativeId, layout.nodes, 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) } function defaultCreatePoint() { const rect = viewportRef.current?.getBoundingClientRect() if (!rect) return { x: 80, y: 80 } return clientToGraphPoint( rect, pan, zoom, rect.left + rect.width / 2, rect.top + rect.height / 2, ) } if (loading && items.length > 0) { return } if (!items.length && !canManage) { return ( ) } if (!items.length && canManage) { return (
setShowCreate(false)}> setShowCreate(false)} busy={busy} />
) } return (

Zielzustände (Designer)

Zielzustands-Graph modellieren — Knoten verschieben, Kanten vom unteren Anker ziehen, Klick öffnet Infos. Verify und Kriterien auf der Gate-Detailseite.

{error && }
{canManage && onCreate && ( )} {canManage && ( )} {canManage && edgeKindNeedsGroupKey(edgeType) && ( )}
{Math.round(zoom * 100)}%
{canManage && selectedEdgeId && ( )} {canManage && ( )}
{canManage && (

Vom unteren Punkt ziehen: Voraussetzung/Block/Bezug/Parallel/Optional. Join-Gates verbinden mehrere Pflicht-Voraussetzungen (AND). Doppelklick auf leere Fläche legt ein Gate an.

)}
{layout.edges.map((edge) => ( { event.stopPropagation() if (canManage) { setSelectedEdgeId(edge.id) setSelectedNodeId(null) } }} /> ))} {edgeDrag && ( )} {layout.nodes.map((node) => { const item = node.item const isSelected = selectedNodeId === node.id const nodeClass = 'gate-map-view__node gate-map-view__node--' + (item.status || 'planned') + (item.item_type === 'join_gate' ? ' gate-map-view__node--join_gate' : '') + (isSelected ? ' gate-graph-designer__node--selected' : '') + (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 && ( handlePortPointerDown(node, event)} /> )} ) })}
{selectedItem && ( setSelectedNodeId(null)} /> )}
setShowCreate(false)}> setShowCreate(false)} busy={busy} />
) }