Kairo-Jinkendo/frontend/src/components/GateGraphDesigner.jsx
Lars d7aefed0de
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 2m18s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 13s
AP1.15c: Join/Branch-Topologie mit parallel_group und optional_branch.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 09:27:56 +02:00

671 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 <LoadingState message="Zielzustands-Designer wird geladen …" />
}
if (!items.length && !canManage) {
return (
<EmptyState message="Noch kein Plan — strukturiere das Vorhaben in überprüfbare Roadmap-Elemente." />
)
}
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>
)
}
return (
<section className="card gate-graph-designer">
<div className="section-header">
<div>
<h2>Zielzustände (Designer)</h2>
<p className="section-lead muted">
Zielzustands-Graph modellieren Knoten verschieben, Kanten vom unteren Anker ziehen,
Klick öffnet Infos. Verify und Kriterien auf der Gate-Detailseite.
</p>
</div>
</div>
{error && <ErrorState message={error} />}
<div className="gate-graph-designer__toolbar">
{canManage && onCreate && (
<button
type="button"
className="btn btn-primary btn-sm"
disabled={busy}
onClick={() => openCreateAt(defaultCreatePoint())}
>
Gate anlegen
</button>
)}
{canManage && (
<label className="gate-graph-designer__edge-type">
<span className="muted">Kantentyp</span>
<select value={edgeType} disabled={busy} onChange={(e) => setEdgeType(e.target.value)}>
{DESIGNER_EDGE_KINDS.map((value) => (
<option key={value} value={value}>
{DESIGNER_EDGE_LABELS[value]}
</option>
))}
</select>
</label>
)}
{canManage && edgeKindNeedsGroupKey(edgeType) && (
<label className="gate-graph-designer__edge-type">
<span className="muted">Gruppenschlüssel</span>
<input
type="text"
value={groupKey}
maxLength={128}
disabled={busy}
placeholder="z. B. phase-1"
onChange={(e) => setGroupKey(e.target.value)}
/>
</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"
onClick={() => setZoom((c) => clampDesignerZoom(c - DESIGNER_ZOOM_STEP))}
>
</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"
onClick={() => setZoom((c) => clampDesignerZoom(c + DESIGNER_ZOOM_STEP))}
>
+
</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>
{canManage && (
<p className="gate-graph-designer__hint muted">
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.
</p>
)}
<div className="gate-graph-designer__main">
<div
ref={viewportRef}
className="gate-graph-designer__viewport"
onPointerDown={handleViewportPointerDown}
onDoubleClick={handleViewportDoubleClick}
onWheel={handleWheel}
>
<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) => (
<path
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)
}
}}
/>
))}
{edgeDrag && (
<line
className="gate-graph-designer__edge-preview"
x1={edgeDrag.fromX}
y1={edgeDrag.fromY}
x2={edgeDrag.toX}
y2={edgeDrag.toY}
/>
)}
{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}
graphState={graphState}
criteriaProgress={criteriaProgress}
onClose={() => setSelectedNodeId(null)}
/>
)}
</div>
<Modal open={showCreate} title="Zielzustand anlegen" onClose={() => setShowCreate(false)}>
<RoadmapItemForm
onSubmit={handleCreateGateSubmit}
onCancel={() => setShowCreate(false)}
busy={busy}
/>
</Modal>
</section>
)
}