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 (tool === 'connect' && canManage) {
if (!connectFromId) {
setConnectFromId(nodeId)
if (busy || edgeDrag) return
setSelectedNodeId(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)
}}
>
Verschieben
</button>
{canManage && (
<button
type="button"
className={
'btn btn-secondary btn-sm' +
(tool === 'connect' ? ' gate-graph-designer__tool--active' : '')
}
aria-pressed={tool === 'connect'}
className="btn btn-primary btn-sm"
disabled={busy}
onClick={() => {
setTool('connect')
setConnectFromId(null)
setSelectedEdgeId(null)
}}
onClick={() => openCreateAt(defaultCreatePoint())}
>
Verbinden
Gate anlegen
</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,23 +491,19 @@ 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.' : ''}
</p>
)}
{!canManage && (
<p className="gate-graph-designer__hint muted">
Nur Ansicht zum Modellieren fehlt die Berechtigung Plan verwalten.
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
@ -478,18 +562,31 @@ export function GateGraphDesigner({
}
onClick={(event) => {
event.stopPropagation()
if (canManage) setSelectedEdgeId(edge.id)
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 isConnectSource = connectFromId === node.id
const isSelected = selectedNodeId === node.id
const nodeClass =
'gate-map-view__node gate-map-view__node--' +
(item.status || 'planned') +
(isConnectSource ? ' gate-graph-designer__node--connect-source' : '') +
(isSelected ? ' gate-graph-designer__node--selected' : '') +
(draggingNodeId === node.id ? ' gate-graph-designer__node--dragging' : '')
return (
<g
@ -512,17 +609,37 @@ export function GateGraphDesigner({
<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>
{canManage && (
<p className="muted gate-graph-designer__footer-hint">
Doppelklick auf Gate öffnet Details. Layout-Positionen werden lokal gespeichert.
</p>
{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>
)
}

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;