2026-07-11 08:28:15 +02:00
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
|
|
import {
|
|
|
|
|
|
addRoadmapItemDependency,
|
|
|
|
|
|
deleteRoadmapDependency,
|
2026-07-11 09:26:16 +02:00
|
|
|
|
listInitiativeRoadmapCriteriaProgress,
|
2026-07-11 08:28:15 +02:00
|
|
|
|
listInitiativeRoadmapDependencies,
|
|
|
|
|
|
} from '../api/roadmap.js'
|
|
|
|
|
|
import { ROADMAP_ITEM_TYPE_LABELS } from '../constants/status.js'
|
2026-07-11 09:26:16 +02:00
|
|
|
|
import { computeGateGraphLayout, GATE_NODE_HEIGHT, GATE_NODE_WIDTH } from '../plan/gateGraphLayout.js'
|
2026-07-11 08:28:15 +02:00
|
|
|
|
import {
|
|
|
|
|
|
applyManualGatePositions,
|
|
|
|
|
|
clampDesignerZoom,
|
|
|
|
|
|
clearManualGatePositions,
|
|
|
|
|
|
DESIGNER_ZOOM_STEP,
|
|
|
|
|
|
loadManualGatePositions,
|
|
|
|
|
|
storeManualGatePositions,
|
|
|
|
|
|
} from '../plan/gateGraphDesigner.js'
|
2026-07-11 09:26:16 +02:00
|
|
|
|
import { DESIGNER_EDGE_KINDS, DESIGNER_EDGE_LABELS } from '../plan/gateGraphTopology.js'
|
2026-07-11 08:28:15 +02:00
|
|
|
|
import { LoadingState } from './LoadingState.jsx'
|
|
|
|
|
|
import { ErrorState } from './ErrorState.jsx'
|
|
|
|
|
|
import { EmptyState } from './EmptyState.jsx'
|
2026-07-11 09:26:16 +02:00
|
|
|
|
import { Modal } from './Modal.jsx'
|
|
|
|
|
|
import { RoadmapItemForm } from './RoadmapItemForm.jsx'
|
|
|
|
|
|
import { GateDesignerNodePanel } from './GateDesignerNodePanel.jsx'
|
2026-07-11 08:28:15 +02:00
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 09:26:16 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 08:28:15 +02:00
|
|
|
|
export function GateGraphDesigner({
|
|
|
|
|
|
initiativeId,
|
|
|
|
|
|
items,
|
|
|
|
|
|
canManage = false,
|
2026-07-11 09:26:16 +02:00
|
|
|
|
busy: busyProp = false,
|
|
|
|
|
|
onCreate,
|
2026-07-11 08:28:15 +02:00
|
|
|
|
onGraphChanged,
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const viewportRef = useRef(null)
|
|
|
|
|
|
const [dependencies, setDependencies] = useState([])
|
2026-07-11 09:26:16 +02:00
|
|
|
|
const [criteriaProgress, setCriteriaProgress] = useState({})
|
2026-07-11 08:28:15 +02:00
|
|
|
|
const [manualPositions, setManualPositions] = useState(() =>
|
|
|
|
|
|
loadManualGatePositions(initiativeId),
|
|
|
|
|
|
)
|
|
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
|
|
const [error, setError] = useState(null)
|
2026-07-11 09:26:16 +02:00
|
|
|
|
const [busyLocal, setBusyLocal] = useState(false)
|
2026-07-11 08:28:15 +02:00
|
|
|
|
const [edgeType, setEdgeType] = useState('requires')
|
|
|
|
|
|
const [selectedEdgeId, setSelectedEdgeId] = useState(null)
|
2026-07-11 09:26:16 +02:00
|
|
|
|
const [selectedNodeId, setSelectedNodeId] = useState(null)
|
|
|
|
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
|
|
|
|
const [createPosition, setCreatePosition] = useState(null)
|
|
|
|
|
|
const [edgeDrag, setEdgeDrag] = useState(null)
|
2026-07-11 08:28:15 +02:00
|
|
|
|
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)
|
2026-07-11 09:26:16 +02:00
|
|
|
|
const busy = busyProp || busyLocal
|
2026-07-11 08:28:15 +02:00
|
|
|
|
|
|
|
|
|
|
const reloadDependencies = useCallback(async () => {
|
|
|
|
|
|
if (!initiativeId) {
|
|
|
|
|
|
setDependencies([])
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const data = await listInitiativeRoadmapDependencies(initiativeId)
|
|
|
|
|
|
setDependencies(Array.isArray(data) ? data : [])
|
|
|
|
|
|
}, [initiativeId])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!initiativeId) {
|
|
|
|
|
|
setDependencies([])
|
2026-07-11 09:26:16 +02:00
|
|
|
|
setCriteriaProgress({})
|
2026-07-11 08:28:15 +02:00
|
|
|
|
setLoading(false)
|
|
|
|
|
|
return undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
setError(null)
|
2026-07-11 09:26:16 +02:00
|
|
|
|
Promise.all([
|
|
|
|
|
|
reloadDependencies(),
|
|
|
|
|
|
listInitiativeRoadmapCriteriaProgress(initiativeId).catch(() => ({})),
|
|
|
|
|
|
])
|
|
|
|
|
|
.then(([_, progress]) => {
|
|
|
|
|
|
if (!cancelled) setCriteriaProgress(progress || {})
|
|
|
|
|
|
})
|
2026-07-11 08:28:15 +02:00
|
|
|
|
.catch((err) => {
|
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
|
setError(err?.message || 'Abhängigkeiten konnten nicht geladen werden.')
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.finally(() => {
|
|
|
|
|
|
if (!cancelled) setLoading(false)
|
|
|
|
|
|
})
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
2026-07-11 09:26:16 +02:00
|
|
|
|
}, [initiativeId, reloadDependencies, items])
|
2026-07-11 08:28:15 +02:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setManualPositions(loadManualGatePositions(initiativeId))
|
|
|
|
|
|
setSelectedEdgeId(null)
|
2026-07-11 09:26:16 +02:00
|
|
|
|
setSelectedNodeId(null)
|
2026-07-11 08:28:15 +02:00
|
|
|
|
}, [initiativeId])
|
|
|
|
|
|
|
|
|
|
|
|
const baseLayout = useMemo(
|
|
|
|
|
|
() => computeGateGraphLayout(items, dependencies),
|
|
|
|
|
|
[items, dependencies],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const layout = useMemo(
|
|
|
|
|
|
() => applyManualGatePositions(baseLayout, manualPositions),
|
|
|
|
|
|
[baseLayout, manualPositions],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-11 09:26:16 +02:00
|
|
|
|
const selectedItem = useMemo(
|
|
|
|
|
|
() => items.find((item) => item.id === selectedNodeId) || null,
|
|
|
|
|
|
[items, selectedNodeId],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-11 08:28:15 +02:00
|
|
|
|
async function notifyGraphChanged() {
|
|
|
|
|
|
await reloadDependencies()
|
|
|
|
|
|
onGraphChanged?.()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleCreateEdge(fromId, toId) {
|
|
|
|
|
|
if (!fromId || !toId || fromId === toId) return
|
2026-07-11 09:26:16 +02:00
|
|
|
|
setBusyLocal(true)
|
2026-07-11 08:28:15 +02:00
|
|
|
|
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,
|
|
|
|
|
|
})
|
|
|
|
|
|
await notifyGraphChanged()
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err?.message || 'Kante konnte nicht angelegt werden.')
|
|
|
|
|
|
} finally {
|
2026-07-11 09:26:16 +02:00
|
|
|
|
setBusyLocal(false)
|
2026-07-11 08:28:15 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 09:26:16 +02:00
|
|
|
|
const handleCreateEdgeRef = useRef(handleCreateEdge)
|
|
|
|
|
|
handleCreateEdgeRef.current = handleCreateEdge
|
|
|
|
|
|
|
2026-07-11 08:28:15 +02:00
|
|
|
|
async function handleDeleteEdge(edgeId) {
|
|
|
|
|
|
if (!edgeId || !canManage) return
|
2026-07-11 09:26:16 +02:00
|
|
|
|
setBusyLocal(true)
|
2026-07-11 08:28:15 +02:00
|
|
|
|
setError(null)
|
|
|
|
|
|
try {
|
|
|
|
|
|
await deleteRoadmapDependency(edgeId)
|
|
|
|
|
|
setSelectedEdgeId(null)
|
|
|
|
|
|
await notifyGraphChanged()
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err?.message || 'Kante konnte nicht entfernt werden.')
|
|
|
|
|
|
} finally {
|
2026-07-11 09:26:16 +02:00
|
|
|
|
setBusyLocal(false)
|
2026-07-11 08:28:15 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 09:26:16 +02:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-07-11 08:28:15 +02:00
|
|
|
|
|
2026-07-11 09:26:16 +02:00
|
|
|
|
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)
|
2026-07-11 08:28:15 +02:00
|
|
|
|
}
|
2026-07-11 09:26:16 +02:00
|
|
|
|
setShowCreate(false)
|
|
|
|
|
|
setCreatePosition(null)
|
|
|
|
|
|
onGraphChanged?.()
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err?.message || 'Gate konnte nicht angelegt werden.')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusyLocal(false)
|
2026-07-11 08:28:15 +02:00
|
|
|
|
}
|
2026-07-11 09:26:16 +02:00
|
|
|
|
}
|
2026-07-11 08:28:15 +02:00
|
|
|
|
|
2026-07-11 09:26:16 +02:00
|
|
|
|
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)
|
2026-07-11 08:28:15 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleNodePointerDown(node, event) {
|
2026-07-11 09:26:16 +02:00
|
|
|
|
if (!canManage || event.button !== 0) return
|
|
|
|
|
|
if (event.target.classList?.contains('gate-graph-designer__port')) return
|
2026-07-11 08:28:15 +02:00
|
|
|
|
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)
|
2026-07-11 09:26:16 +02:00
|
|
|
|
setSelectedNodeId(null)
|
|
|
|
|
|
|
|
|
|
|
|
if (draggingNodeId || edgeDrag) return
|
2026-07-11 08:28:15 +02:00
|
|
|
|
|
|
|
|
|
|
panStartRef.current = {
|
|
|
|
|
|
startX: event.clientX,
|
|
|
|
|
|
startY: event.clientY,
|
|
|
|
|
|
panX: pan.x,
|
|
|
|
|
|
panY: pan.y,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 09:26:16 +02:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 08:28:15 +02:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
function handlePointerMove(event) {
|
2026-07-11 09:26:16 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 08:28:15 +02:00
|
|
|
|
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,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 09:26:16 +02:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-07-11 08:28:15 +02:00
|
|
|
|
setDraggingNodeId(null)
|
|
|
|
|
|
panStartRef.current = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener('pointermove', handlePointerMove)
|
|
|
|
|
|
window.addEventListener('pointerup', handlePointerUp)
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
window.removeEventListener('pointermove', handlePointerMove)
|
|
|
|
|
|
window.removeEventListener('pointerup', handlePointerUp)
|
|
|
|
|
|
}
|
2026-07-11 09:26:16 +02:00
|
|
|
|
}, [canManage, draggingNodeId, edgeDrag, initiativeId, layout.nodes, pan, zoom])
|
2026-07-11 08:28:15 +02:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 09:26:16 +02:00
|
|
|
|
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 <LoadingState message="Zielzustands-Designer wird geladen …" />
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!items.length && !canManage) {
|
2026-07-11 08:28:15 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<EmptyState message="Noch kein Plan — strukturiere das Vorhaben in überprüfbare Roadmap-Elemente." />
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-11 09:26:16 +02:00
|
|
|
|
if (!items.length && canManage) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<section className="card gate-graph-designer">
|
|
|
|
|
|
<EmptyState message="Noch kein Zielzustand — lege das erste Gate an." />
|
|
|
|
|
|
<div className="gate-graph-designer__empty-actions">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-primary"
|
|
|
|
|
|
disabled={busy || !onCreate}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setCreatePosition({ x: 80, y: 80 })
|
|
|
|
|
|
setShowCreate(true)
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Gate anlegen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Modal open={showCreate} title="Zielzustand anlegen" onClose={() => setShowCreate(false)}>
|
|
|
|
|
|
<RoadmapItemForm
|
|
|
|
|
|
onSubmit={handleCreateGateSubmit}
|
|
|
|
|
|
onCancel={() => setShowCreate(false)}
|
|
|
|
|
|
busy={busy}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Modal>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)
|
2026-07-11 08:28:15 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<section className="card gate-graph-designer">
|
|
|
|
|
|
<div className="section-header">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2>Zielzustände (Designer)</h2>
|
|
|
|
|
|
<p className="section-lead muted">
|
2026-07-11 09:26:16 +02:00
|
|
|
|
Zielzustands-Graph modellieren — Knoten verschieben, Kanten vom unteren Anker ziehen,
|
|
|
|
|
|
Klick öffnet Infos. Verify und Kriterien auf der Gate-Detailseite.
|
2026-07-11 08:28:15 +02:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{error && <ErrorState message={error} />}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="gate-graph-designer__toolbar">
|
2026-07-11 09:26:16 +02:00
|
|
|
|
{canManage && onCreate && (
|
2026-07-11 08:28:15 +02:00
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
2026-07-11 09:26:16 +02:00
|
|
|
|
className="btn btn-primary btn-sm"
|
|
|
|
|
|
disabled={busy}
|
|
|
|
|
|
onClick={() => openCreateAt(defaultCreatePoint())}
|
2026-07-11 08:28:15 +02:00
|
|
|
|
>
|
2026-07-11 09:26:16 +02:00
|
|
|
|
Gate anlegen
|
2026-07-11 08:28:15 +02:00
|
|
|
|
</button>
|
2026-07-11 09:26:16 +02:00
|
|
|
|
)}
|
2026-07-11 08:28:15 +02:00
|
|
|
|
|
2026-07-11 09:26:16 +02:00
|
|
|
|
{canManage && (
|
2026-07-11 08:28:15 +02:00
|
|
|
|
<label className="gate-graph-designer__edge-type">
|
|
|
|
|
|
<span className="muted">Kantentyp</span>
|
2026-07-11 09:26:16 +02:00
|
|
|
|
<select value={edgeType} disabled={busy} onChange={(e) => setEdgeType(e.target.value)}>
|
|
|
|
|
|
{DESIGNER_EDGE_KINDS.map((value) => (
|
2026-07-11 08:28:15 +02:00
|
|
|
|
<option key={value} value={value}>
|
2026-07-11 09:26:16 +02:00
|
|
|
|
{DESIGNER_EDGE_LABELS[value]}
|
2026-07-11 08:28:15 +02:00
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="gate-graph-designer__zoom-group" role="group" aria-label="Zoom">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary btn-sm"
|
|
|
|
|
|
aria-label="Verkleinern"
|
2026-07-11 09:26:16 +02:00
|
|
|
|
onClick={() => setZoom((c) => clampDesignerZoom(c - DESIGNER_ZOOM_STEP))}
|
2026-07-11 08:28:15 +02:00
|
|
|
|
>
|
|
|
|
|
|
−
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<span className="gate-graph-designer__zoom-label muted">{Math.round(zoom * 100)}%</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary btn-sm"
|
|
|
|
|
|
aria-label="Vergrößern"
|
2026-07-11 09:26:16 +02:00
|
|
|
|
onClick={() => setZoom((c) => clampDesignerZoom(c + DESIGNER_ZOOM_STEP))}
|
2026-07-11 08:28:15 +02:00
|
|
|
|
>
|
|
|
|
|
|
+
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{canManage && selectedEdgeId && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary btn-sm"
|
|
|
|
|
|
disabled={busy}
|
|
|
|
|
|
onClick={() => handleDeleteEdge(selectedEdgeId)}
|
|
|
|
|
|
>
|
|
|
|
|
|
Kante entfernen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{canManage && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary btn-sm"
|
|
|
|
|
|
disabled={busy}
|
|
|
|
|
|
onClick={handleResetLayout}
|
|
|
|
|
|
>
|
|
|
|
|
|
Layout zurücksetzen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-07-11 09:26:16 +02:00
|
|
|
|
{canManage && (
|
2026-07-11 08:28:15 +02:00
|
|
|
|
<p className="gate-graph-designer__hint muted">
|
2026-07-11 09:26:16 +02:00
|
|
|
|
Vom unteren Punkt eines Gates zur Zielkante ziehen. Doppelklick auf leere Fläche legt ein
|
|
|
|
|
|
Gate an. Klick auf Gate öffnet das Info-Panel.
|
2026-07-11 08:28:15 +02:00
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-07-11 09:26:16 +02:00
|
|
|
|
<div className="gate-graph-designer__main">
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={viewportRef}
|
|
|
|
|
|
className="gate-graph-designer__viewport"
|
|
|
|
|
|
onPointerDown={handleViewportPointerDown}
|
|
|
|
|
|
onDoubleClick={handleViewportDoubleClick}
|
|
|
|
|
|
onWheel={handleWheel}
|
2026-07-11 08:28:15 +02:00
|
|
|
|
>
|
2026-07-11 09:26:16 +02:00
|
|
|
|
<svg
|
|
|
|
|
|
className="gate-graph-designer__canvas"
|
|
|
|
|
|
width={layout.width}
|
|
|
|
|
|
height={layout.height}
|
|
|
|
|
|
role="img"
|
|
|
|
|
|
aria-label="Zielzustands-Designer"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
|
|
|
|
|
transformOrigin: '0 0',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<defs>
|
|
|
|
|
|
<marker
|
|
|
|
|
|
id="gate-designer-arrow-requires"
|
|
|
|
|
|
markerWidth="8"
|
|
|
|
|
|
markerHeight="8"
|
|
|
|
|
|
refX="6"
|
|
|
|
|
|
refY="4"
|
|
|
|
|
|
orient="auto"
|
|
|
|
|
|
>
|
|
|
|
|
|
<path
|
|
|
|
|
|
d="M0,0 L8,4 L0,8 Z"
|
|
|
|
|
|
className="gate-map-view__arrow gate-map-view__arrow--requires"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</marker>
|
|
|
|
|
|
<marker
|
|
|
|
|
|
id="gate-designer-arrow-blocks"
|
|
|
|
|
|
markerWidth="8"
|
|
|
|
|
|
markerHeight="8"
|
|
|
|
|
|
refX="6"
|
|
|
|
|
|
refY="4"
|
|
|
|
|
|
orient="auto"
|
|
|
|
|
|
>
|
|
|
|
|
|
<path
|
|
|
|
|
|
d="M0,0 L8,4 L0,8 Z"
|
|
|
|
|
|
className="gate-map-view__arrow gate-map-view__arrow--blocks"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</marker>
|
|
|
|
|
|
</defs>
|
|
|
|
|
|
|
|
|
|
|
|
{layout.edges.map((edge) => (
|
2026-07-11 08:28:15 +02:00
|
|
|
|
<path
|
2026-07-11 09:26:16 +02:00
|
|
|
|
key={edge.id}
|
|
|
|
|
|
d={edgePath(edge)}
|
|
|
|
|
|
className={
|
|
|
|
|
|
'gate-map-view__edge gate-map-view__edge--' +
|
|
|
|
|
|
edge.dependency_type +
|
|
|
|
|
|
(selectedEdgeId === edge.id ? ' gate-graph-designer__edge--selected' : '')
|
|
|
|
|
|
}
|
|
|
|
|
|
markerEnd={
|
|
|
|
|
|
edge.dependency_type === 'related'
|
|
|
|
|
|
? undefined
|
|
|
|
|
|
: `url(#gate-designer-arrow-${edge.dependency_type})`
|
|
|
|
|
|
}
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
event.stopPropagation()
|
|
|
|
|
|
if (canManage) {
|
|
|
|
|
|
setSelectedEdgeId(edge.id)
|
|
|
|
|
|
setSelectedNodeId(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
2026-07-11 08:28:15 +02:00
|
|
|
|
/>
|
2026-07-11 09:26:16 +02:00
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
|
|
{edgeDrag && (
|
|
|
|
|
|
<line
|
|
|
|
|
|
className="gate-graph-designer__edge-preview"
|
|
|
|
|
|
x1={edgeDrag.fromX}
|
|
|
|
|
|
y1={edgeDrag.fromY}
|
|
|
|
|
|
x2={edgeDrag.toX}
|
|
|
|
|
|
y2={edgeDrag.toY}
|
2026-07-11 08:28:15 +02:00
|
|
|
|
/>
|
2026-07-11 09:26:16 +02:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{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') +
|
|
|
|
|
|
(isSelected ? ' gate-graph-designer__node--selected' : '') +
|
|
|
|
|
|
(draggingNodeId === node.id ? ' gate-graph-designer__node--dragging' : '')
|
|
|
|
|
|
return (
|
|
|
|
|
|
<g
|
|
|
|
|
|
key={node.id}
|
|
|
|
|
|
className={nodeClass}
|
|
|
|
|
|
transform={`translate(${node.x}, ${node.y})`}
|
|
|
|
|
|
onPointerDown={(event) => handleNodePointerDown(node, event)}
|
|
|
|
|
|
onClick={(event) => handleNodeClick(node.id, event)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<rect
|
|
|
|
|
|
className="gate-map-view__node-box"
|
|
|
|
|
|
width={node.width}
|
|
|
|
|
|
height={node.height}
|
|
|
|
|
|
rx="8"
|
|
|
|
|
|
ry="8"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<text className="gate-map-view__node-title" x="12" y="28">
|
|
|
|
|
|
{truncateTitle(item.title)}
|
|
|
|
|
|
</text>
|
|
|
|
|
|
<text className="gate-map-view__node-meta" x="12" y="50">
|
|
|
|
|
|
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type || 'Gate'}
|
|
|
|
|
|
</text>
|
|
|
|
|
|
{canManage && (
|
|
|
|
|
|
<circle
|
|
|
|
|
|
className="gate-graph-designer__port"
|
|
|
|
|
|
cx={node.width / 2}
|
|
|
|
|
|
cy={node.height}
|
|
|
|
|
|
r="7"
|
|
|
|
|
|
onPointerDown={(event) => handlePortPointerDown(node, event)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</g>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{selectedItem && (
|
|
|
|
|
|
<GateDesignerNodePanel
|
|
|
|
|
|
item={selectedItem}
|
|
|
|
|
|
criteriaProgress={criteriaProgress}
|
|
|
|
|
|
onClose={() => setSelectedNodeId(null)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-07-11 08:28:15 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-07-11 09:26:16 +02:00
|
|
|
|
<Modal open={showCreate} title="Zielzustand anlegen" onClose={() => setShowCreate(false)}>
|
|
|
|
|
|
<RoadmapItemForm
|
|
|
|
|
|
onSubmit={handleCreateGateSubmit}
|
|
|
|
|
|
onCancel={() => setShowCreate(false)}
|
|
|
|
|
|
busy={busy}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Modal>
|
2026-07-11 08:28:15 +02:00
|
|
|
|
</section>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|