diff --git a/backend/routers/roadmap.py b/backend/routers/roadmap.py index 8065d51..a974275 100644 --- a/backend/routers/roadmap.py +++ b/backend/routers/roadmap.py @@ -115,6 +115,19 @@ def list_initiative_roadmap_items( raise HTTPException(status_code=400, detail=str(exc)) from exc +@initiative_router.get("/{initiative_id}/roadmap/dependencies") +def list_initiative_roadmap_dependencies( + initiative_id: str, + ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), +): + try: + return roadmap_service.list_dependencies_for_initiative( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @initiative_router.post("/{initiative_id}/roadmap/items", status_code=201) def create_initiative_roadmap_item( initiative_id: str, diff --git a/backend/services/roadmap.py b/backend/services/roadmap.py index a74b1d8..036d157 100644 --- a/backend/services/roadmap.py +++ b/backend/services/roadmap.py @@ -484,6 +484,42 @@ def list_dependencies(*, tenant_id: str, item_id: str) -> list[dict[str, Any]]: conn.close() +def list_dependencies_for_initiative( + *, tenant_id: str, initiative_id: str +) -> list[dict[str, Any]]: + if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): + raise ValueError("Initiative nicht gefunden") + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT d.id, d.tenant_id, d.from_item_id, d.to_item_id, + d.dependency_type, d.created_at + FROM roadmap_item_dependencies d + INNER JOIN roadmap_items fi + ON fi.id = d.from_item_id AND fi.tenant_id = d.tenant_id + INNER JOIN roadmaps r + ON r.id = fi.roadmap_id AND r.tenant_id = fi.tenant_id + WHERE d.tenant_id = %s AND r.initiative_id = %s + ORDER BY d.created_at ASC + """, + (tenant_id, initiative_id), + ) + items = [] + for row in cur.fetchall(): + dep = dict(row) + for key in ("id", "tenant_id", "from_item_id", "to_item_id"): + dep[key] = str(dep[key]) + if dep.get("created_at"): + dep["created_at"] = dep["created_at"].isoformat() + items.append(dep) + return items + finally: + conn.close() + + def add_dependency( *, tenant_id: str, diff --git a/backend/tests/test_ap14_roadmap.py b/backend/tests/test_ap14_roadmap.py index 8f17d92..9ed63fa 100644 --- a/backend/tests/test_ap14_roadmap.py +++ b/backend/tests/test_ap14_roadmap.py @@ -120,3 +120,29 @@ def test_steering_snapshot_reads_roadmap_items(client): assert snap.get("roadmap_items") assert snap.get("upcoming_milestones") assert len(snap["upcoming_roadmap_items"]) >= 1 + + +def test_initiative_roadmap_dependencies_list(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + gate_a = _create_roadmap_item(client, token, initiative_id, title="Gate A").json() + gate_b = _create_roadmap_item(client, token, initiative_id, title="Gate B").json() + + created = client.post( + f"/api/roadmap-items/{gate_a['id']}/dependencies", + json={"to_item_id": gate_b["id"], "dependency_type": "requires"}, + headers=_auth(token), + ) + assert created.status_code == 201 + + listed = client.get( + f"/api/initiatives/{initiative_id}/roadmap/dependencies", + headers=_auth(token), + ) + assert listed.status_code == 200 + deps = listed.json() + assert len(deps) == 1 + assert deps[0]["from_item_id"] == gate_a["id"] + assert deps[0]["to_item_id"] == gate_b["id"] + assert deps[0]["dependency_type"] == "requires" diff --git a/frontend/src/api/roadmap.js b/frontend/src/api/roadmap.js index 40cabf3..4013a12 100644 --- a/frontend/src/api/roadmap.js +++ b/frontend/src/api/roadmap.js @@ -4,6 +4,10 @@ export function listInitiativeRoadmapItems(initiativeId) { return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`) } +export function listInitiativeRoadmapDependencies(initiativeId) { + return apiFetch(`/api/initiatives/${initiativeId}/roadmap/dependencies`) +} + export function createInitiativeRoadmapItem(initiativeId, body) { return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, { method: 'POST', diff --git a/frontend/src/components/GateMapView.jsx b/frontend/src/components/GateMapView.jsx new file mode 100644 index 0000000..76bf46d --- /dev/null +++ b/frontend/src/components/GateMapView.jsx @@ -0,0 +1,186 @@ +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 ( + + ) + } + + if (loading) { + return + } + + if (error) { + return + } + + return ( +
+
+
+

Zielzustände (Graph)

+

+ Read-only Übersicht der Gate-Abhängigkeiten — Bearbeitung auf der Gate-Detailseite + (AP1.13b). +

+
+
+ +
+ + + Voraussetzung + + + + Blockiert + + + + Bezug + +
+ +
+ + + + + + + + + + + {layout.edges.map((edge) => ( + + ))} + + {layout.nodes.map((node) => { + const item = node.item + return ( + navigate(gatePath(node.id))} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + navigate(gatePath(node.id)) + } + }} + > + + + {truncateTitle(item.title)} + + + {ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type || 'Gate'} + + + ) + })} + +
+ + {dependencies.length === 0 && ( +

+ Noch keine Kanten — Gates sind nach Reihenfolge angeordnet. Abhängigkeiten können auf den + Gate-Detailseiten gepflegt werden (AP1.13b). +

+ )} +
+ ) +} diff --git a/frontend/src/components/GatesPlanPanel.jsx b/frontend/src/components/GatesPlanPanel.jsx new file mode 100644 index 0000000..611e47f --- /dev/null +++ b/frontend/src/components/GatesPlanPanel.jsx @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react' +import { RoadmapPlanSection } from './RoadmapPlanSection.jsx' +import { GateMapView } from './GateMapView.jsx' +import { resolveGatesViewMode, storeGatesViewMode } from '../plan/gateGraphLayout.js' + +export function GatesPlanPanel({ + initiativeId, + items, + canManage, + onCreate, + onReorder, + onDelete, + busy, +}) { + const [viewMode, setViewMode] = useState(() => resolveGatesViewMode(initiativeId, items.length)) + + useEffect(() => { + setViewMode(resolveGatesViewMode(initiativeId, items.length)) + }, [initiativeId, items.length]) + + function handleViewChange(mode) { + setViewMode(mode) + storeGatesViewMode(initiativeId, mode) + } + + return ( +
+
+ + +
+ + {viewMode === 'list' ? ( + + ) : ( + + )} +
+ ) +} diff --git a/frontend/src/pages/initiative/InitiativePlanPage.jsx b/frontend/src/pages/initiative/InitiativePlanPage.jsx index 04fc506..38ea136 100644 --- a/frontend/src/pages/initiative/InitiativePlanPage.jsx +++ b/frontend/src/pages/initiative/InitiativePlanPage.jsx @@ -1,5 +1,5 @@ import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' -import { RoadmapPlanSection } from '../../components/RoadmapPlanSection.jsx' +import { GatesPlanPanel } from '../../components/GatesPlanPanel.jsx' export function InitiativePlanPage() { const { @@ -20,7 +20,7 @@ export function InitiativePlanPage() { return ( <> {error &&

{error}

} - + (a.sort_order ?? 0) - (b.sort_order ?? 0) || + (a.title || '').localeCompare(b.title || '') || + a.id.localeCompare(b.id), + ) + + const layoutEdges = buildLayoutEdges(dependencies) + const layerById = new Map() + + for (const item of sortedItems) { + layerById.set(item.id, 0) + } + + const maxIterations = sortedItems.length + 1 + for (let i = 0; i < maxIterations; i += 1) { + let changed = false + for (const edge of layoutEdges) { + const fromLayer = layerById.get(edge.from) ?? 0 + const toLayer = layerById.get(edge.to) ?? 0 + const nextLayer = fromLayer + 1 + if (nextLayer > toLayer) { + layerById.set(edge.to, nextLayer) + changed = true + } + } + if (!changed) break + } + + const layers = new Map() + for (const item of sortedItems) { + const layer = layerById.get(item.id) ?? 0 + if (!layers.has(layer)) layers.set(layer, []) + layers.get(layer).push(item) + } + + const layerIndices = [...layers.keys()].sort((a, b) => a - b) + const maxLayerWidth = Math.max( + ...layerIndices.map((layer) => layers.get(layer).length), + 1, + ) + + const nodes = [] + for (const layer of layerIndices) { + const row = layers.get(layer) + const rowWidth = + row.length * GATE_NODE_WIDTH + Math.max(0, row.length - 1) * GATE_NODE_GAP_X + const canvasWidth = + maxLayerWidth * GATE_NODE_WIDTH + + Math.max(0, maxLayerWidth - 1) * GATE_NODE_GAP_X + + GATE_GRAPH_PADDING * 2 + const startX = GATE_GRAPH_PADDING + Math.max(0, (canvasWidth - rowWidth) / 2 - GATE_GRAPH_PADDING) + + row.forEach((item, index) => { + nodes.push({ + id: item.id, + item, + width: GATE_NODE_WIDTH, + height: GATE_NODE_HEIGHT, + x: startX + index * (GATE_NODE_WIDTH + GATE_NODE_GAP_X), + y: GATE_GRAPH_PADDING + layer * (GATE_NODE_HEIGHT + GATE_LAYER_GAP_Y), + }) + }) + } + + const nodeById = new Map(nodes.map((node) => [node.id, node])) + const edges = dependencies + .map((dep) => { + const sourceNode = + dep.dependency_type === 'requires' + ? nodeById.get(dep.to_item_id) + : nodeById.get(dep.from_item_id) + const targetNode = + dep.dependency_type === 'requires' + ? nodeById.get(dep.from_item_id) + : nodeById.get(dep.to_item_id) + if (!sourceNode || !targetNode) return null + + const center = (node) => ({ + x: node.x + node.width / 2, + y: node.y + node.height / 2, + }) + + if (dep.dependency_type === 'related') { + const from = center(sourceNode) + const to = center(targetNode) + return { + id: dep.id || `${dep.from_item_id}-${dep.to_item_id}-${dep.dependency_type}`, + fromId: dep.from_item_id, + toId: dep.to_item_id, + dependency_type: dep.dependency_type, + x1: from.x, + y1: from.y, + x2: to.x, + y2: to.y, + } + } + + return { + id: dep.id || `${dep.from_item_id}-${dep.to_item_id}-${dep.dependency_type}`, + fromId: dep.from_item_id, + toId: dep.to_item_id, + dependency_type: dep.dependency_type, + x1: sourceNode.x + sourceNode.width / 2, + y1: sourceNode.y + sourceNode.height, + x2: targetNode.x + targetNode.width / 2, + y2: targetNode.y, + } + }) + .filter(Boolean) + + const width = + Math.max(...nodes.map((node) => node.x + node.width), GATE_NODE_WIDTH) + GATE_GRAPH_PADDING + const height = + Math.max(...nodes.map((node) => node.y + node.height), GATE_NODE_HEIGHT) + GATE_GRAPH_PADDING + + return { nodes, edges, width, height } +} + +/** + * @param {string} initiativeId + * @param {number} itemCount + * @returns {'list' | 'graph'} + */ +export function resolveGatesViewMode(initiativeId, itemCount) { + if (typeof window !== 'undefined' && initiativeId) { + const stored = window.localStorage.getItem(`kairo.plan.gates.view:${initiativeId}`) + if (stored === 'list' || stored === 'graph') { + return stored + } + } + return itemCount <= 5 ? 'list' : 'graph' +} + +/** + * @param {string} initiativeId + * @param {'list' | 'graph'} mode + */ +export function storeGatesViewMode(initiativeId, mode) { + if (typeof window !== 'undefined' && initiativeId) { + window.localStorage.setItem(`kairo.plan.gates.view:${initiativeId}`, mode) + } +} diff --git a/frontend/src/plan/gateGraphLayout.test.js b/frontend/src/plan/gateGraphLayout.test.js new file mode 100644 index 0000000..3004785 --- /dev/null +++ b/frontend/src/plan/gateGraphLayout.test.js @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { buildLayoutEdges, computeGateGraphLayout } from './gateGraphLayout.js' + +describe('gateGraphLayout', () => { + it('layers requires dependencies with prerequisite above dependent', () => { + const items = [ + { id: 'a', title: 'A', sort_order: 0 }, + { id: 'b', title: 'B', sort_order: 1 }, + ] + const dependencies = [ + { id: 'd1', from_item_id: 'a', to_item_id: 'b', dependency_type: 'requires' }, + ] + + expect(buildLayoutEdges(dependencies)).toEqual([ + { from: 'b', to: 'a', dependency_type: 'requires' }, + ]) + + const layout = computeGateGraphLayout(items, dependencies) + const nodeA = layout.nodes.find((node) => node.id === 'a') + const nodeB = layout.nodes.find((node) => node.id === 'b') + expect(nodeB.y).toBeLessThan(nodeA.y) + }) + + it('places items without dependencies on one row by sort_order', () => { + const items = [ + { id: 'b', title: 'B', sort_order: 10 }, + { id: 'a', title: 'A', sort_order: 0 }, + ] + const layout = computeGateGraphLayout(items, []) + expect(layout.nodes.map((node) => node.id)).toEqual(['a', 'b']) + expect(layout.nodes[0].y).toBe(layout.nodes[1].y) + expect(layout.nodes[0].x).toBeLessThan(layout.nodes[1].x) + }) +}) diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 8ff612c..9684f89 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -1611,3 +1611,152 @@ font-size: 0.875rem; } +.gates-plan-panel__toolbar { + display: flex; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.gates-plan-panel__tab { + border: 1px solid var(--jk-border, #dde3ea); + background: var(--jk-surface, #fff); + color: var(--jk-text-secondary, #445); + border-radius: var(--jk-radius-md, 8px); + padding: 0.4rem 0.85rem; + font-size: 0.875rem; + cursor: pointer; +} + +.gates-plan-panel__tab--active { + background: var(--jk-surface-muted, #eef2f6); + color: var(--jk-text, #1a1a1a); + border-color: var(--jk-border-strong, #c5ced8); + font-weight: 600; +} + +.gate-map-view__legend { + display: flex; + flex-wrap: wrap; + gap: 1rem 1.5rem; + margin-bottom: 0.75rem; + font-size: 0.8125rem; +} + +.gate-map-view__legend-item { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.gate-map-view__legend-line { + display: inline-block; + width: 1.75rem; + height: 0; + border-top-width: 2px; + border-top-style: solid; +} + +.gate-map-view__legend-line--requires { + border-top-color: #2563eb; +} + +.gate-map-view__legend-line--blocks { + border-top-color: #dc2626; +} + +.gate-map-view__legend-line--related { + border-top-color: #64748b; + border-top-style: dashed; +} + +.gate-map-view__canvas-wrap { + overflow: auto; + border: 1px solid var(--jk-border, #dde3ea); + border-radius: var(--jk-radius-md, 8px); + background: var(--jk-surface-subtle, #f8fafc); +} + +.gate-map-view__canvas { + display: block; + min-width: 100%; +} + +.gate-map-view__edge { + fill: none; + stroke-width: 2; +} + +.gate-map-view__edge--requires { + stroke: #2563eb; +} + +.gate-map-view__edge--blocks { + stroke: #dc2626; +} + +.gate-map-view__edge--related { + stroke: #64748b; + stroke-dasharray: 6 4; +} + +.gate-map-view__arrow--requires { + fill: #2563eb; +} + +.gate-map-view__arrow--blocks { + fill: #dc2626; +} + +.gate-map-view__node { + cursor: pointer; +} + +.gate-map-view__node-box { + fill: #fff; + stroke: var(--jk-border-strong, #c5ced8); + stroke-width: 1.5; +} + +.gate-map-view__node--active .gate-map-view__node-box, +.gate-map-view__node--planned .gate-map-view__node-box { + stroke: #2563eb; +} + +.gate-map-view__node--at_risk .gate-map-view__node-box { + stroke: #d97706; +} + +.gate-map-view__node--reached .gate-map-view__node-box { + stroke: #16a34a; + fill: #f0fdf4; +} + +.gate-map-view__node--discarded .gate-map-view__node-box, +.gate-map-view__node--moved .gate-map-view__node-box { + stroke: #94a3b8; + fill: #f8fafc; +} + +.gate-map-view__node-title { + fill: var(--jk-text, #1a1a1a); + font-size: 14px; + font-weight: 600; + pointer-events: none; +} + +.gate-map-view__node-meta { + fill: var(--jk-text-secondary, #445); + font-size: 12px; + pointer-events: none; +} + +.gate-map-view__node:focus-visible .gate-map-view__node-box { + stroke: #2563eb; + stroke-width: 2.5; +} + +.gate-map-view__hint { + margin-top: 0.75rem; + font-size: 0.875rem; +} +