diff --git a/backend/routers/roadmap.py b/backend/routers/roadmap.py
index c55688b..1e0ce7b 100644
--- a/backend/routers/roadmap.py
+++ b/backend/routers/roadmap.py
@@ -26,6 +26,7 @@ class RoadmapItemCreateRequest(BaseModel):
sequencing_mode: Literal["sequential", "parallel", "optional"] = "sequential"
target_date: Optional[date] = None
sort_order: int = 0
+ initial_criterion_title: Optional[str] = Field(default=None, max_length=500)
class RoadmapItemUpdateRequest(BaseModel):
@@ -129,6 +130,19 @@ def list_initiative_roadmap_dependencies(
raise HTTPException(status_code=400, detail=str(exc)) from exc
+@initiative_router.get("/{initiative_id}/roadmap/criteria-progress")
+def list_initiative_roadmap_criteria_progress(
+ initiative_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
+):
+ try:
+ return criteria_service.criteria_progress_for_initiative(
+ tenant_id=ctx.tenant_id, initiative_id=initiative_id
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
@initiative_router.post("/{initiative_id}/roadmap/items", status_code=201)
def create_initiative_roadmap_item(
initiative_id: str,
@@ -148,6 +162,7 @@ def create_initiative_roadmap_item(
sequencing_mode=body.sequencing_mode,
target_date=body.target_date,
sort_order=body.sort_order,
+ initial_criterion_title=body.initial_criterion_title,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
diff --git a/backend/services/roadmap.py b/backend/services/roadmap.py
index 57b2b74..7d51f20 100644
--- a/backend/services/roadmap.py
+++ b/backend/services/roadmap.py
@@ -233,6 +233,7 @@ def create_roadmap_item(
sequencing_mode: SequencingMode = "sequential",
target_date: Optional[date] = None,
sort_order: int = 0,
+ initial_criterion_title: Optional[str] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
@@ -280,6 +281,8 @@ def create_roadmap_item(
cur,
tenant_id=tenant_id,
roadmap_item_id=row["id"],
+ title=(initial_criterion_title or "Gate allgemein").strip()
+ or "Gate allgemein",
description=goal_description or "",
)
_sync_milestone_compat_row(
diff --git a/backend/services/roadmap_criteria.py b/backend/services/roadmap_criteria.py
index beed977..aef7fab 100644
--- a/backend/services/roadmap_criteria.py
+++ b/backend/services/roadmap_criteria.py
@@ -562,6 +562,46 @@ def defer_criterion(
)
+def criteria_progress_for_initiative(
+ *, tenant_id: str, initiative_id: str
+) -> dict[str, dict[str, int]]:
+ from services.initiatives import get_initiative
+
+ if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
+ raise ValueError("Initiative nicht gefunden")
+
+ conn = get_connection()
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ """
+ SELECT
+ ri.id::text,
+ COUNT(c.id) AS total,
+ COUNT(c.id) FILTER (
+ WHERE c.status IN ('satisfied', 'waived', 'deferred')
+ ) AS closed
+ FROM roadmap_items ri
+ JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
+ LEFT JOIN roadmap_item_criteria c
+ ON c.roadmap_item_id = ri.id AND c.tenant_id = ri.tenant_id
+ WHERE ri.tenant_id = %s AND r.initiative_id = %s
+ GROUP BY ri.id
+ """,
+ (tenant_id, initiative_id),
+ )
+ return {
+ row[0]: {
+ "total": row[1],
+ "closed": row[2],
+ "open": row[1] - row[2],
+ }
+ for row in cur.fetchall()
+ }
+ finally:
+ conn.close()
+
+
def criteria_progress(*, tenant_id: str, item_id: str) -> dict[str, int]:
conn = get_connection()
try:
diff --git a/backend/tests/test_ap14_roadmap.py b/backend/tests/test_ap14_roadmap.py
index a55a186..3014ff5 100644
--- a/backend/tests/test_ap14_roadmap.py
+++ b/backend/tests/test_ap14_roadmap.py
@@ -196,3 +196,25 @@ def test_roadmap_dependency_duplicate_rejected(client):
)
assert dup.status_code == 400
assert "existiert bereits" in dup.json()["detail"]
+
+
+def test_initiative_roadmap_criteria_progress(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+ item = _create_roadmap_item(
+ client,
+ token,
+ initiative_id,
+ title="Gate mit Kriterium",
+ initial_criterion_title="Erstes Kriterium",
+ ).json()
+
+ progress = client.get(
+ f"/api/initiatives/{initiative_id}/roadmap/criteria-progress",
+ headers=_auth(token),
+ )
+ assert progress.status_code == 200
+ body = progress.json()
+ assert body[item["id"]]["total"] >= 1
+ assert body[item["id"]]["closed"] == 0
diff --git a/frontend/src/api/roadmap.js b/frontend/src/api/roadmap.js
index d6cae7b..48d9f91 100644
--- a/frontend/src/api/roadmap.js
+++ b/frontend/src/api/roadmap.js
@@ -8,6 +8,10 @@ export function listInitiativeRoadmapDependencies(initiativeId) {
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/dependencies`)
}
+export function listInitiativeRoadmapCriteriaProgress(initiativeId) {
+ return apiFetch(`/api/initiatives/${initiativeId}/roadmap/criteria-progress`)
+}
+
export function createInitiativeRoadmapItem(initiativeId, body) {
return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, {
method: 'POST',
diff --git a/frontend/src/components/GateCriteriaSection.jsx b/frontend/src/components/GateCriteriaSection.jsx
new file mode 100644
index 0000000..d76be99
--- /dev/null
+++ b/frontend/src/components/GateCriteriaSection.jsx
@@ -0,0 +1,253 @@
+import { useState } from 'react'
+import {
+ CRITERION_KIND_LABELS,
+ CRITERION_STATUS_LABELS,
+} from '../constants/status.js'
+import { Modal } from './Modal.jsx'
+import { GateCriterionForm } from './GateCriterionForm.jsx'
+import {
+ createRoadmapItemCriterion,
+ deleteRoadmapCriterion,
+ deferRoadmapCriterion,
+ satisfyRoadmapCriterion,
+ updateRoadmapCriterion,
+ waiveRoadmapCriterion,
+} from '../api/roadmap.js'
+
+function CriterionDecisionForm({ label, onSubmit, busy, onCancel }) {
+ const [title, setTitle] = useState('')
+ const [description, setDescription] = useState('')
+
+ return (
+
+ )
+}
+
+export function GateCriteriaSection({
+ itemId,
+ itemStatus = 'planned',
+ criteria = [],
+ progress = null,
+ canManage = false,
+ busy = false,
+ onChanged,
+ onAction,
+}) {
+ const [showCreate, setShowCreate] = useState(false)
+ const [editingCriterion, setEditingCriterion] = useState(null)
+ const [decisionModal, setDecisionModal] = useState(null)
+
+ const closedCount = progress?.closed ?? criteria.filter((c) =>
+ ['satisfied', 'waived', 'deferred'].includes(c.status),
+ ).length
+ const totalCount = progress?.total ?? criteria.length
+ const itemReached = itemStatus === 'reached'
+
+ async function runAction(fn) {
+ if (onAction) {
+ await onAction(fn)
+ return
+ }
+ await fn()
+ await onChanged?.()
+ }
+
+ return (
+
+
+
+
Checkliste
+
+ {closedCount}/{totalCount} Kriterien abgeschlossen — prüfbare Akzeptanz statt Freitext-DoD.
+
+
+ {canManage && (
+
+ )}
+
+
+ {criteria.length === 0 && (
+
+ Noch keine Kriterien — lege prüfbare Akzeptanzpunkte an.
+
+ )}
+
+
+ {criteria.map((crit) => (
+ -
+
+
{crit.title}
+
+ {CRITERION_KIND_LABELS[crit.criterion_kind] || crit.criterion_kind}
+
+ {crit.description &&
{crit.description}
}
+
+
+
+ {CRITERION_STATUS_LABELS[crit.status] || crit.status}
+
+ {canManage && crit.status === 'open' && (
+ <>
+
+
+
+ >
+ )}
+ {canManage && (
+
+ )}
+ {canManage && criteria.length > 1 && (
+
+ )}
+
+
+ ))}
+
+
+ setShowCreate(false)}>
+ setShowCreate(false)}
+ onSubmit={async (payload) => {
+ await runAction(async () => {
+ await createRoadmapItemCriterion(itemId, payload)
+ setShowCreate(false)
+ })
+ }}
+ />
+
+
+ setEditingCriterion(null)}
+ >
+ {editingCriterion && (
+ setEditingCriterion(null)}
+ onSubmit={async (payload) => {
+ await runAction(async () => {
+ await updateRoadmapCriterion(editingCriterion.id, payload)
+ setEditingCriterion(null)
+ })
+ }}
+ />
+ )}
+
+
+ setDecisionModal(null)}
+ >
+ {decisionModal && (
+ setDecisionModal(null)}
+ onSubmit={async (body) => {
+ await runAction(async () => {
+ const fn =
+ decisionModal.action === 'defer'
+ ? deferRoadmapCriterion
+ : waiveRoadmapCriterion
+ await fn(decisionModal.id, body)
+ setDecisionModal(null)
+ })
+ }}
+ />
+ )}
+
+
+ )
+}
diff --git a/frontend/src/components/GateCriterionForm.jsx b/frontend/src/components/GateCriterionForm.jsx
new file mode 100644
index 0000000..ef4643c
--- /dev/null
+++ b/frontend/src/components/GateCriterionForm.jsx
@@ -0,0 +1,81 @@
+import { CRITERION_KIND_LABELS, CRITERION_KINDS } from '../constants/status.js'
+
+export function GateCriterionForm({
+ initial = {},
+ onSubmit,
+ onCancel,
+ busy = false,
+ submitLabel = 'Speichern',
+ mode = 'create',
+ itemReached = false,
+}) {
+ async function handleSubmit(e) {
+ e.preventDefault()
+ const form = e.target
+ const payload = {
+ title: form.title.value.trim(),
+ description: form.description.value.trim(),
+ criterion_kind: form.criterion_kind.value,
+ }
+ if (mode === 'edit' && itemReached) {
+ payload.change_reason = form.change_reason.value.trim()
+ }
+ await onSubmit(payload)
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/RoadmapItemForm.jsx b/frontend/src/components/RoadmapItemForm.jsx
index 3304f3a..8fd1356 100644
--- a/frontend/src/components/RoadmapItemForm.jsx
+++ b/frontend/src/components/RoadmapItemForm.jsx
@@ -29,6 +29,12 @@ export function RoadmapItemForm({
item_type: form.item_type.value,
sequencing_mode: form.sequencing_mode.value,
}
+ if (mode === 'create') {
+ const initialCriterion = form.initial_criterion_title?.value?.trim()
+ if (initialCriterion) {
+ payload.initial_criterion_title = initialCriterion
+ }
+ }
if (mode === 'edit') {
payload.status = form.status.value
}
@@ -63,15 +69,31 @@ export function RoadmapItemForm({
/>
+ {mode === 'create' && (
+
+ )}
diff --git a/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx b/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx
index 5e0fe00..157928b 100644
--- a/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx
+++ b/frontend/src/pages/initiative/RoadmapItemDetailPage.jsx
@@ -1,16 +1,9 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
-import {
- createInitiativeEvidence,
-} from '../../api/evidence.js'
+import { createInitiativeEvidence } from '../../api/evidence.js'
import {
getRoadmapItem,
listRoadmapItemCriteria,
- createRoadmapItemCriterion,
- deleteRoadmapCriterion,
- satisfyRoadmapCriterion,
- waiveRoadmapCriterion,
- deferRoadmapCriterion,
verifyRoadmapItemReached,
reopenRoadmapItem,
updateRoadmapItem,
@@ -23,9 +16,6 @@ import { ErrorState } from '../../components/ErrorState.jsx'
import { LoadingState } from '../../components/LoadingState.jsx'
import { StatusBadge } from '../../components/StatusBadge.jsx'
import {
- CRITERION_KIND_LABELS,
- CRITERION_KINDS,
- CRITERION_STATUS_LABELS,
MILESTONE_STATUS_LABELS,
ROADMAP_ITEM_TYPE_LABELS,
SEQUENCING_MODE_LABELS,
@@ -36,52 +26,10 @@ 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 { GateCriteriaSection } from '../../components/GateCriteriaSection.jsx'
import { Modal } from '../../components/Modal.jsx'
import { RoadmapItemForm } from '../../components/RoadmapItemForm.jsx'
-function CriterionDecisionForm({ label, onSubmit, busy }) {
- const [title, setTitle] = useState('')
- const [description, setDescription] = useState('')
-
- return (
-
- )
-}
-
export function RoadmapItemDetailPage({ overrideItemId }) {
const { id: routeInitiativeId, itemId: routeItemId, gateId } = useParams()
const ops = useInitiativeOperations()
@@ -97,9 +45,6 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
- const [newTitle, setNewTitle] = useState('')
- const [newKind, setNewKind] = useState('manual')
- const [expandedDecision, setExpandedDecision] = useState(null)
const [reopenReason, setReopenReason] = useState('')
const [contributions, setContributions] = useState(null)
const [contributionsLoading, setContributionsLoading] = useState(true)
@@ -151,6 +96,16 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
}
}, [itemId, initiativeId])
+ const reloadCriteria = useCallback(async () => {
+ try {
+ const criteriaData = await listRoadmapItemCriteria(itemId)
+ setCriteria(criteriaData.items || [])
+ setProgress(criteriaData.progress || null)
+ } catch (err) {
+ setError(err.message)
+ }
+ }, [itemId])
+
useEffect(() => {
load()
}, [load])
@@ -159,6 +114,19 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
loadContributions()
}, [loadContributions])
+ async function runCriteriaAction(fn) {
+ setBusy(true)
+ setError(null)
+ try {
+ await fn()
+ await reloadCriteria()
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setBusy(false)
+ }
+ }
+
async function runAction(fn) {
setBusy(true)
setError(null)
@@ -176,9 +144,6 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
if (error && !item) return
if (!item) return
- const closedCount = progress?.closed ?? 0
- const totalCount = progress?.total ?? criteria.length
-
return (
@@ -199,7 +164,9 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
{' · '}
{SEQUENCING_MODE_LABELS[item.sequencing_mode] || item.sequencing_mode}
- {item.goal_description && {item.goal_description}
}
+ {item.goal_description && (
+ {item.goal_description}
+ )}
@@ -216,10 +183,6 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
-
- Checkliste: {closedCount}/{totalCount} Kriterien abgeschlossen
-
-
runAction(() => deleteRoadmapDependency(dependencyId))}
/>
-
-
- {canManage && (
-
- )}
+
{canManage && ['planned', 'active', 'at_risk'].includes(item.status) && (