diff --git a/backend/steering/graph/join_branch.py b/backend/steering/graph/join_branch.py new file mode 100644 index 0000000..b2b5ac6 --- /dev/null +++ b/backend/steering/graph/join_branch.py @@ -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 diff --git a/backend/steering/graph/roadmap_engine.py b/backend/steering/graph/roadmap_engine.py index 16cd8ee..6aab5d0 100644 --- a/backend/steering/graph/roadmap_engine.py +++ b/backend/steering/graph/roadmap_engine.py @@ -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] = [] diff --git a/docs/architecture/ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md b/docs/architecture/ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md index 94e5ab1..fa93f20 100644 --- a/docs/architecture/ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md +++ b/docs/architecture/ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md @@ -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. --- diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index dfe4d83..1175161 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -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` | ✗ | | diff --git a/docs/product/Kairo_Plan_Mode_Design_v0.1.md b/docs/product/Kairo_Plan_Mode_Design_v0.1.md index f673a78..c4b510f 100644 --- a/docs/product/Kairo_Plan_Mode_Design_v0.1.md +++ b/docs/product/Kairo_Plan_Mode_Design_v0.1.md @@ -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) ``` diff --git a/frontend/src/components/GateDesignerNodePanel.jsx b/frontend/src/components/GateDesignerNodePanel.jsx new file mode 100644 index 0000000..5d87a33 --- /dev/null +++ b/frontend/src/components/GateDesignerNodePanel.jsx @@ -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 ( + + ) +} diff --git a/frontend/src/components/GateGraphDesigner.jsx b/frontend/src/components/GateGraphDesigner.jsx index d3222c6..4b1a237 100644 --- a/frontend/src/components/GateGraphDesigner.jsx +++ b/frontend/src/components/GateGraphDesigner.jsx @@ -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 + } + + if (!items.length && !canManage) { return ( ) } - if (loading) { - return + if (!items.length && canManage) { + return ( +
+ +
+ +
+ setShowCreate(false)}> + setShowCreate(false)} + busy={busy} + /> + +
+ ) } return ( @@ -296,8 +415,8 @@ export function GateGraphDesigner({

Zielzustände (Designer)

- 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.

@@ -305,55 +424,24 @@ export function GateGraphDesigner({ {error && }
-
+ {canManage && onCreate && ( - {canManage && ( - - )} -
+ )} - {canManage && tool === 'connect' && ( + {canManage && (
- {tool === 'connect' && canManage && ( + {canManage && (

- {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.

)} - {!canManage && ( -

- Nur Ansicht — zum Modellieren fehlt die Berechtigung „Plan verwalten“. -

- )} - -
- +
- - - - - - - - - - {layout.edges.map((edge) => ( - { - 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 ( - handleNodePointerDown(node, event)} - onClick={(event) => handleNodeClick(node.id, event)} + + + - - - {truncateTitle(item.title)} - - - {ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type || 'Gate'} - - - ) - })} - + + + + + + + {layout.edges.map((edge) => ( + { + event.stopPropagation() + if (canManage) { + setSelectedEdgeId(edge.id) + setSelectedNodeId(null) + } + }} + /> + ))} + + {edgeDrag && ( + + )} + + {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 ( + handleNodePointerDown(node, event)} + onClick={(event) => handleNodeClick(node.id, event)} + > + + + {truncateTitle(item.title)} + + + {ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type || 'Gate'} + + {canManage && ( + handlePortPointerDown(node, event)} + /> + )} + + ) + })} + +
+ + {selectedItem && ( + setSelectedNodeId(null)} + /> + )}
- {canManage && ( -

- Doppelklick auf Gate öffnet Details. Layout-Positionen werden lokal gespeichert. -

- )} + setShowCreate(false)}> + setShowCreate(false)} + busy={busy} + /> + ) } diff --git a/frontend/src/components/GatesPlanPanel.jsx b/frontend/src/components/GatesPlanPanel.jsx index 6867b21..f52942a 100644 --- a/frontend/src/components/GatesPlanPanel.jsx +++ b/frontend/src/components/GatesPlanPanel.jsx @@ -127,6 +127,8 @@ export function GatesPlanPanel({ initiativeId={initiativeId} items={items} canManage={canManage} + busy={busy} + onCreate={canManage ? onCreate : undefined} onGraphChanged={refreshGraphState} /> )} diff --git a/frontend/src/context/InitiativeOperationsContext.jsx b/frontend/src/context/InitiativeOperationsContext.jsx index 8795fbb..dbc107f 100644 --- a/frontend/src/context/InitiativeOperationsContext.jsx +++ b/frontend/src/context/InitiativeOperationsContext.jsx @@ -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) } diff --git a/frontend/src/plan/gateGraphTopology.js b/frontend/src/plan/gateGraphTopology.js new file mode 100644 index 0000000..cbe708d --- /dev/null +++ b/frontend/src/plan/gateGraphTopology.js @@ -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', +} diff --git a/frontend/src/plan/gateGraphTopology.test.js b/frontend/src/plan/gateGraphTopology.test.js new file mode 100644 index 0000000..83f3e6a --- /dev/null +++ b/frontend/src/plan/gateGraphTopology.test.js @@ -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) + } + }) +}) diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index dd7d404..817ed13 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -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;