From 7cd8f4b90e99890a9106991a68ac6d8b1fe78afb Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 10 Jul 2026 15:53:03 +0200 Subject: [PATCH] AP1.13b: Gate-Abhaengigkeiten auf der Detailseite pflegen. DELETE fuer Roadmap-Kanten, Duplikat-Schutz beim Anlegen, GateDependenciesSection mit ausgehenden/eingehenden Kanten auf der Gate-Detailseite. Co-authored-by: Cursor --- backend/main.py | 1 + backend/routers/roadmap.py | 16 ++ backend/services/roadmap.py | 54 +++++ backend/tests/test_ap14_roadmap.py | 50 +++++ frontend/src/api/roadmap.js | 4 + .../components/GateDependenciesSection.jsx | 201 ++++++++++++++++++ frontend/src/components/GateMapView.jsx | 8 +- .../initiative/RoadmapItemDetailPage.jsx | 32 ++- frontend/src/styles/components.css | 24 +++ 9 files changed, 385 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/GateDependenciesSection.jsx 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} +

    +
    + {canManage && ( +
    + +
    + )} +
  • + ) +} + +export function GateDependenciesSection({ + itemId, + siblingItems = [], + dependencies = [], + canManage, + busy, + onAdd, + onDelete, +}) { + const candidates = siblingItems.filter((item) => item.id !== itemId) + const { outgoing, incoming } = groupDependencies(itemId, dependencies) + const hasAny = outgoing.length > 0 || incoming.length > 0 + + async function handleAddSubmit(event) { + event.preventDefault() + const form = event.target + const toItemId = form.to_item_id.value + const dependencyType = form.dependency_type.value + if (!toItemId) return + await onAdd({ + to_item_id: toItemId, + dependency_type: dependencyType, + }) + form.reset() + } + + return ( +
    +
    +
    +

    Abhängigkeiten

    +

    + Kanten für den Gate-Graph — ausgehende Abhängigkeiten kannst du hier anlegen und + entfernen. +

    +
    +
    + + {!hasAny && ( +

    + Noch keine Kanten — im Graph erscheinen Gates sonst nach Reihenfolge. +

    + )} + + {outgoing.length > 0 && ( + <> +

    Ausgehend

    +
      + {outgoing.map((dep) => ( + + ))} +
    + + )} + + {incoming.length > 0 && ( + <> +

    Eingehend

    +
      + {incoming.map((dep) => ( + + ))} +
    + + )} + + {canManage && candidates.length > 0 && ( +
    +

    Kante hinzufügen

    +

    + Dieses Gate … +

    + + + +
    + )} + + {canManage && candidates.length === 0 && ( +

    + Mindestens ein weiteres Gate im Vorhaben nötig, um Kanten zu definieren. +

    + )} +
    + ) +} diff --git a/frontend/src/components/GateMapView.jsx b/frontend/src/components/GateMapView.jsx index 76bf46d..4757c28 100644 --- a/frontend/src/components/GateMapView.jsx +++ b/frontend/src/components/GateMapView.jsx @@ -73,8 +73,8 @@ export function GateMapView({ initiativeId, items }) {

    Zielzustände (Graph)

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

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

    - 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} /> + runAction(() => addRoadmapItemDependency(itemId, body))} + onDelete={(dependencyId) => runAction(() => deleteRoadmapDependency(dependencyId))} + /> +
      {criteria.map((crit) => (
    • diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 9684f89..5bcd791 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -1760,3 +1760,27 @@ font-size: 0.875rem; } +.gate-dependencies-section { + margin: 1.25rem 0; + padding-top: 1rem; + border-top: 1px solid var(--jk-border, #dde3ea); +} + +.gate-dependencies-section__group-title { + margin: 0.75rem 0 0.5rem; + font-size: 0.9375rem; +} + +.gate-dependencies-section__empty { + margin: 0 0 0.75rem; + font-size: 0.875rem; +} + +.gate-dependency-item__phrase { + margin: 0; +} + +.gate-dependency-form__hint { + margin-bottom: 0.75rem; +} +