Some checks failed
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 2m18s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
generic_operating ohne harte Blockade, Reifegrad-Methoden mit Erfuellungsgrad; Attention gate_graph_blocked fuer gate-orientierte Methoden. Co-authored-by: Cursor <cursoragent@cursor.com>
207 lines
7.0 KiB
JavaScript
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 — Kanten auf der Gate-Detailseite pflegen,
|
|
Verify und Kriterien dort ebenfalls.
|
|
</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 — auf der Gate-Detailseite unter „Abhängigkeiten“ anlegen; ohne Kanten
|
|
gelten Gates nach Reihenfolge.
|
|
</p>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|