From bd64340ca1fb35a67383160e75ba838ab596b964 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 11 Jul 2026 07:53:38 +0200 Subject: [PATCH] AP1.4c: Gate-Checkliste statt Freitext-DoD. Kriterien-Fortschritt in der Gate-Liste, Modal-UX fuer Checklisten und optionales Erst-Kriterium beim Anlegen. Co-authored-by: Cursor --- backend/routers/roadmap.py | 15 ++ backend/services/roadmap.py | 3 + backend/services/roadmap_criteria.py | 40 +++ backend/tests/test_ap14_roadmap.py | 22 ++ frontend/src/api/roadmap.js | 4 + .../src/components/GateCriteriaSection.jsx | 253 ++++++++++++++++++ frontend/src/components/GateCriterionForm.jsx | 81 ++++++ frontend/src/components/RoadmapItemForm.jsx | 26 +- .../src/components/RoadmapPlanSection.jsx | 30 ++- .../initiative/RoadmapItemDetailPage.jsx | 233 +++------------- 10 files changed, 509 insertions(+), 198 deletions(-) create mode 100644 frontend/src/components/GateCriteriaSection.jsx create mode 100644 frontend/src/components/GateCriterionForm.jsx 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 ( +
{ + e.preventDefault() + if (!title.trim()) return + await onSubmit({ + decision_title: title.trim(), + decision_description: description.trim(), + }) + setTitle('') + setDescription('') + }} + > + +