diff --git a/frontend/src/components/GateGraphDesigner.jsx b/frontend/src/components/GateGraphDesigner.jsx
new file mode 100644
index 0000000..d3222c6
--- /dev/null
+++ b/frontend/src/components/GateGraphDesigner.jsx
@@ -0,0 +1,528 @@
+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 (
+
+ Zielzustands-Graph modellieren — Pan/Zoom, Knoten verschieben, Kanten verbinden. Verify
+ und Kriterien bleiben auf der Gate-Detailseite.
+
+ {CONNECT_HINTS[edgeType] || 'Zwei Gates nacheinander anklicken.'}
+ {connectFromId ? ' — erstes Gate gewählt, zweites wählen.' : ''}
+
+ Nur Ansicht — zum Modellieren fehlt die Berechtigung „Plan verwalten“.
+
+ Doppelklick auf Gate öffnet Details. Layout-Positionen werden lokal gespeichert.
+ Zielzustände (Designer)
+
- Read-only Übersicht der Gate-Abhängigkeiten — Kanten auf der Gate-Detailseite pflegen, - Verify und Kriterien dort ebenfalls. + Read-only Übersicht der Gate-Abhängigkeiten — im Designer modellieren oder auf der + Gate-Detailseite Kanten pflegen; Verify und Kriterien dort.
- Noch keine Kanten — auf der Gate-Detailseite unter „Abhängigkeiten“ anlegen; ohne Kanten - gelten Gates nach Reihenfolge. + Noch keine Kanten — im Designer verbinden oder auf der Gate-Detailseite unter + „Abhängigkeiten“ anlegen; ohne Kanten gelten Gates nach Reihenfolge.
)} diff --git a/frontend/src/components/GatesPlanPanel.jsx b/frontend/src/components/GatesPlanPanel.jsx index 56b9774..6867b21 100644 --- a/frontend/src/components/GatesPlanPanel.jsx +++ b/frontend/src/components/GatesPlanPanel.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { RoadmapPlanSection } from './RoadmapPlanSection.jsx' import { GateMapView } from './GateMapView.jsx' +import { GateGraphDesigner } from './GateGraphDesigner.jsx' import { resolveGatesViewMode, storeGatesViewMode } from '../plan/gateGraphLayout.js' import { listInitiativeRoadmapGraphState } from '../api/roadmap.js' @@ -43,6 +44,13 @@ export function GatesPlanPanel({ storeGatesViewMode(initiativeId, mode) } + function refreshGraphState() { + if (!initiativeId) return + listInitiativeRoadmapGraphState(initiativeId) + .then((data) => setGraphState(data || null)) + .catch(() => setGraphState(null)) + } + const fulfillmentPct = graphState?.initiative_fulfillment_ratio != null ? Math.round(graphState.initiative_fulfillment_ratio * 100) @@ -87,6 +95,18 @@ export function GatesPlanPanel({ > Graph + {viewMode === 'list' ? ( @@ -100,8 +120,15 @@ export function GatesPlanPanel({ busy={busy} graphState={graphState} /> - ) : ( + ) : viewMode === 'graph' ? (