AP1.13b: Gate-Abhaengigkeiten auf der Detailseite pflegen.
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 2m11s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s

DELETE fuer Roadmap-Kanten, Duplikat-Schutz beim Anlegen, GateDependenciesSection mit ausgehenden/eingehenden Kanten auf der Gate-Detailseite.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-10 15:53:03 +02:00
parent 7f03504c2d
commit 7cd8f4b90e
9 changed files with 385 additions and 5 deletions

View File

@ -98,6 +98,7 @@ app.include_router(backlog.router)
app.include_router(milestones.router) app.include_router(milestones.router)
app.include_router(roadmap.initiative_router) app.include_router(roadmap.initiative_router)
app.include_router(roadmap.items_router) app.include_router(roadmap.items_router)
app.include_router(roadmap.deps_router)
app.include_router(roadmap.criteria_router) app.include_router(roadmap.criteria_router)
app.include_router(evidence.router) app.include_router(evidence.router)
app.include_router(decisions.router) app.include_router(decisions.router)

View File

@ -81,6 +81,7 @@ class ReopenRequest(BaseModel):
initiative_router = APIRouter(prefix="/api/initiatives", tags=["roadmap"]) initiative_router = APIRouter(prefix="/api/initiatives", tags=["roadmap"])
items_router = APIRouter(prefix="/api/roadmap-items", 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") @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 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"]) criteria_router = APIRouter(prefix="/api/roadmap-criteria", tags=["roadmap"])

View File

@ -542,6 +542,17 @@ def add_dependency(
conn = get_connection() conn = get_connection()
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cur: 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( cur.execute(
""" """
INSERT INTO roadmap_item_dependencies ( INSERT INTO roadmap_item_dependencies (
@ -573,6 +584,49 @@ def add_dependency(
return row 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( def verify_reached(
*, tenant_id: str, item_id: str, user_id: Optional[str] = None *, tenant_id: str, item_id: str, user_id: Optional[str] = None
) -> dict[str, Any]: ) -> dict[str, Any]:

View File

@ -146,3 +146,53 @@ def test_initiative_roadmap_dependencies_list(client):
assert deps[0]["from_item_id"] == gate_a["id"] assert deps[0]["from_item_id"] == gate_a["id"]
assert deps[0]["to_item_id"] == gate_b["id"] assert deps[0]["to_item_id"] == gate_b["id"]
assert deps[0]["dependency_type"] == "requires" 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"]

View File

@ -96,3 +96,7 @@ export function addRoadmapItemDependency(itemId, body) {
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
} }
export function deleteRoadmapDependency(dependencyId) {
return apiFetch(`/api/roadmap-dependencies/${dependencyId}`, { method: 'DELETE' })
}

View File

@ -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 (
<li className="list-item card-list-item gate-dependency-item">
<div className="list-item-main">
<p className="gate-dependency-item__phrase">
{direction === 'outgoing' ? (
<>
Dieses Gate <strong>{phrase}</strong>{' '}
<Link to={gatePath(otherId)}>{otherTitle}</Link>
</>
) : (
<>
<strong>{phrase}</strong>{' '}
<Link to={gatePath(otherId)}>{otherTitle}</Link>
</>
)}
</p>
<p className="muted list-item-sub">
{DEPENDENCY_TYPE_LABELS[dep.dependency_type] || dep.dependency_type}
</p>
</div>
{canManage && (
<div className="list-item-meta action-controls">
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={busy}
onClick={() => onDelete(dep.id)}
>
Entfernen
</button>
</div>
)}
</li>
)
}
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 (
<section className="gate-dependencies-section">
<div className="section-header">
<div>
<h3>Abhängigkeiten</h3>
<p className="section-lead muted">
Kanten für den Gate-Graph ausgehende Abhängigkeiten kannst du hier anlegen und
entfernen.
</p>
</div>
</div>
{!hasAny && (
<p className="muted gate-dependencies-section__empty">
Noch keine Kanten im Graph erscheinen Gates sonst nach Reihenfolge.
</p>
)}
{outgoing.length > 0 && (
<>
<h4 className="gate-dependencies-section__group-title">Ausgehend</h4>
<ul className="item-list gate-dependency-list">
{outgoing.map((dep) => (
<DependencyRow
key={dep.id}
dep={dep}
itemId={itemId}
siblingItems={siblingItems}
direction="outgoing"
canManage={canManage}
busy={busy}
onDelete={onDelete}
/>
))}
</ul>
</>
)}
{incoming.length > 0 && (
<>
<h4 className="gate-dependencies-section__group-title">Eingehend</h4>
<ul className="item-list gate-dependency-list">
{incoming.map((dep) => (
<DependencyRow
key={dep.id}
dep={dep}
itemId={itemId}
siblingItems={siblingItems}
direction="incoming"
canManage={canManage}
busy={busy}
onDelete={onDelete}
/>
))}
</ul>
</>
)}
{canManage && candidates.length > 0 && (
<form className="inline-form-block gate-dependency-form" onSubmit={handleAddSubmit}>
<h4>Kante hinzufügen</h4>
<p className="muted form-hint gate-dependency-form__hint">
Dieses Gate
</p>
<label>
Beziehung
<select name="dependency_type" defaultValue="requires">
<option value="requires">benötigt (Voraussetzung)</option>
<option value="blocks">blockiert</option>
<option value="related">steht in Bezug zu</option>
</select>
</label>
<label>
Anderes Gate
<select name="to_item_id" required defaultValue="">
<option value="" disabled>
Gate wählen
</option>
{candidates.map((item) => (
<option key={item.id} value={item.id}>
{item.title}
</option>
))}
</select>
</label>
<button type="submit" className="btn btn-primary" disabled={busy}>
Kante anlegen
</button>
</form>
)}
{canManage && candidates.length === 0 && (
<p className="muted gate-dependencies-section__empty">
Mindestens ein weiteres Gate im Vorhaben nötig, um Kanten zu definieren.
</p>
)}
</section>
)
}

View File

@ -73,8 +73,8 @@ export function GateMapView({ initiativeId, items }) {
<div> <div>
<h2>Zielzustände (Graph)</h2> <h2>Zielzustände (Graph)</h2>
<p className="section-lead muted"> <p className="section-lead muted">
Read-only Übersicht der Gate-Abhängigkeiten Bearbeitung auf der Gate-Detailseite Read-only Übersicht der Gate-Abhängigkeiten Kanten auf der Gate-Detailseite pflegen,
(AP1.13b). Verify und Kriterien dort ebenfalls.
</p> </p>
</div> </div>
</div> </div>
@ -177,8 +177,8 @@ export function GateMapView({ initiativeId, items }) {
{dependencies.length === 0 && ( {dependencies.length === 0 && (
<p className="muted gate-map-view__hint"> <p className="muted gate-map-view__hint">
Noch keine Kanten Gates sind nach Reihenfolge angeordnet. Abhängigkeiten können auf den Noch keine Kanten auf der Gate-Detailseite unter Abhängigkeiten anlegen; ohne Kanten
Gate-Detailseiten gepflegt werden (AP1.13b). gelten Gates nach Reihenfolge.
</p> </p>
)} )}
</section> </section>

View File

@ -14,6 +14,10 @@ import {
verifyRoadmapItemReached, verifyRoadmapItemReached,
reopenRoadmapItem, reopenRoadmapItem,
updateRoadmapItem, updateRoadmapItem,
listRoadmapItemDependencies,
addRoadmapItemDependency,
deleteRoadmapDependency,
listInitiativeRoadmapItems,
} from '../../api/roadmap.js' } from '../../api/roadmap.js'
import { ErrorState } from '../../components/ErrorState.jsx' import { ErrorState } from '../../components/ErrorState.jsx'
import { LoadingState } from '../../components/LoadingState.jsx' import { LoadingState } from '../../components/LoadingState.jsx'
@ -31,6 +35,7 @@ import { useInitiativeOperations } from '../../context/InitiativeOperationsConte
import { scopedPath } from '../../utils/routes.js' import { scopedPath } from '../../utils/routes.js'
import { listGateContributions } from '../../api/journey.js' import { listGateContributions } from '../../api/journey.js'
import { GateContributionsSection } from '../../components/GateContributionsSection.jsx' import { GateContributionsSection } from '../../components/GateContributionsSection.jsx'
import { GateDependenciesSection } from '../../components/GateDependenciesSection.jsx'
import { Modal } from '../../components/Modal.jsx' import { Modal } from '../../components/Modal.jsx'
import { RoadmapItemForm } from '../../components/RoadmapItemForm.jsx' import { RoadmapItemForm } from '../../components/RoadmapItemForm.jsx'
@ -100,6 +105,8 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
const [contributionsLoading, setContributionsLoading] = useState(true) const [contributionsLoading, setContributionsLoading] = useState(true)
const [contributionsError, setContributionsError] = useState(null) const [contributionsError, setContributionsError] = useState(null)
const [editingMeta, setEditingMeta] = useState(false) const [editingMeta, setEditingMeta] = useState(false)
const [dependencies, setDependencies] = useState([])
const [siblingItems, setSiblingItems] = useState([])
const loadContributions = useCallback(async () => { const loadContributions = useCallback(async () => {
setContributionsLoading(true) setContributionsLoading(true)
@ -124,12 +131,25 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
setItem(itemData) setItem(itemData)
setCriteria(criteriaData.items || []) setCriteria(criteriaData.items || [])
setProgress(criteriaData.progress || null) 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) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [itemId]) }, [itemId, initiativeId])
useEffect(() => { useEffect(() => {
load() load()
@ -207,6 +227,16 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
error={contributionsError} error={contributionsError}
/> />
<GateDependenciesSection
itemId={itemId}
siblingItems={siblingItems}
dependencies={dependencies}
canManage={canManage}
busy={busy}
onAdd={(body) => runAction(() => addRoadmapItemDependency(itemId, body))}
onDelete={(dependencyId) => runAction(() => deleteRoadmapDependency(dependencyId))}
/>
<ul className="item-list criterion-list"> <ul className="item-list criterion-list">
{criteria.map((crit) => ( {criteria.map((crit) => (
<li key={crit.id} className="list-item card-list-item"> <li key={crit.id} className="list-item card-list-item">

View File

@ -1760,3 +1760,27 @@
font-size: 0.875rem; 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;
}