Kairo-Jinkendo/frontend/src/components/GateMapView.jsx
Lars 0c2ca4a69d
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 2m19s
Test Suite / lint-backend (push) Successful in 2s
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 12s
AP1.15a: Zielzustands-Designer mit Pan/Zoom und Kanten im Plan-Tab.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 08:28:15 +02:00

207 lines
7.0 KiB
JavaScript

import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { listInitiativeRoadmapDependencies, listInitiativeRoadmapGraphState } 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 { LoadingState } from './LoadingState.jsx'
import { ErrorState } from './ErrorState.jsx'
import { EmptyState } from './EmptyState.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)}`
}
export function GateMapView({ initiativeId, items, graphState: graphStateProp }) {
const navigate = useNavigate()
const [dependencies, setDependencies] = useState([])
const [graphStateLocal, setGraphStateLocal] = useState(null)
const graphState = graphStateProp ?? graphStateLocal
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
if (!initiativeId) {
setDependencies([])
if (!graphStateProp) setGraphStateLocal(null)
setLoading(false)
return undefined
}
let cancelled = false
setLoading(true)
setError(null)
const loaders = [listInitiativeRoadmapDependencies(initiativeId)]
if (!graphStateProp) {
loaders.push(listInitiativeRoadmapGraphState(initiativeId))
}
Promise.allSettled(loaders).then(([depsResult, graphResult]) => {
if (cancelled) return
if (depsResult.status === 'fulfilled') {
setDependencies(Array.isArray(depsResult.value) ? depsResult.value : [])
} else {
setError(
depsResult.reason?.message || 'Abhängigkeiten konnten nicht geladen werden.',
)
}
if (!graphStateProp && graphResult?.status === 'fulfilled') {
setGraphStateLocal(graphResult.value || null)
} else if (!graphStateProp) {
setGraphStateLocal(null)
}
setLoading(false)
})
return () => {
cancelled = true
}
}, [initiativeId, graphStateProp])
const layout = useMemo(
() => computeGateGraphLayout(items, dependencies),
[items, dependencies],
)
if (!items.length) {
return (
<EmptyState message="Noch kein Plan — strukturiere das Vorhaben in überprüfbare Roadmap-Elemente." />
)
}
if (loading) {
return <LoadingState message="Gate-Graph wird geladen …" />
}
if (error) {
return <ErrorState message={error} />
}
return (
<section className="card gate-map-view">
<div className="section-header">
<div>
<h2>Zielzustände (Graph)</h2>
<p className="section-lead muted">
Read-only Übersicht der Gate-Abhängigkeiten im Designer modellieren oder auf der
Gate-Detailseite Kanten pflegen; Verify und Kriterien dort.
</p>
</div>
</div>
<div className="gate-map-view__legend muted">
<span className="gate-map-view__legend-item">
<span className="gate-map-view__legend-line gate-map-view__legend-line--requires" />
Voraussetzung
</span>
<span className="gate-map-view__legend-item">
<span className="gate-map-view__legend-line gate-map-view__legend-line--blocks" />
Blockiert
</span>
<span className="gate-map-view__legend-item">
<span className="gate-map-view__legend-line gate-map-view__legend-line--related" />
Bezug
</span>
</div>
<div className="gate-map-view__canvas-wrap">
<svg
className="gate-map-view__canvas"
width={layout.width}
height={layout.height}
role="img"
aria-label="Gate-Abhängigkeitsgraph"
>
<defs>
<marker
id="gate-map-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-map-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}`}
markerEnd={
edge.dependency_type === 'related'
? undefined
: `url(#gate-map-arrow-${edge.dependency_type})`
}
/>
))}
{layout.nodes.map((node) => {
const item = node.item
const nodeGraph = graphState?.items?.[node.id]
const enforceBlocking = graphState?.graph_profile?.enforce_gate_blocking !== false
const nodeClass =
'gate-map-view__node gate-map-view__node--' +
(item.status || 'planned') +
(enforceBlocking && nodeGraph?.blocked ? ' gate-map-view__node--blocked' : '') +
(nodeGraph?.ready ? ' gate-map-view__node--ready' : '')
return (
<g
key={node.id}
className={nodeClass}
transform={`translate(${node.x}, ${node.y})`}
role="link"
tabIndex={0}
aria-label={`${item.title} öffnen`}
onClick={() => navigate(gatePath(node.id))}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
navigate(gatePath(node.id))
}
}}
>
<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>
</g>
)
})}
</svg>
</div>
{dependencies.length === 0 && (
<p className="muted gate-map-view__hint">
Noch keine Kanten im Designer verbinden oder auf der Gate-Detailseite unter
Abhängigkeiten anlegen; ohne Kanten gelten Gates nach Reihenfolge.
</p>
)}
</section>
)
}