Kairo-Jinkendo/frontend/src/components/GateGraphDesigner.jsx
Lars 364ad44759
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Has been cancelled
AP1.15b: Designer UX mit Drag-Kanten, Gate anlegen und Info-Panel.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 09:26:16 +02:00

646 lines
20 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 } 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,
}) {
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 [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(fromId, toId) {
if (!fromId || !toId || fromId === toId) return
setBusyLocal(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,
})
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>
)}
<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 eines Gates zur Zielkante ziehen. Doppelklick auf leere Fläche legt ein
Gate an. Klick auf Gate öffnet das Info-Panel.
</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}
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>
)
}