diff --git a/backend/routers/roadmap.py b/backend/routers/roadmap.py index a6d9067..2a5c4c2 100644 --- a/backend/routers/roadmap.py +++ b/backend/routers/roadmap.py @@ -299,9 +299,13 @@ def delete_roadmap_item( item_id: str, ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), ): - if not roadmap_service.delete_roadmap_item( - tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id - ): + try: + deleted = roadmap_service.delete_roadmap_item( + tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if not deleted: raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden") diff --git a/backend/services/roadmap.py b/backend/services/roadmap.py index 26c528b..75ccc34 100644 --- a/backend/services/roadmap.py +++ b/backend/services/roadmap.py @@ -461,6 +461,14 @@ def update_roadmap_item( def delete_roadmap_item( *, tenant_id: str, item_id: str, user_id: Optional[str] = None ) -> bool: + existing = get_roadmap_item(tenant_id=tenant_id, item_id=item_id) + if not existing: + return False + if existing.get("status") in TERMINAL_STATUSES: + raise ValueError( + "Erreichte oder terminal abgeschlossene Gates können nicht gelöscht werden" + ) + conn = get_connection() try: with conn.cursor() as cur: diff --git a/backend/tests/test_ap14_roadmap.py b/backend/tests/test_ap14_roadmap.py index 35d798c..07b27c6 100644 --- a/backend/tests/test_ap14_roadmap.py +++ b/backend/tests/test_ap14_roadmap.py @@ -86,6 +86,13 @@ def test_verify_reached_requires_evidence(client): assert ok.json()["status"] == "reached" assert ok.json()["verify_reason"] in ("criteria_ready", "evidence_accepted") + blocked_delete = client.delete( + f"/api/roadmap-items/{item['id']}", + headers=_auth(token), + ) + assert blocked_delete.status_code == 400 + assert "nicht gelöscht" in blocked_delete.json()["detail"].lower() or "terminal" in blocked_delete.json()["detail"].lower() + def test_milestone_compat_api_uses_roadmap(client): user = provision_user_in_tenant(tenant_role="member") diff --git a/frontend/src/components/GateDesignerNodePanel.jsx b/frontend/src/components/GateDesignerNodePanel.jsx index ecddf72..3565b0b 100644 --- a/frontend/src/components/GateDesignerNodePanel.jsx +++ b/frontend/src/components/GateDesignerNodePanel.jsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { Link } from 'react-router-dom' import { gatePath } from '../utils/routes.js' import { @@ -6,6 +7,8 @@ import { isJoinGateItem, } from '../constants/status.js' import { StatusBadge } from './StatusBadge.jsx' +import { Modal } from './Modal.jsx' +import { RoadmapItemForm } from './RoadmapItemForm.jsx' function formatDate(value) { if (!value) return null @@ -18,90 +21,176 @@ function formatDate(value) { } } +function graphNodeHint(item, graphItem, enforceBlocking) { + if (item.status === 'reached') { + return { label: 'Abgeschlossen', className: 'status-pill status-pill--done' } + } + if (enforceBlocking && graphItem?.blocked) { + return { label: 'Blockiert', className: 'status-pill status-pill--blocked' } + } + if (graphItem?.ready || item.status === 'active') { + return { label: 'Aktiv / bereit', className: 'status-pill status-pill--ready' } + } + return null +} + export function GateDesignerNodePanel({ item, graphState, criteriaProgress, + canManage = false, + busy = false, onClose, + onUpdate, + onDelete, }) { + const [showEdit, setShowEdit] = useState(false) + if (!item) return null const joinGate = isJoinGateItem(item) const graphItem = graphState?.items?.[item.id] + const enforceBlocking = graphState?.graph_profile?.enforce_gate_blocking !== false + const statusHint = graphNodeHint(item, graphItem, enforceBlocking) + const terminalStatus = ['reached', 'moved', 'discarded'].includes(item.status) + const canDelete = canManage && !terminalStatus && typeof onDelete === 'function' + const canEdit = canManage && !terminalStatus && typeof onUpdate === 'function' const progress = criteriaProgress?.[item.id] const closed = progress?.closed ?? 0 const total = progress?.total ?? 0 - return ( - +
+ {canEdit && ( + + )} + {canDelete && ( + + )} + + Vollständig bearbeiten + +
+ + + setShowEdit(false)}> + setShowEdit(false)} + onSubmit={handleEditSubmit} + /> + + ) } diff --git a/frontend/src/components/GateGraphDesigner.jsx b/frontend/src/components/GateGraphDesigner.jsx index 09e23a2..ed5ccd5 100644 --- a/frontend/src/components/GateGraphDesigner.jsx +++ b/frontend/src/components/GateGraphDesigner.jsx @@ -15,7 +15,14 @@ import { loadManualGatePositions, storeManualGatePositions, } from '../plan/gateGraphDesigner.js' -import { DESIGNER_EDGE_KINDS, DESIGNER_EDGE_LABELS, edgeKindNeedsGroupKey, resolveDependencyEndpoints } from '../plan/gateGraphTopology.js' +import { + DESIGNER_EDGE_KINDS, + DESIGNER_EDGE_DESCRIPTIONS, + DESIGNER_EDGE_LABELS, + dependencyVisualEndpoints, + edgeKindNeedsGroupKey, + resolveDependencyEndpoints, +} from '../plan/gateGraphTopology.js' import { LoadingState } from './LoadingState.jsx' import { ErrorState } from './ErrorState.jsx' import { EmptyState } from './EmptyState.jsx' @@ -61,6 +68,8 @@ export function GateGraphDesigner({ canManage = false, busy: busyProp = false, onCreate, + onUpdate, + onDelete, onGraphChanged, graphState, }) { @@ -147,6 +156,21 @@ export function GateGraphDesigner({ [items, selectedNodeId], ) + const selectedDependency = useMemo( + () => dependencies.find((dep) => dep.id === selectedEdgeId) || null, + [dependencies, selectedEdgeId], + ) + + const itemTitleById = useMemo(() => { + const map = new Map() + for (const item of items) { + map.set(item.id, item.title || 'Gate') + } + return map + }, [items]) + + const enforceBlocking = graphState?.graph_profile?.enforce_gate_blocking !== false + async function notifyGraphChanged() { await reloadDependencies() onGraphChanged?.() @@ -200,6 +224,74 @@ export function GateGraphDesigner({ } } + async function handleChangeEdgeType(newType) { + if (!selectedDependency || !canManage) return + if (newType === selectedDependency.dependency_type) return + const effectiveGroupKey = (selectedDependency.group_key || groupKey || 'phase-1').trim() + if (edgeKindNeedsGroupKey(newType) && !effectiveGroupKey) { + setError('Parallelgruppe benötigt einen Schlüssel (group_key).') + return + } + setBusyLocal(true) + setError(null) + try { + const { sourceId, targetId } = dependencyVisualEndpoints(selectedDependency) + await deleteRoadmapDependency(selectedDependency.id) + const { from_item_id: fromItemId, to_item_id: toItemId } = resolveDependencyEndpoints( + newType, + sourceId, + targetId, + ) + const body = { + to_item_id: toItemId, + dependency_type: newType, + } + if (edgeKindNeedsGroupKey(newType)) { + body.group_key = effectiveGroupKey + } + await addRoadmapItemDependency(fromItemId, body) + setEdgeType(newType) + if (edgeKindNeedsGroupKey(newType)) { + setGroupKey(effectiveGroupKey) + } + await notifyGraphChanged() + } catch (err) { + setError(err?.message || 'Kantentyp konnte nicht geändert werden.') + } finally { + setBusyLocal(false) + } + } + + async function handleDeleteGate(itemId) { + if (!onDelete || !canManage) return + setBusyLocal(true) + setError(null) + try { + await onDelete(itemId) + setSelectedNodeId(null) + onGraphChanged?.() + } catch (err) { + setError(err?.message || 'Gate konnte nicht gelöscht werden.') + } finally { + setBusyLocal(false) + } + } + + async function handleUpdateGate(itemId, payload) { + if (!onUpdate || !canManage) return + setBusyLocal(true) + setError(null) + try { + await onUpdate(itemId, payload) + onGraphChanged?.() + } catch (err) { + setError(err?.message || 'Gate konnte nicht gespeichert werden.') + throw err + } finally { + setBusyLocal(false) + } + } + function openCreateAt(point) { setCreatePosition({ x: Math.max(0, point.x - GATE_NODE_WIDTH / 2), @@ -514,11 +606,46 @@ export function GateGraphDesigner({ )} +
+
+ + + Voraussetzung + + + + Blockiert + + + + Bezug + + + + Parallel + + + + Optional + +
+
+ + Abgeschlossen + + + Aktiv / bereit + + + Blockiert + +
+
+ {canManage && (

- Vom unteren Punkt ziehen: Voraussetzung/Block/Bezug/Parallel/Optional. Join-Gates - verbinden mehrere Pflicht-Voraussetzungen (AND). Doppelklick auf leere Fläche legt ein - Gate an. + Vom unteren Punkt ziehen: Kante anlegen. Klick auf Kante oder Gate öffnet Bearbeitung + rechts. Join-Gates sammeln Eingänge (AND). Doppelklick auf leere Fläche legt ein Gate an.

)} @@ -586,10 +713,8 @@ export function GateGraphDesigner({ } onClick={(event) => { event.stopPropagation() - if (canManage) { - setSelectedEdgeId(edge.id) - setSelectedNodeId(null) - } + setSelectedEdgeId(edge.id) + setSelectedNodeId(null) }} /> ))} @@ -607,12 +732,24 @@ export function GateGraphDesigner({ {layout.nodes.map((node) => { const item = node.item const isSelected = selectedNodeId === node.id + const nodeGraph = graphState?.items?.[node.id] const nodeClass = 'gate-map-view__node gate-map-view__node--' + (item.status || 'planned') + (item.item_type === 'join_gate' ? ' gate-map-view__node--join_gate' : '') + + (enforceBlocking && nodeGraph?.blocked ? ' gate-map-view__node--blocked' : '') + + (nodeGraph?.ready ? ' gate-map-view__node--ready' : '') + + (item.status === 'reached' ? ' gate-map-view__node--reached' : '') + (isSelected ? ' gate-graph-designer__node--selected' : '') + (draggingNodeId === node.id ? ' gate-graph-designer__node--dragging' : '') + const statusLabel = + item.status === 'reached' + ? '✓' + : enforceBlocking && nodeGraph?.blocked + ? '⏸' + : nodeGraph?.ready || item.status === 'active' + ? '●' + : null return ( + {statusLabel && ( + + )} {truncateTitle(item.title)} {ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type || 'Gate'} + {item.status === 'reached' && ( + + Erreicht + + )} {canManage && ( - {selectedItem && ( - setSelectedNodeId(null)} - /> + {(selectedItem || selectedDependency) && ( +
+ {selectedDependency && ( + + )} + + {selectedItem && !selectedDependency && ( + setSelectedNodeId(null)} + onUpdate={handleUpdateGate} + onDelete={handleDeleteGate} + /> + )} +
)} diff --git a/frontend/src/components/GatesPlanPanel.jsx b/frontend/src/components/GatesPlanPanel.jsx index 2cfb179..bc79c72 100644 --- a/frontend/src/components/GatesPlanPanel.jsx +++ b/frontend/src/components/GatesPlanPanel.jsx @@ -10,6 +10,7 @@ export function GatesPlanPanel({ items, canManage, onCreate, + onUpdate, onReorder, onDelete, busy, @@ -129,6 +130,8 @@ export function GatesPlanPanel({ canManage={canManage} busy={busy} onCreate={canManage ? onCreate : undefined} + onUpdate={canManage ? onUpdate : undefined} + onDelete={canManage ? onDelete : undefined} onGraphChanged={refreshGraphState} graphState={graphState} /> diff --git a/frontend/src/components/RoadmapItemForm.jsx b/frontend/src/components/RoadmapItemForm.jsx index 27e860e..4830caf 100644 --- a/frontend/src/components/RoadmapItemForm.jsx +++ b/frontend/src/components/RoadmapItemForm.jsx @@ -44,16 +44,18 @@ export function RoadmapItemForm({ } const statusLocked = ['reached', 'moved', 'discarded'].includes(initial.status) + const showTypeField = mode === 'create' || (mode === 'edit' && !statusLocked) return (
- {mode === 'create' && ( + {showTypeField && (