From 0c2ca4a69d203fc7bf982808c41c0737d00200ae Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Jul 2026 08:28:15 +0200 Subject: [PATCH] AP1.15a: Zielzustands-Designer mit Pan/Zoom und Kanten im Plan-Tab. Co-authored-by: Cursor --- frontend/src/components/GateGraphDesigner.jsx | 528 ++++++++++++++++++ frontend/src/components/GateMapView.jsx | 8 +- frontend/src/components/GatesPlanPanel.jsx | 29 +- frontend/src/plan/gateGraphDesigner.js | 100 ++++ frontend/src/plan/gateGraphDesigner.test.js | 28 + frontend/src/plan/gateGraphLayout.js | 6 +- frontend/src/styles/components.css | 79 +++ 7 files changed, 770 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/GateGraphDesigner.jsx create mode 100644 frontend/src/plan/gateGraphDesigner.js create mode 100644 frontend/src/plan/gateGraphDesigner.test.js diff --git a/frontend/src/components/GateGraphDesigner.jsx b/frontend/src/components/GateGraphDesigner.jsx new file mode 100644 index 0000000..d3222c6 --- /dev/null +++ b/frontend/src/components/GateGraphDesigner.jsx @@ -0,0 +1,528 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { + addRoadmapItemDependency, + deleteRoadmapDependency, + 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 { + applyManualGatePositions, + clampDesignerZoom, + clearManualGatePositions, + DESIGNER_ZOOM_STEP, + loadManualGatePositions, + storeManualGatePositions, +} from '../plan/gateGraphDesigner.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', +} + +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)}…` +} + +/** + * @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, + y: (clientY - rect.top - pan.y) / zoom, + } +} + +export function GateGraphDesigner({ + initiativeId, + items, + canManage = false, + onGraphChanged, +}) { + const navigate = useNavigate() + const viewportRef = useRef(null) + const [dependencies, setDependencies] = 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 [edgeType, setEdgeType] = useState('requires') + const [connectFromId, setConnectFromId] = useState(null) + const [selectedEdgeId, setSelectedEdgeId] = 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 reloadDependencies = useCallback(async () => { + if (!initiativeId) { + setDependencies([]) + return + } + const data = await listInitiativeRoadmapDependencies(initiativeId) + setDependencies(Array.isArray(data) ? data : []) + }, [initiativeId]) + + useEffect(() => { + if (!initiativeId) { + setDependencies([]) + setLoading(false) + return undefined + } + let cancelled = false + setLoading(true) + setError(null) + reloadDependencies() + .catch((err) => { + if (!cancelled) { + setError(err?.message || 'Abhängigkeiten konnten nicht geladen werden.') + } + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [initiativeId, reloadDependencies]) + + useEffect(() => { + setManualPositions(loadManualGatePositions(initiativeId)) + setConnectFromId(null) + setSelectedEdgeId(null) + }, [initiativeId]) + + const baseLayout = useMemo( + () => computeGateGraphLayout(items, dependencies), + [items, dependencies], + ) + + const layout = useMemo( + () => applyManualGatePositions(baseLayout, manualPositions), + [baseLayout, manualPositions], + ) + + async function notifyGraphChanged() { + await reloadDependencies() + onGraphChanged?.() + } + + async function handleCreateEdge(fromId, toId) { + if (!fromId || !toId || fromId === toId) return + setBusy(true) + setError(null) + try { + let fromItemId = fromId + let toItemId = toId + if (edgeType === 'requires') { + fromItemId = toId + toItemId = fromId + } + await addRoadmapItemDependency(fromItemId, { + to_item_id: toItemId, + dependency_type: edgeType, + }) + setConnectFromId(null) + await notifyGraphChanged() + } catch (err) { + setError(err?.message || 'Kante konnte nicht angelegt werden.') + } finally { + setBusy(false) + } + } + + async function handleDeleteEdge(edgeId) { + if (!edgeId || !canManage) return + setBusy(true) + setError(null) + try { + await deleteRoadmapDependency(edgeId) + setSelectedEdgeId(null) + await notifyGraphChanged() + } catch (err) { + setError(err?.message || 'Kante konnte nicht entfernt werden.') + } finally { + setBusy(false) + } + } + + function handleNodeClick(nodeId, event) { + event.stopPropagation() + if (busy) return + + 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 handleNodePointerDown(node, event) { + if (!canManage || tool !== 'pan' || event.button !== 0) return + event.stopPropagation() + const rect = viewportRef.current?.getBoundingClientRect() + if (!rect) return + const point = clientToGraphPoint(rect, pan, zoom, event.clientX, event.clientY) + dragOffsetRef.current = { x: point.x - node.x, y: point.y - node.y } + setDraggingNodeId(node.id) + setSelectedEdgeId(null) + } + + function handleViewportPointerDown(event) { + if (event.button !== 0) return + const target = event.target + if (target.closest?.('.gate-map-view__node')) return + if (target.classList?.contains('gate-map-view__edge')) return + + setSelectedEdgeId(null) + if (tool === 'connect') { + setConnectFromId(null) + return + } + if (tool !== 'pan' || draggingNodeId) return + + panStartRef.current = { + startX: event.clientX, + startY: event.clientY, + panX: pan.x, + panY: pan.y, + } + } + + useEffect(() => { + function handlePointerMove(event) { + if (draggingNodeId && canManage) { + const rect = viewportRef.current?.getBoundingClientRect() + if (!rect) return + const point = clientToGraphPoint(rect, pan, zoom, event.clientX, event.clientY) + const nextX = Math.max(0, point.x - dragOffsetRef.current.x) + const nextY = Math.max(0, point.y - dragOffsetRef.current.y) + setManualPositions((prev) => { + const next = { ...prev, [draggingNodeId]: { x: nextX, y: nextY } } + storeManualGatePositions(initiativeId, next) + return next + }) + return + } + + if (panStartRef.current) { + const deltaX = event.clientX - panStartRef.current.startX + const deltaY = event.clientY - panStartRef.current.startY + setPan({ + x: panStartRef.current.panX + deltaX, + y: panStartRef.current.panY + deltaY, + }) + } + } + + function handlePointerUp() { + setDraggingNodeId(null) + panStartRef.current = null + } + + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', handlePointerUp) + return () => { + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', handlePointerUp) + } + }, [canManage, draggingNodeId, initiativeId, pan, zoom]) + + function handleWheel(event) { + event.preventDefault() + const direction = event.deltaY > 0 ? -1 : 1 + setZoom((current) => clampDesignerZoom(current + direction * DESIGNER_ZOOM_STEP)) + } + + function handleResetLayout() { + clearManualGatePositions(initiativeId) + setManualPositions({}) + setPan({ x: 24, y: 24 }) + setZoom(1) + } + + if (!items.length) { + return ( + + ) + } + + if (loading) { + return + } + + return ( +
+
+
+

Zielzustände (Designer)

+

+ Zielzustands-Graph modellieren — Pan/Zoom, Knoten verschieben, Kanten verbinden. Verify + und Kriterien bleiben auf der Gate-Detailseite. +

+
+
+ + {error && } + +
+
+ + {canManage && ( + + )} +
+ + {canManage && tool === 'connect' && ( + + )} + +
+ + {Math.round(zoom * 100)}% + +
+ + {canManage && selectedEdgeId && ( + + )} + + {canManage && ( + + )} +
+ + {tool === 'connect' && canManage && ( +

+ {CONNECT_HINTS[edgeType] || 'Zwei Gates nacheinander anklicken.'} + {connectFromId ? ' — erstes Gate gewählt, zweites wählen.' : ''} +

+ )} + + {!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'} + + + ) + })} + +
+ + {canManage && ( +

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

+ )} +
+ ) +} diff --git a/frontend/src/components/GateMapView.jsx b/frontend/src/components/GateMapView.jsx index f50a243..d7899b2 100644 --- a/frontend/src/components/GateMapView.jsx +++ b/frontend/src/components/GateMapView.jsx @@ -86,8 +86,8 @@ export function GateMapView({ initiativeId, items, graphState: graphStateProp })

Zielzustände (Graph)

- Read-only Übersicht der Gate-Abhängigkeiten — Kanten auf der Gate-Detailseite pflegen, - Verify und Kriterien dort ebenfalls. + Read-only Übersicht der Gate-Abhängigkeiten — im Designer modellieren oder auf der + Gate-Detailseite Kanten pflegen; Verify und Kriterien dort.

@@ -197,8 +197,8 @@ export function GateMapView({ initiativeId, items, graphState: graphStateProp }) {dependencies.length === 0 && (

- Noch keine Kanten — auf der Gate-Detailseite unter „Abhängigkeiten“ anlegen; ohne Kanten - gelten Gates nach Reihenfolge. + Noch keine Kanten — im Designer verbinden oder auf der Gate-Detailseite unter + „Abhängigkeiten“ anlegen; ohne Kanten gelten Gates nach Reihenfolge.

)} diff --git a/frontend/src/components/GatesPlanPanel.jsx b/frontend/src/components/GatesPlanPanel.jsx index 56b9774..6867b21 100644 --- a/frontend/src/components/GatesPlanPanel.jsx +++ b/frontend/src/components/GatesPlanPanel.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { RoadmapPlanSection } from './RoadmapPlanSection.jsx' import { GateMapView } from './GateMapView.jsx' +import { GateGraphDesigner } from './GateGraphDesigner.jsx' import { resolveGatesViewMode, storeGatesViewMode } from '../plan/gateGraphLayout.js' import { listInitiativeRoadmapGraphState } from '../api/roadmap.js' @@ -43,6 +44,13 @@ export function GatesPlanPanel({ storeGatesViewMode(initiativeId, mode) } + function refreshGraphState() { + if (!initiativeId) return + listInitiativeRoadmapGraphState(initiativeId) + .then((data) => setGraphState(data || null)) + .catch(() => setGraphState(null)) + } + const fulfillmentPct = graphState?.initiative_fulfillment_ratio != null ? Math.round(graphState.initiative_fulfillment_ratio * 100) @@ -87,6 +95,18 @@ export function GatesPlanPanel({ > Graph + {viewMode === 'list' ? ( @@ -100,8 +120,15 @@ export function GatesPlanPanel({ busy={busy} graphState={graphState} /> - ) : ( + ) : viewMode === 'graph' ? ( + ) : ( + )} ) diff --git a/frontend/src/plan/gateGraphDesigner.js b/frontend/src/plan/gateGraphDesigner.js new file mode 100644 index 0000000..9bc6f07 --- /dev/null +++ b/frontend/src/plan/gateGraphDesigner.js @@ -0,0 +1,100 @@ +/** Designer-Hilfen für Zielzustands-Graph (AP1.15a) — kein Workflow. */ + +export const DESIGNER_MIN_ZOOM = 0.5 +export const DESIGNER_MAX_ZOOM = 2 +export const DESIGNER_ZOOM_STEP = 0.1 + +/** + * @param {number} zoom + * @returns {number} + */ +export function clampDesignerZoom(zoom) { + return Math.min(DESIGNER_MAX_ZOOM, Math.max(DESIGNER_MIN_ZOOM, zoom)) +} + +/** + * @param {string} initiativeId + * @returns {Record} + */ +export function loadManualGatePositions(initiativeId) { + if (typeof window === 'undefined' || !initiativeId) return {} + try { + const raw = window.localStorage.getItem(`kairo.plan.gateLayout:${initiativeId}`) + if (!raw) return {} + const parsed = JSON.parse(raw) + return parsed && typeof parsed === 'object' ? parsed : {} + } catch { + return {} + } +} + +/** + * @param {string} initiativeId + * @param {Record} positions + */ +export function storeManualGatePositions(initiativeId, positions) { + if (typeof window === 'undefined' || !initiativeId) return + window.localStorage.setItem( + `kairo.plan.gateLayout:${initiativeId}`, + JSON.stringify(positions), + ) +} + +/** + * @param {string} initiativeId + */ +export function clearManualGatePositions(initiativeId) { + if (typeof window === 'undefined' || !initiativeId) return + window.localStorage.removeItem(`kairo.plan.gateLayout:${initiativeId}`) +} + +/** + * @param {{ nodes: Array<{ id: string, x: number, y: number, width: number, height: number, item: object }> }} layout + * @param {Record} manualPositions + */ +export function applyManualGatePositions(layout, manualPositions) { + if (!layout?.nodes?.length || !manualPositions) return layout + + const nodes = layout.nodes.map((node) => { + const manual = manualPositions[node.id] + if (!manual) return node + return { ...node, x: manual.x, y: manual.y } + }) + + const nodeById = new Map(nodes.map((node) => [node.id, node])) + const edges = (layout.edges || []).map((edge) => { + const sourceNode = + edge.dependency_type === 'requires' + ? nodeById.get(edge.toId || edge.from_item_id) + : nodeById.get(edge.fromId || edge.from_item_id) + const targetNode = + edge.dependency_type === 'requires' + ? nodeById.get(edge.fromId || edge.to_item_id) + : nodeById.get(edge.toId || edge.to_item_id) + if (!sourceNode || !targetNode) return edge + + const center = (node) => ({ + x: node.x + node.width / 2, + y: node.y + node.height / 2, + }) + + if (edge.dependency_type === 'related') { + const from = center(sourceNode) + const to = center(targetNode) + return { ...edge, x1: from.x, y1: from.y, x2: to.x, y2: to.y } + } + + return { + ...edge, + x1: sourceNode.x + sourceNode.width / 2, + y1: sourceNode.y + sourceNode.height, + x2: targetNode.x + targetNode.width / 2, + y2: targetNode.y, + } + }) + + const width = Math.max(...nodes.map((node) => node.x + node.width), 0) + 32 + const height = Math.max(...nodes.map((node) => node.y + node.height), 0) + 32 + + return { ...layout, nodes, edges, width, height } +} diff --git a/frontend/src/plan/gateGraphDesigner.test.js b/frontend/src/plan/gateGraphDesigner.test.js new file mode 100644 index 0000000..62ea42c --- /dev/null +++ b/frontend/src/plan/gateGraphDesigner.test.js @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { + applyManualGatePositions, + clampDesignerZoom, +} from './gateGraphDesigner.js' + +describe('gateGraphDesigner', () => { + it('clamps zoom range', () => { + expect(clampDesignerZoom(0.1)).toBe(0.5) + expect(clampDesignerZoom(3)).toBe(2) + expect(clampDesignerZoom(1)).toBe(1) + }) + + it('applies manual node positions', () => { + const layout = { + nodes: [ + { id: 'a', x: 10, y: 20, width: 100, height: 72, item: {} }, + { id: 'b', x: 200, y: 20, width: 100, height: 72, item: {} }, + ], + edges: [], + width: 400, + height: 200, + } + const merged = applyManualGatePositions(layout, { a: { x: 50, y: 80 } }) + expect(merged.nodes.find((n) => n.id === 'a').x).toBe(50) + expect(merged.nodes.find((n) => n.id === 'b').x).toBe(200) + }) +}) diff --git a/frontend/src/plan/gateGraphLayout.js b/frontend/src/plan/gateGraphLayout.js index 3c6a459..e7d7d69 100644 --- a/frontend/src/plan/gateGraphLayout.js +++ b/frontend/src/plan/gateGraphLayout.js @@ -171,12 +171,12 @@ export function computeGateGraphLayout(items, dependencies) { /** * @param {string} initiativeId * @param {number} itemCount - * @returns {'list' | 'graph'} + * @returns {'list' | 'graph' | 'designer'} */ 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') { + if (stored === 'list' || stored === 'graph' || stored === 'designer') { return stored } } @@ -185,7 +185,7 @@ export function resolveGatesViewMode(initiativeId, itemCount) { /** * @param {string} initiativeId - * @param {'list' | 'graph'} mode + * @param {'list' | 'graph' | 'designer'} mode */ export function storeGatesViewMode(initiativeId, mode) { if (typeof window !== 'undefined' && initiativeId) { diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 761b856..dd7d404 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -1874,6 +1874,85 @@ font-size: 0.875rem; } +.gate-graph-designer__toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.75rem; + margin-bottom: 0.75rem; +} + +.gate-graph-designer__tool-group, +.gate-graph-designer__zoom-group { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.gate-graph-designer__tool--active { + border-color: var(--jk-border-strong, #c5ced8); + background: var(--jk-surface-muted, #eef2f6); +} + +.gate-graph-designer__edge-type { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.875rem; +} + +.gate-graph-designer__zoom-label { + min-width: 3rem; + text-align: center; + font-size: 0.875rem; +} + +.gate-graph-designer__hint, +.gate-graph-designer__footer-hint { + margin: 0 0 0.75rem; + font-size: 0.875rem; +} + +.gate-graph-designer__viewport { + position: relative; + min-height: 420px; + max-height: 70vh; + overflow: hidden; + border: 1px solid var(--jk-border, #dde3ea); + border-radius: 8px; + background: var(--jk-surface-muted, #f6f8fa); + cursor: grab; + touch-action: none; +} + +.gate-graph-designer__viewport:active { + cursor: grabbing; +} + +.gate-graph-designer__canvas { + display: block; + pointer-events: all; +} + +.gate-graph-designer__edge--selected { + stroke-width: 3; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.2)); +} + +.gate-graph-designer__node--connect-source .gate-map-view__node-box { + stroke: var(--jk-accent, #2563eb); + stroke-width: 2; +} + +.gate-graph-designer__node--dragging { + opacity: 0.92; +} + +.gate-graph-designer .gate-map-view__edge { + pointer-events: stroke; + cursor: pointer; +} + .gate-dependencies-section { margin: 1.25rem 0; padding-top: 1rem;