AP1.15b: Designer UX mit Drag-Kanten, Gate anlegen und Info-Panel.
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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-11 09:26:16 +02:00
parent 0c2ca4a69d
commit 364ad44759
12 changed files with 628 additions and 222 deletions

View File

@ -0,0 +1,36 @@
"""Join- und Branch-Topologie — Reservierung für AP1.15c+.
Heute blockiert `roadmap_engine.compute_initiative_graph_state` nur über
`requires` und `blocks`. `parallel_group` und `optional_branch` sind im Schema
vorhanden, werden aber noch nicht für blocked/ready ausgewertet.
Erweiterungspunkt: neue Auswertung hier kapseln, Engine ruft dann
`apply_topology_to_blocked_map(...)` auf ohne Router- oder UI-Sonderlogik.
Siehe ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md § Join/Branch deferred.
"""
from __future__ import annotations
from typing import Any
# Im Schema/API vorhanden; Engine-Logik folgt in AP1.15c
DEFERRED_TOPOLOGY_EDGE_KINDS = frozenset({"parallel_group", "optional_branch"})
# Designer AP1.15c; OR-Pfade nur nach validiertem Use Case + PO-Freigabe
PLANNED_TOPOLOGY_PACKAGES = ("AP1.15c",)
def apply_deferred_topology(
*,
dependencies: list[dict[str, Any]],
item_by_id: dict[str, dict[str, Any]],
blocked_by_map: dict[str, list[str]],
) -> dict[str, list[str]]:
"""
Erweitert blocked_by_map um parallel_group / optional_branch (AP1.15c).
Aktuell: No-Op gibt blocked_by_map unverändert zurück.
"""
_ = (dependencies, item_by_id)
return blocked_by_map

View File

@ -10,6 +10,7 @@ from steering.graph.profiles import (
get_graph_profile,
graph_profile_as_dict,
)
from steering.graph.join_branch import apply_deferred_topology
TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"})
ACTIVE_STATUSES = frozenset({"planned", "active", "at_risk"})
@ -106,6 +107,12 @@ def compute_initiative_graph_state(
if prereq_id not in blocked_by_map[item_id]:
blocked_by_map[item_id].append(prereq_id)
blocked_by_map = apply_deferred_topology(
dependencies=dependencies,
item_by_id=item_by_id,
blocked_by_map=blocked_by_map,
)
item_states: dict[str, dict[str, Any]] = {}
blocked_items: list[str] = []
ready_items: list[str] = []

View File

@ -221,6 +221,34 @@ Plan-UI: **Detail-Modal/Route** `/initiatives/:id/plan/items/:itemId` — Checkl
| **AP1.14** (vorgeschlagen) | Plan-Snapshot, Ist-Overlay, Diff, Plan-Revision mit Audit |
| **AP1.15** (vorgeschlagen) | Zielzustands-Modellierungswerkzeug (Canvas-Designer) |
| **AP1.15a** | Designer MVP: Pan/Zoom, Kanten (Klick), Layout localStorage |
| **AP1.15b** | UX: Drag-Kanten, Gate anlegen, Klick-Panel (ohne Navigation) |
| **AP1.15c** | Topologie: `parallel_group`, `optional_branch`, Join-Semantik in Engine + Designer |
---
## Join / Branch — bewusst zurückgestellt, vorbereitet (PO 2026-07-11)
**Entscheidung:** Volle Verzweigungslogik (Parallel-Join, optionale Äste, ggf. Alternativpfade/OR) wird **nicht** im aktuellen Slice implementiert. Die Praxis soll zeigen, ob OR-Pfade nötig sind (z. B. persönliche Entwicklung). Bis dahin reicht **AND-Join** über Join-Gates mit mehreren `requires`-Kanten.
**Geplant (AP1.15c+):**
| Element | Semantik | Modell |
|---------|----------|--------|
| AND-Join | Alle Voraussetzungen `reached` | Join-Gate + mehrere `requires` (**heute**) |
| Parallel | Gleichzeitige Stränge markieren | `parallel_group` + `group_key` |
| Optional | Ast zählt nur wenn „committed“ | `optional_branch` |
| OR / Alternativpfad | Eine von mehreren Voraussetzungen reicht | **Später**, nur bei validiertem Use Case |
| Split/Join (UI) | Lesbarkeit im Designer | Visuelle Hilfen — **keine** Workflow-Knoten |
**Vorbereitung der Implementierung (ab jetzt):**
- Kantentypen bleiben in `roadmap_item_dependencies.dependency_type`**keine** Joint-Tabelle.
- Graph Engine: Blocking-Logik bleibt in `backend/steering/graph/`; Erweiterung über `join_branch.py` (Reservierung AP1.15c).
- Designer: nur `requires` / `blocks` / `related` bis AP1.15c; deferred Typen in `gateGraphTopology.js`.
- Ist-Read-Models (`blocked`, `ready`) bleiben **getrennt** vom Plan-Designer (Steuerung vs. Modellierung).
**Guardrail unverändert:** Zielzustands-Graph — kein Workflow, kein OR-Knoten ohne PO-Freigabe.
---

View File

@ -105,8 +105,8 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Abhängigkeiten | ◐ | API; Graph Engine AP1.4d |
| parallel vs. sequenziell | ◐ | `sequencing_mode` (deprecated); Graph AP1.4d |
| Plan-Graph vs. Ist-Overlay | ✗ | AP1.13: ein Graph + Status am Knoten — Plan/Ist vermischt |
| Zielzustands-Designer (Joint/Parallel) | ✗ | AP1.4d + AP1.15; AP1.13 nur Auto-Layout |
| Plan-Revision / Graph-Historie | ✗ | AP1.14 vorgeschlagen |
| Zielzustands-Designer (Joint/Parallel) | ◐ | AP1.15a Designer MVP; Join/Branch → AP1.15c (deferred, vorbereitet) |
| Plan-Revision / Graph-Historie | ◐ | AP1.14 Plan-Snapshots |
| Verify vor `reached` | ◐ | Kriterienplan + gate_override |
| Reopen nach `reached` | ◐ | `active`, Kriterien bleiben AP1.4b |
| Decision bei `moved` | ✗ | |

View File

@ -243,6 +243,9 @@ AP1.13b Kanten-CRUD auf Gate-Detail (kein Designer)
AP1.4d Graph Engine: parallel_group, edge_kind, blocked/ready
AP1.14 Plan-Snapshot / Ist-Overlay / Plan-Revision (vorgeschlagen)
AP1.15 Zielzustands-Modellierungswerkzeug (Designer, Mitai-Pattern)
AP1.15a Designer MVP (Pan/Zoom, Kanten, Layout)
AP1.15b Designer UX (Drag-Kanten, Gate anlegen, Klick-Panel)
AP1.15c Join/Branch-Topologie (parallel_group, optional_branch, Engine) — deferred, vorbereitet
AP1.5d Tasks in Outline-Knoten „Arbeit“
AP1.10 Archetyp + EFS für Initiative-Profil-Modal (parallel ab 10a)
```

View File

@ -0,0 +1,83 @@
import { Link } from 'react-router-dom'
import { gatePath } from '../utils/routes.js'
import {
MILESTONE_STATUS_LABELS,
ROADMAP_ITEM_TYPE_LABELS,
} from '../constants/status.js'
import { StatusBadge } from './StatusBadge.jsx'
function formatDate(value) {
if (!value) return null
try {
return new Date(value.includes('T') ? value : `${value}T12:00:00`).toLocaleDateString(
'de-DE',
)
} catch {
return value
}
}
export function GateDesignerNodePanel({
item,
criteriaProgress,
onClose,
}) {
if (!item) return null
const progress = criteriaProgress?.[item.id]
const closed = progress?.closed ?? 0
const total = progress?.total ?? 0
return (
<aside className="gate-designer-node-panel" aria-label="Gate-Informationen">
<div className="gate-designer-node-panel__header">
<h3 className="gate-designer-node-panel__title">{item.title || 'Gate'}</h3>
<button
type="button"
className="btn btn-secondary btn-sm"
aria-label="Panel schließen"
onClick={onClose}
>
×
</button>
</div>
<dl className="gate-designer-node-panel__meta">
<div>
<dt>Typ</dt>
<dd>{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type || 'Gate'}</dd>
</div>
<div>
<dt>Status</dt>
<dd>
<StatusBadge status={item.status || 'planned'} />
</dd>
</div>
{item.target_date && (
<div>
<dt>Zieltermin</dt>
<dd>{formatDate(item.target_date)}</dd>
</div>
)}
{total > 0 && (
<div>
<dt>Kriterien</dt>
<dd>
{closed}/{total} erfüllt
</dd>
</div>
)}
</dl>
{item.goal_description && (
<p className="gate-designer-node-panel__goal muted">{item.goal_description}</p>
)}
<div className="gate-designer-node-panel__actions">
<Link to={gatePath(item.id)} className="btn btn-secondary btn-sm">
Vollständig bearbeiten
</Link>
</div>
</aside>
)
}

View File

@ -1,13 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
addRoadmapItemDependency,
deleteRoadmapDependency,
listInitiativeRoadmapCriteriaProgress,
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 { computeGateGraphLayout, GATE_NODE_HEIGHT, GATE_NODE_WIDTH } from '../plan/gateGraphLayout.js'
import {
applyManualGatePositions,
clampDesignerZoom,
@ -16,21 +15,13 @@ import {
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'
const EDGE_TYPE_LABELS = {
requires: 'Voraussetzung',
blocks: 'Blockiert',
related: 'Bezug',
}
const CONNECT_HINTS = {
requires: 'Schritt 1: Voraussetzung wählen · Schritt 2: abhängiges Gate',
blocks: 'Schritt 1: Blocker wählen · Schritt 2: geblocktes Gate',
related: 'Schritt 1: erstes Gate · Schritt 2: zweites Gate',
}
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
@ -42,13 +33,6 @@ function truncateTitle(title, max = 28) {
return `${title.slice(0, max - 1)}`
}
/**
* @param {DOMRect} rect
* @param {{ x: number, y: number }} pan
* @param {number} zoom
* @param {number} clientX
* @param {number} clientY
*/
function clientToGraphPoint(rect, pan, zoom, clientX, clientY) {
return {
x: (clientX - rect.left - pan.x) / zoom,
@ -56,30 +40,50 @@ function clientToGraphPoint(rect, pan, zoom, clientX, clientY) {
}
}
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 navigate = useNavigate()
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 [busy, setBusy] = useState(false)
const [tool, setTool] = useState('pan')
const [busyLocal, setBusyLocal] = useState(false)
const [edgeType, setEdgeType] = useState('requires')
const [connectFromId, setConnectFromId] = useState(null)
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) {
@ -93,13 +97,20 @@ export function GateGraphDesigner({
useEffect(() => {
if (!initiativeId) {
setDependencies([])
setCriteriaProgress({})
setLoading(false)
return undefined
}
let cancelled = false
setLoading(true)
setError(null)
reloadDependencies()
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.')
@ -111,12 +122,12 @@ export function GateGraphDesigner({
return () => {
cancelled = true
}
}, [initiativeId, reloadDependencies])
}, [initiativeId, reloadDependencies, items])
useEffect(() => {
setManualPositions(loadManualGatePositions(initiativeId))
setConnectFromId(null)
setSelectedEdgeId(null)
setSelectedNodeId(null)
}, [initiativeId])
const baseLayout = useMemo(
@ -129,6 +140,11 @@ export function GateGraphDesigner({
[baseLayout, manualPositions],
)
const selectedItem = useMemo(
() => items.find((item) => item.id === selectedNodeId) || null,
[items, selectedNodeId],
)
async function notifyGraphChanged() {
await reloadDependencies()
onGraphChanged?.()
@ -136,7 +152,7 @@ export function GateGraphDesigner({
async function handleCreateEdge(fromId, toId) {
if (!fromId || !toId || fromId === toId) return
setBusy(true)
setBusyLocal(true)
setError(null)
try {
let fromItemId = fromId
@ -149,18 +165,20 @@ export function GateGraphDesigner({
to_item_id: toItemId,
dependency_type: edgeType,
})
setConnectFromId(null)
await notifyGraphChanged()
} catch (err) {
setError(err?.message || 'Kante konnte nicht angelegt werden.')
} finally {
setBusy(false)
setBusyLocal(false)
}
}
const handleCreateEdgeRef = useRef(handleCreateEdge)
handleCreateEdgeRef.current = handleCreateEdge
async function handleDeleteEdge(edgeId) {
if (!edgeId || !canManage) return
setBusy(true)
setBusyLocal(true)
setError(null)
try {
await deleteRoadmapDependency(edgeId)
@ -169,35 +187,69 @@ export function GateGraphDesigner({
} catch (err) {
setError(err?.message || 'Kante konnte nicht entfernt werden.')
} finally {
setBusy(false)
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) return
if (busy || edgeDrag) return
setSelectedNodeId(nodeId)
setSelectedEdgeId(null)
}
if (tool === 'connect' && canManage) {
if (!connectFromId) {
setConnectFromId(nodeId)
setSelectedEdgeId(null)
return
}
if (connectFromId === nodeId) {
setConnectFromId(null)
return
}
handleCreateEdge(connectFromId, nodeId)
return
}
if (event.detail === 2 && tool === 'pan') {
navigate(gatePath(nodeId))
}
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 || tool !== 'pan' || event.button !== 0) return
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
@ -214,11 +266,9 @@ export function GateGraphDesigner({
if (target.classList?.contains('gate-map-view__edge')) return
setSelectedEdgeId(null)
if (tool === 'connect') {
setConnectFromId(null)
return
}
if (tool !== 'pan' || draggingNodeId) return
setSelectedNodeId(null)
if (draggingNodeId || edgeDrag) return
panStartRef.current = {
startX: event.clientX,
@ -228,8 +278,26 @@ export function GateGraphDesigner({
}
}
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
@ -254,7 +322,18 @@ export function GateGraphDesigner({
}
}
function handlePointerUp() {
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
}
@ -265,7 +344,7 @@ export function GateGraphDesigner({
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
}
}, [canManage, draggingNodeId, initiativeId, pan, zoom])
}, [canManage, draggingNodeId, edgeDrag, initiativeId, layout.nodes, pan, zoom])
function handleWheel(event) {
event.preventDefault()
@ -280,14 +359,54 @@ export function GateGraphDesigner({
setZoom(1)
}
if (!items.length) {
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 (loading) {
return <LoadingState message="Zielzustands-Designer wird geladen …" />
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 (
@ -296,8 +415,8 @@ export function GateGraphDesigner({
<div>
<h2>Zielzustände (Designer)</h2>
<p className="section-lead muted">
Zielzustands-Graph modellieren Pan/Zoom, Knoten verschieben, Kanten verbinden. Verify
und Kriterien bleiben auf der Gate-Detailseite.
Zielzustands-Graph modellieren Knoten verschieben, Kanten vom unteren Anker ziehen,
Klick öffnet Infos. Verify und Kriterien auf der Gate-Detailseite.
</p>
</div>
</div>
@ -305,55 +424,24 @@ export function GateGraphDesigner({
{error && <ErrorState message={error} />}
<div className="gate-graph-designer__toolbar">
<div className="gate-graph-designer__tool-group" role="group" aria-label="Werkzeug">
{canManage && onCreate && (
<button
type="button"
className={
'btn btn-secondary btn-sm' +
(tool === 'pan' ? ' gate-graph-designer__tool--active' : '')
}
aria-pressed={tool === 'pan'}
onClick={() => {
setTool('pan')
setConnectFromId(null)
}}
className="btn btn-primary btn-sm"
disabled={busy}
onClick={() => openCreateAt(defaultCreatePoint())}
>
Verschieben
Gate anlegen
</button>
{canManage && (
<button
type="button"
className={
'btn btn-secondary btn-sm' +
(tool === 'connect' ? ' gate-graph-designer__tool--active' : '')
}
aria-pressed={tool === 'connect'}
disabled={busy}
onClick={() => {
setTool('connect')
setConnectFromId(null)
setSelectedEdgeId(null)
}}
>
Verbinden
</button>
)}
</div>
)}
{canManage && tool === 'connect' && (
{canManage && (
<label className="gate-graph-designer__edge-type">
<span className="muted">Kantentyp</span>
<select
value={edgeType}
disabled={busy}
onChange={(event) => {
setEdgeType(event.target.value)
setConnectFromId(null)
}}
>
{Object.entries(EDGE_TYPE_LABELS).map(([value, label]) => (
<select value={edgeType} disabled={busy} onChange={(e) => setEdgeType(e.target.value)}>
{DESIGNER_EDGE_KINDS.map((value) => (
<option key={value} value={value}>
{label}
{DESIGNER_EDGE_LABELS[value]}
</option>
))}
</select>
@ -365,7 +453,7 @@ export function GateGraphDesigner({
type="button"
className="btn btn-secondary btn-sm"
aria-label="Verkleinern"
onClick={() => setZoom((current) => clampDesignerZoom(current - DESIGNER_ZOOM_STEP))}
onClick={() => setZoom((c) => clampDesignerZoom(c - DESIGNER_ZOOM_STEP))}
>
</button>
@ -374,7 +462,7 @@ export function GateGraphDesigner({
type="button"
className="btn btn-secondary btn-sm"
aria-label="Vergrößern"
onClick={() => setZoom((current) => clampDesignerZoom(current + DESIGNER_ZOOM_STEP))}
onClick={() => setZoom((c) => clampDesignerZoom(c + DESIGNER_ZOOM_STEP))}
>
+
</button>
@ -403,126 +491,155 @@ export function GateGraphDesigner({
)}
</div>
{tool === 'connect' && canManage && (
{canManage && (
<p className="gate-graph-designer__hint muted">
{CONNECT_HINTS[edgeType] || 'Zwei Gates nacheinander anklicken.'}
{connectFromId ? ' — erstes Gate gewählt, zweites wählen.' : ''}
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>
)}
{!canManage && (
<p className="gate-graph-designer__hint muted">
Nur Ansicht zum Modellieren fehlt die Berechtigung Plan verwalten.
</p>
)}
<div
ref={viewportRef}
className="gate-graph-designer__viewport"
onPointerDown={handleViewportPointerDown}
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',
}}
<div className="gate-graph-designer__main">
<div
ref={viewportRef}
className="gate-graph-designer__viewport"
onPointerDown={handleViewportPointerDown}
onDoubleClick={handleViewportDoubleClick}
onWheel={handleWheel}
>
<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)
}}
/>
))}
{layout.nodes.map((node) => {
const item = node.item
const isConnectSource = connectFromId === node.id
const nodeClass =
'gate-map-view__node gate-map-view__node--' +
(item.status || 'planned') +
(isConnectSource ? ' gate-graph-designer__node--connect-source' : '') +
(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)}
<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"
>
<rect
className="gate-map-view__node-box"
width={node.width}
height={node.height}
rx="8"
ry="8"
<path
d="M0,0 L8,4 L0,8 Z"
className="gate-map-view__arrow gate-map-view__arrow--requires"
/>
<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>
</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>
{canManage && (
<p className="muted gate-graph-designer__footer-hint">
Doppelklick auf Gate öffnet Details. Layout-Positionen werden lokal gespeichert.
</p>
)}
<Modal open={showCreate} title="Zielzustand anlegen" onClose={() => setShowCreate(false)}>
<RoadmapItemForm
onSubmit={handleCreateGateSubmit}
onCancel={() => setShowCreate(false)}
busy={busy}
/>
</Modal>
</section>
)
}

View File

@ -127,6 +127,8 @@ export function GatesPlanPanel({
initiativeId={initiativeId}
items={items}
canManage={canManage}
busy={busy}
onCreate={canManage ? onCreate : undefined}
onGraphChanged={refreshGraphState}
/>
)}

View File

@ -414,10 +414,12 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
async function handleCreateRoadmapItem(body) {
setFormBusy(true)
try {
await createInitiativeRoadmapItem(id, body)
const created = await createInitiativeRoadmapItem(id, body)
await load()
return created
} catch (err) {
setError(err.message)
throw err
} finally {
setFormBusy(false)
}

View File

@ -0,0 +1,18 @@
/**
* Kantentypen für Zielzustands-Graph AP1.15c erweitert um Topologie.
* Siehe ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md § Join/Branch deferred.
*/
/** Im Designer AP1.15a/b nutzbar */
export const DESIGNER_EDGE_KINDS = ['requires', 'blocks', 'related']
/** AP1.15c — parallel_group, optional_branch; ggf. OR später */
export const DEFERRED_TOPOLOGY_EDGE_KINDS = ['parallel_group', 'optional_branch']
export const DESIGNER_EDGE_LABELS = {
requires: 'Voraussetzung',
blocks: 'Blockiert',
related: 'Bezug',
parallel_group: 'Parallelgruppe',
optional_branch: 'Optional',
}

View File

@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest'
import {
DEFERRED_TOPOLOGY_EDGE_KINDS,
DESIGNER_EDGE_KINDS,
} from './gateGraphTopology.js'
describe('gateGraphTopology', () => {
it('keeps designer and deferred edge kinds separate', () => {
expect(DESIGNER_EDGE_KINDS).toContain('requires')
expect(DEFERRED_TOPOLOGY_EDGE_KINDS).toContain('parallel_group')
for (const kind of DEFERRED_TOPOLOGY_EDGE_KINDS) {
expect(DESIGNER_EDGE_KINDS).not.toContain(kind)
}
})
})

View File

@ -1874,6 +1874,101 @@
font-size: 0.875rem;
}
.gate-graph-designer__main {
display: flex;
gap: 0.75rem;
align-items: stretch;
}
.gate-graph-designer__main .gate-graph-designer__viewport {
flex: 1;
min-width: 0;
}
.gate-graph-designer__empty-actions {
display: flex;
justify-content: center;
margin-top: 1rem;
}
.gate-graph-designer__port {
fill: var(--jk-accent, #2563eb);
stroke: #fff;
stroke-width: 2;
cursor: crosshair;
}
.gate-graph-designer__edge-preview {
stroke: var(--jk-accent, #2563eb);
stroke-width: 2;
stroke-dasharray: 6 4;
pointer-events: none;
}
.gate-graph-designer__node--selected .gate-map-view__node-box {
stroke: var(--jk-accent, #2563eb);
stroke-width: 2;
}
.gate-designer-node-panel {
flex: 0 0 240px;
max-width: 280px;
padding: 0.75rem 1rem;
border: 1px solid var(--jk-border, #dde3ea);
border-radius: 8px;
background: var(--jk-surface, #fff);
align-self: flex-start;
max-height: 70vh;
overflow-y: auto;
}
.gate-designer-node-panel__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.gate-designer-node-panel__title {
margin: 0;
font-size: 1rem;
line-height: 1.3;
}
.gate-designer-node-panel__meta {
margin: 0 0 0.75rem;
font-size: 0.875rem;
}
.gate-designer-node-panel__meta div {
display: flex;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.gate-designer-node-panel__meta dt {
margin: 0;
color: var(--jk-text-secondary, #64748b);
}
.gate-designer-node-panel__meta dd {
margin: 0;
text-align: right;
}
.gate-designer-node-panel__goal {
margin: 0 0 0.75rem;
font-size: 0.875rem;
}
.gate-designer-node-panel__actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.gate-graph-designer__toolbar {
display: flex;
flex-wrap: wrap;