All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 2m11s
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 12s
DELETE fuer Roadmap-Kanten, Duplikat-Schutz beim Anlegen, GateDependenciesSection mit ausgehenden/eingehenden Kanten auf der Gate-Detailseite. Co-authored-by: Cursor <cursoragent@cursor.com>
187 lines
6.0 KiB
JavaScript
187 lines
6.0 KiB
JavaScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { listInitiativeRoadmapDependencies } 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 }) {
|
|
const navigate = useNavigate()
|
|
const [dependencies, setDependencies] = useState([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState(null)
|
|
|
|
useEffect(() => {
|
|
if (!initiativeId) {
|
|
setDependencies([])
|
|
setLoading(false)
|
|
return undefined
|
|
}
|
|
let cancelled = false
|
|
setLoading(true)
|
|
setError(null)
|
|
listInitiativeRoadmapDependencies(initiativeId)
|
|
.then((data) => {
|
|
if (!cancelled) setDependencies(Array.isArray(data) ? data : [])
|
|
})
|
|
.catch((err) => {
|
|
if (!cancelled) setError(err.message || 'Abhängigkeiten konnten nicht geladen werden.')
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setLoading(false)
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [initiativeId])
|
|
|
|
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
|
|
return (
|
|
<g
|
|
key={node.id}
|
|
className={`gate-map-view__node gate-map-view__node--${item.status || 'planned'}`}
|
|
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>
|
|
)
|
|
}
|