diff --git a/backend/main.py b/backend/main.py index 60a5f19..de5a481 100644 --- a/backend/main.py +++ b/backend/main.py @@ -98,6 +98,7 @@ app.include_router(backlog.router) app.include_router(milestones.router) app.include_router(roadmap.initiative_router) app.include_router(roadmap.items_router) +app.include_router(roadmap.deps_router) app.include_router(roadmap.criteria_router) app.include_router(evidence.router) app.include_router(decisions.router) diff --git a/backend/routers/roadmap.py b/backend/routers/roadmap.py index a974275..c55688b 100644 --- a/backend/routers/roadmap.py +++ b/backend/routers/roadmap.py @@ -81,6 +81,7 @@ class ReopenRequest(BaseModel): initiative_router = APIRouter(prefix="/api/initiatives", tags=["roadmap"]) items_router = APIRouter(prefix="/api/roadmap-items", tags=["roadmap"]) +deps_router = APIRouter(prefix="/api/roadmap-dependencies", tags=["roadmap"]) @initiative_router.get("/{initiative_id}/roadmap") @@ -270,6 +271,21 @@ def create_roadmap_item_criterion( raise HTTPException(status_code=400, detail=str(exc)) from exc +@deps_router.delete("/{dependency_id}", status_code=204) +def delete_roadmap_dependency( + dependency_id: str, + ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), +): + try: + roadmap_service.delete_dependency( + tenant_id=ctx.tenant_id, + dependency_id=dependency_id, + user_id=ctx.user_id, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + criteria_router = APIRouter(prefix="/api/roadmap-criteria", tags=["roadmap"]) diff --git a/backend/services/roadmap.py b/backend/services/roadmap.py index 036d157..57b2b74 100644 --- a/backend/services/roadmap.py +++ b/backend/services/roadmap.py @@ -542,6 +542,17 @@ def add_dependency( conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT 1 FROM roadmap_item_dependencies + WHERE tenant_id = %s AND from_item_id = %s AND to_item_id = %s + AND dependency_type = %s + """, + (tenant_id, from_item_id, to_item_id, dependency_type), + ) + if cur.fetchone(): + raise ValueError("Abhängigkeit existiert bereits") + cur.execute( """ INSERT INTO roadmap_item_dependencies ( @@ -573,6 +584,49 @@ def add_dependency( return row +def delete_dependency( + *, tenant_id: str, dependency_id: str, user_id: Optional[str] = None +) -> bool: + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id, from_item_id, to_item_id, dependency_type + FROM roadmap_item_dependencies + WHERE id = %s AND tenant_id = %s + """, + (dependency_id, tenant_id), + ) + row = cur.fetchone() + if not row: + raise ValueError("Abhängigkeit nicht gefunden") + + cur.execute( + """ + DELETE FROM roadmap_item_dependencies + WHERE id = %s AND tenant_id = %s + """, + (dependency_id, tenant_id), + ) + conn.commit() + finally: + conn.close() + + log_audit( + "roadmap_item.dependency_removed", + user_id=user_id, + tenant_id=tenant_id, + details={ + "dependency_id": dependency_id, + "from_item_id": str(row["from_item_id"]), + "to_item_id": str(row["to_item_id"]), + "dependency_type": row["dependency_type"], + }, + ) + return True + + def verify_reached( *, tenant_id: str, item_id: str, user_id: Optional[str] = None ) -> dict[str, Any]: diff --git a/backend/tests/test_ap14_roadmap.py b/backend/tests/test_ap14_roadmap.py index 9ed63fa..a55a186 100644 --- a/backend/tests/test_ap14_roadmap.py +++ b/backend/tests/test_ap14_roadmap.py @@ -146,3 +146,53 @@ def test_initiative_roadmap_dependencies_list(client): assert deps[0]["from_item_id"] == gate_a["id"] assert deps[0]["to_item_id"] == gate_b["id"] assert deps[0]["dependency_type"] == "requires" + + +def test_roadmap_dependency_delete(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), + ) + dep_id = created.json()["id"] + + deleted = client.delete( + f"/api/roadmap-dependencies/{dep_id}", + headers=_auth(token), + ) + assert deleted.status_code == 204 + + listed = client.get( + f"/api/initiatives/{initiative_id}/roadmap/dependencies", + headers=_auth(token), + ) + assert listed.json() == [] + + +def test_roadmap_dependency_duplicate_rejected(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() + body = {"to_item_id": gate_b["id"], "dependency_type": "requires"} + + assert client.post( + f"/api/roadmap-items/{gate_a['id']}/dependencies", + json=body, + headers=_auth(token), + ).status_code == 201 + + dup = client.post( + f"/api/roadmap-items/{gate_a['id']}/dependencies", + json=body, + headers=_auth(token), + ) + assert dup.status_code == 400 + assert "existiert bereits" in dup.json()["detail"] diff --git a/frontend/src/api/roadmap.js b/frontend/src/api/roadmap.js index 4013a12..d6cae7b 100644 --- a/frontend/src/api/roadmap.js +++ b/frontend/src/api/roadmap.js @@ -96,3 +96,7 @@ export function addRoadmapItemDependency(itemId, body) { body: JSON.stringify(body), }) } + +export function deleteRoadmapDependency(dependencyId) { + return apiFetch(`/api/roadmap-dependencies/${dependencyId}`, { method: 'DELETE' }) +} diff --git a/frontend/src/components/GateDependenciesSection.jsx b/frontend/src/components/GateDependenciesSection.jsx new file mode 100644 index 0000000..2eb679f --- /dev/null +++ b/frontend/src/components/GateDependenciesSection.jsx @@ -0,0 +1,201 @@ +import { Link } from 'react-router-dom' +import { gatePath } from '../utils/routes.js' +import { gateTitleById } from './GateSelect.jsx' + +const DEPENDENCY_TYPE_LABELS = { + requires: 'Voraussetzung', + blocks: 'Blockiert', + related: 'Bezug', +} + +const OUTGOING_PHRASES = { + requires: 'benötigt', + blocks: 'blockiert', + related: 'Bezug zu', +} + +const INCOMING_PHRASES = { + requires: 'Voraussetzung für', + blocks: 'Wird blockiert von', + related: 'Bezug von', +} + +function groupDependencies(itemId, dependencies) { + /** @type {{ outgoing: typeof dependencies, incoming: typeof dependencies }} */ + const groups = { outgoing: [], incoming: [] } + for (const dep of dependencies) { + if (dep.from_item_id === itemId) groups.outgoing.push(dep) + else if (dep.to_item_id === itemId) groups.incoming.push(dep) + } + return groups +} + +function DependencyRow({ dep, itemId, siblingItems, direction, canManage, busy, onDelete }) { + const otherId = direction === 'outgoing' ? dep.to_item_id : dep.from_item_id + const otherTitle = gateTitleById(siblingItems, otherId) || otherId + const phrase = + direction === 'outgoing' + ? OUTGOING_PHRASES[dep.dependency_type] || dep.dependency_type + : INCOMING_PHRASES[dep.dependency_type] || dep.dependency_type + + return ( +
+ {direction === 'outgoing' ? ( + <> + Dieses Gate {phrase}{' '} + {otherTitle} + > + ) : ( + <> + {phrase}{' '} + {otherTitle} + > + )} +
++ {DEPENDENCY_TYPE_LABELS[dep.dependency_type] || dep.dependency_type} +
++ Kanten für den Gate-Graph — ausgehende Abhängigkeiten kannst du hier anlegen und + entfernen. +
++ Noch keine Kanten — im Graph erscheinen Gates sonst nach Reihenfolge. +
+ )} + + {outgoing.length > 0 && ( + <> ++ Mindestens ein weiteres Gate im Vorhaben nötig, um Kanten zu definieren. +
+ )} +- Read-only Übersicht der Gate-Abhängigkeiten — Bearbeitung auf der Gate-Detailseite - (AP1.13b). + Read-only Übersicht der Gate-Abhängigkeiten — Kanten auf der Gate-Detailseite pflegen, + Verify und Kriterien dort ebenfalls.
- Noch keine Kanten — Gates sind nach Reihenfolge angeordnet. Abhängigkeiten können auf den - Gate-Detailseiten gepflegt werden (AP1.13b). + Noch keine Kanten — auf der Gate-Detailseite unter „Abhängigkeiten“ anlegen; ohne Kanten + gelten Gates nach Reihenfolge.
)} diff --git a/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx b/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx index cde0a5e..5e0fe00 100644 --- a/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx +++ b/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx @@ -14,6 +14,10 @@ import { verifyRoadmapItemReached, reopenRoadmapItem, updateRoadmapItem, + listRoadmapItemDependencies, + addRoadmapItemDependency, + deleteRoadmapDependency, + listInitiativeRoadmapItems, } from '../../api/roadmap.js' import { ErrorState } from '../../components/ErrorState.jsx' import { LoadingState } from '../../components/LoadingState.jsx' @@ -31,6 +35,7 @@ import { useInitiativeOperations } from '../../context/InitiativeOperationsConte import { scopedPath } from '../../utils/routes.js' import { listGateContributions } from '../../api/journey.js' import { GateContributionsSection } from '../../components/GateContributionsSection.jsx' +import { GateDependenciesSection } from '../../components/GateDependenciesSection.jsx' import { Modal } from '../../components/Modal.jsx' import { RoadmapItemForm } from '../../components/RoadmapItemForm.jsx' @@ -100,6 +105,8 @@ export function RoadmapItemDetailPage({ overrideItemId }) { const [contributionsLoading, setContributionsLoading] = useState(true) const [contributionsError, setContributionsError] = useState(null) const [editingMeta, setEditingMeta] = useState(false) + const [dependencies, setDependencies] = useState([]) + const [siblingItems, setSiblingItems] = useState([]) const loadContributions = useCallback(async () => { setContributionsLoading(true) @@ -124,12 +131,25 @@ export function RoadmapItemDetailPage({ overrideItemId }) { setItem(itemData) setCriteria(criteriaData.items || []) setProgress(criteriaData.progress || null) + + const resolvedInitiativeId = initiativeId || itemData.initiative_id + if (resolvedInitiativeId) { + const [depsData, siblings] = await Promise.all([ + listRoadmapItemDependencies(itemId), + listInitiativeRoadmapItems(resolvedInitiativeId), + ]) + setDependencies(Array.isArray(depsData) ? depsData : []) + setSiblingItems(Array.isArray(siblings) ? siblings : []) + } else { + setDependencies([]) + setSiblingItems([]) + } } catch (err) { setError(err.message) } finally { setLoading(false) } - }, [itemId]) + }, [itemId, initiativeId]) useEffect(() => { load() @@ -207,6 +227,16 @@ export function RoadmapItemDetailPage({ overrideItemId }) { error={contributionsError} /> +