diff --git a/backend/data_layer/initiative_snapshot.py b/backend/data_layer/initiative_snapshot.py
index 84bf4fe..4d36313 100644
--- a/backend/data_layer/initiative_snapshot.py
+++ b/backend/data_layer/initiative_snapshot.py
@@ -5,28 +5,17 @@ Baut aus flachen OM-Tabellen einen erklärbaren Graph für UI und spätere Steer
from __future__ import annotations
-from typing import Any, Literal, Optional
+from typing import Any, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from data_layer.attention import get_next_action_candidates_for_initiative
from steering.context import get_steering_context_dto
+from steering.signals.snapshot_signals import derive_initiative_signals
from services.initiatives import get_initiative
from tenant_context import TenantContext
-OperatingPhase = Literal[
- "capture",
- "triage",
- "structure",
- "commit",
- "execute",
- "verify",
- "review",
- "adapt",
- "closure",
-]
-
OPEN_BLOCKER = ("open", "in_progress")
OPEN_ACTION = ("open", "ready", "in_progress", "blocked", "review_required")
ACTIVE_INITIATIVE = ("active", "paused")
@@ -40,51 +29,6 @@ def _iso(value: Any) -> Optional[str]:
return value.isoformat() if value else None
-def _derive_operating_phase(
- *,
- initiative_status: str,
- open_actions: int,
- blocked_actions: int,
- review_required_actions: int,
- open_blockers: int,
- backlog_new: int,
- planned_reviews_due: int,
-) -> tuple[OperatingPhase, list[str]]:
- """Heuristische, erklärbare Phase — kein Lifecycle-State-Machine-Ersatz."""
- signals: list[str] = []
-
- if initiative_status in ("completed", "archived"):
- return "closure", ["initiative_terminal"]
-
- if open_actions == 0 and backlog_new > 0:
- signals.append("backlog_awaiting_commit")
- return "triage", signals
-
- if open_actions == 0 and backlog_new == 0:
- signals.append("no_open_work")
- return "structure", signals
-
- if blocked_actions > 0 or open_blockers > 0:
- if blocked_actions:
- signals.append("actions_blocked")
- if open_blockers:
- signals.append("open_blockers")
- return "execute", signals
-
- if review_required_actions > 0 or planned_reviews_due > 0:
- if review_required_actions:
- signals.append("actions_need_review")
- if planned_reviews_due:
- signals.append("reviews_due")
- return "review", signals
-
- if open_actions > 0:
- signals.append("work_in_progress")
- return "execute", signals
-
- return "adapt", signals
-
-
def get_initiative_steering_snapshot(
ctx: TenantContext, *, initiative_id: str
) -> Optional[dict[str, Any]]:
@@ -155,14 +99,16 @@ def get_initiative_steering_snapshot(
cur.execute(
"""
- SELECT id, title, status, target_date, created_at, updated_at
- FROM milestones
- WHERE tenant_id = %s AND initiative_id = %s
- ORDER BY updated_at DESC
+ SELECT id, title, status, target_date, item_type, sequencing_mode,
+ sort_order, created_at, updated_at
+ FROM roadmap_items ri
+ JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
+ WHERE ri.tenant_id = %s AND r.initiative_id = %s
+ ORDER BY ri.sort_order ASC, ri.updated_at DESC
""",
(ctx.tenant_id, initiative_id),
)
- milestones_raw = [dict(r) for r in cur.fetchall()]
+ roadmap_items_raw = [dict(r) for r in cur.fetchall()]
cur.execute(
"""
@@ -270,14 +216,17 @@ def get_initiative_steering_snapshot(
}
)
- milestones = []
- for m in milestones_raw:
+ roadmap_items: list[dict[str, Any]] = []
+ for m in roadmap_items_raw:
mid = _sid(m["id"])
- milestones.append(
+ roadmap_items.append(
{
"id": mid,
"title": m["title"],
"status": m["status"],
+ "item_type": m["item_type"],
+ "sequencing_mode": m["sequencing_mode"],
+ "sort_order": m.get("sort_order", 0),
"target_date": m["target_date"].isoformat() if m.get("target_date") else None,
"reviews": reviews_by_milestone.get(mid, []),
}
@@ -288,7 +237,7 @@ def get_initiative_steering_snapshot(
)
backlog_new = sum(1 for bi in backlog if bi["status"] in ("new", "triaged", "accepted"))
- phase, phase_signals = _derive_operating_phase(
+ signals = derive_initiative_signals(
initiative_status=initiative["status"],
open_actions=open_actions,
blocked_actions=blocked_actions,
@@ -301,7 +250,7 @@ def get_initiative_steering_snapshot(
upcoming_milestones = sorted(
[
m
- for m in milestones
+ for m in roadmap_items
if m["status"] in ("planned", "active", "at_risk")
],
key=lambda m: (m["target_date"] is None, m["target_date"] or ""),
@@ -322,10 +271,9 @@ def get_initiative_steering_snapshot(
"method_key": steering["method_key"],
"method_label": steering.get("method_label"),
"method_version": steering["method_version"],
- "operating_phase": phase,
- "operating_phase_deprecated": True,
- "phase_signals": phase_signals,
+ "signals": signals,
"upcoming_milestones": upcoming_milestones,
+ "upcoming_roadmap_items": upcoming_milestones,
"next_actions": next_actions,
"counts": {
"actions_open": open_actions,
@@ -341,7 +289,8 @@ def get_initiative_steering_snapshot(
"unlinked_reviews": len(unlinked_reviews),
},
"actions": actions,
- "milestones": milestones,
+ "roadmap_items": roadmap_items,
+ "milestones": roadmap_items,
"initiative_level": {
"blockers": unlinked_blockers,
"evidence": unlinked_evidence,
diff --git a/backend/main.py b/backend/main.py
index e3a1a66..a406bb7 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -68,6 +68,7 @@ from routers import ( # noqa: E402
initiatives,
me,
milestones,
+ roadmap,
prompts,
recurring,
reviews,
@@ -85,6 +86,8 @@ app.include_router(actions.router)
app.include_router(blockers.router)
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(evidence.router)
app.include_router(decisions.router)
app.include_router(reviews.router)
diff --git a/backend/migrations/010_roadmap_items.sql b/backend/migrations/010_roadmap_items.sql
new file mode 100644
index 0000000..49522c4
--- /dev/null
+++ b/backend/migrations/010_roadmap_items.sql
@@ -0,0 +1,86 @@
+-- AP1.4: Roadmap + RoadmapItem + Dependencies (methodenneutral)
+
+CREATE TABLE roadmaps (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
+ title VARCHAR(255) NOT NULL DEFAULT 'Plan',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (tenant_id, initiative_id)
+);
+
+CREATE INDEX idx_roadmaps_tenant_initiative ON roadmaps(tenant_id, initiative_id);
+
+CREATE TABLE roadmap_items (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ roadmap_id UUID NOT NULL REFERENCES roadmaps(id) ON DELETE CASCADE,
+ item_type VARCHAR(32) NOT NULL DEFAULT 'milestone'
+ CHECK (item_type IN ('milestone', 'review_gate', 'maturity_stage')),
+ title VARCHAR(255) NOT NULL,
+ goal_description TEXT NOT NULL DEFAULT '',
+ definition_of_done JSONB NOT NULL DEFAULT '[]'::jsonb,
+ status VARCHAR(32) NOT NULL DEFAULT 'planned'
+ CHECK (status IN ('planned', 'active', 'at_risk', 'reached', 'moved', 'discarded')),
+ sequencing_mode VARCHAR(32) NOT NULL DEFAULT 'sequential'
+ CHECK (sequencing_mode IN ('sequential', 'parallel', 'optional')),
+ target_date DATE NULL,
+ sort_order INT NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_roadmap_items_tenant_roadmap ON roadmap_items(tenant_id, roadmap_id);
+CREATE INDEX idx_roadmap_items_tenant_status ON roadmap_items(tenant_id, status);
+CREATE INDEX idx_roadmap_items_tenant_type ON roadmap_items(tenant_id, item_type);
+
+CREATE TABLE roadmap_item_dependencies (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ from_item_id UUID NOT NULL REFERENCES roadmap_items(id) ON DELETE CASCADE,
+ to_item_id UUID NOT NULL REFERENCES roadmap_items(id) ON DELETE CASCADE,
+ dependency_type VARCHAR(32) NOT NULL DEFAULT 'requires'
+ CHECK (dependency_type IN ('requires', 'blocks', 'related')),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (tenant_id, from_item_id, to_item_id, dependency_type)
+);
+
+CREATE INDEX idx_roadmap_deps_from ON roadmap_item_dependencies(tenant_id, from_item_id);
+CREATE INDEX idx_roadmap_deps_to ON roadmap_item_dependencies(tenant_id, to_item_id);
+
+ALTER TABLE backlog_items
+ ADD COLUMN roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
+
+ALTER TABLE actions
+ ADD COLUMN roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
+
+CREATE INDEX idx_backlog_roadmap_item ON backlog_items(tenant_id, roadmap_item_id)
+ WHERE roadmap_item_id IS NOT NULL;
+CREATE INDEX idx_actions_roadmap_item ON actions(tenant_id, roadmap_item_id)
+ WHERE roadmap_item_id IS NOT NULL;
+
+-- Ein Plan pro Initiative
+INSERT INTO roadmaps (tenant_id, initiative_id, title)
+SELECT tenant_id, id, 'Plan' FROM initiatives;
+
+-- Bestehende Meilensteine → RoadmapItems (IDs beibehalten für Evidence/Review-FKs)
+INSERT INTO roadmap_items (
+ id, tenant_id, roadmap_id, item_type, title, goal_description, status,
+ sequencing_mode, target_date, sort_order, created_at, updated_at
+)
+SELECT
+ m.id,
+ m.tenant_id,
+ r.id,
+ 'milestone',
+ m.title,
+ m.goal_description,
+ m.status,
+ 'sequential',
+ m.target_date,
+ 0,
+ m.created_at,
+ m.updated_at
+FROM milestones m
+JOIN roadmaps r ON r.initiative_id = m.initiative_id AND r.tenant_id = m.tenant_id;
diff --git a/backend/routers/roadmap.py b/backend/routers/roadmap.py
new file mode 100644
index 0000000..015044c
--- /dev/null
+++ b/backend/routers/roadmap.py
@@ -0,0 +1,198 @@
+"""Roadmap API — AP1.4."""
+
+from __future__ import annotations
+
+from datetime import date
+from typing import Any, Literal, Optional
+
+from capabilities import require_capability
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel, Field
+from services import roadmap as roadmap_service
+from tenant_context import TenantContext
+
+router = APIRouter(tags=["roadmap"])
+
+
+class RoadmapItemCreateRequest(BaseModel):
+ title: str = Field(min_length=1, max_length=255)
+ item_type: Literal["milestone", "review_gate", "maturity_stage"] = "milestone"
+ goal_description: str = ""
+ definition_of_done: list[Any] = Field(default_factory=list)
+ status: Literal[
+ "planned", "active", "at_risk", "reached", "moved", "discarded"
+ ] = "planned"
+ sequencing_mode: Literal["sequential", "parallel", "optional"] = "sequential"
+ target_date: Optional[date] = None
+ sort_order: int = 0
+
+
+class RoadmapItemUpdateRequest(BaseModel):
+ title: Optional[str] = Field(default=None, min_length=1, max_length=255)
+ item_type: Optional[Literal["milestone", "review_gate", "maturity_stage"]] = None
+ goal_description: Optional[str] = None
+ definition_of_done: Optional[list[Any]] = None
+ status: Optional[
+ Literal["planned", "active", "at_risk", "reached", "moved", "discarded"]
+ ] = None
+ sequencing_mode: Optional[Literal["sequential", "parallel", "optional"]] = None
+ target_date: Optional[date] = None
+ clear_target_date: bool = False
+ sort_order: Optional[int] = None
+
+
+class DependencyCreateRequest(BaseModel):
+ to_item_id: str
+ dependency_type: Literal["requires", "blocks", "related"] = "requires"
+
+
+initiative_router = APIRouter(prefix="/api/initiatives", tags=["roadmap"])
+items_router = APIRouter(prefix="/api/roadmap-items", tags=["roadmap"])
+
+
+@initiative_router.get("/{initiative_id}/roadmap")
+def get_initiative_roadmap(
+ initiative_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
+):
+ try:
+ roadmap = roadmap_service.get_roadmap_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
+ if not roadmap:
+ return {"initiative_id": initiative_id, "items": []}
+ items = roadmap_service.list_roadmap_items_for_initiative(
+ tenant_id=ctx.tenant_id, initiative_id=initiative_id
+ )
+ return {**roadmap, "items": items}
+
+
+@initiative_router.get("/{initiative_id}/roadmap/items")
+def list_initiative_roadmap_items(
+ initiative_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
+):
+ try:
+ return roadmap_service.list_roadmap_items_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,
+ body: RoadmapItemCreateRequest,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
+):
+ try:
+ return roadmap_service.create_roadmap_item(
+ tenant_id=ctx.tenant_id,
+ initiative_id=initiative_id,
+ user_id=ctx.user_id,
+ title=body.title,
+ item_type=body.item_type,
+ goal_description=body.goal_description,
+ definition_of_done=body.definition_of_done,
+ status=body.status,
+ sequencing_mode=body.sequencing_mode,
+ target_date=body.target_date,
+ sort_order=body.sort_order,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@items_router.get("/{item_id}")
+def get_roadmap_item(
+ item_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
+):
+ item = roadmap_service.get_roadmap_item(tenant_id=ctx.tenant_id, item_id=item_id)
+ if not item:
+ raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
+ return item
+
+
+@items_router.patch("/{item_id}")
+def update_roadmap_item(
+ item_id: str,
+ body: RoadmapItemUpdateRequest,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
+):
+ try:
+ item = roadmap_service.update_roadmap_item(
+ tenant_id=ctx.tenant_id,
+ item_id=item_id,
+ user_id=ctx.user_id,
+ title=body.title,
+ item_type=body.item_type,
+ goal_description=body.goal_description,
+ definition_of_done=body.definition_of_done,
+ status=body.status,
+ sequencing_mode=body.sequencing_mode,
+ target_date=body.target_date,
+ clear_target_date=body.clear_target_date,
+ sort_order=body.sort_order,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ if not item:
+ raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
+ return item
+
+
+@items_router.delete("/{item_id}", status_code=204)
+def delete_roadmap_item(
+ item_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
+):
+ if not roadmap_service.delete_roadmap_item(
+ tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id
+ ):
+ raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
+
+
+@items_router.post("/{item_id}/verify-reached")
+def verify_roadmap_item_reached(
+ item_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
+):
+ try:
+ return roadmap_service.verify_reached(
+ tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@items_router.get("/{item_id}/dependencies")
+def list_roadmap_item_dependencies(
+ item_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
+):
+ try:
+ return roadmap_service.list_dependencies(tenant_id=ctx.tenant_id, item_id=item_id)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
+@items_router.post("/{item_id}/dependencies", status_code=201)
+def add_roadmap_item_dependency(
+ item_id: str,
+ body: DependencyCreateRequest,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
+):
+ try:
+ return roadmap_service.add_dependency(
+ tenant_id=ctx.tenant_id,
+ from_item_id=item_id,
+ to_item_id=body.to_item_id,
+ dependency_type=body.dependency_type,
+ user_id=ctx.user_id,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
diff --git a/backend/services/evidence.py b/backend/services/evidence.py
index f82d31a..e4c23e2 100644
--- a/backend/services/evidence.py
+++ b/backend/services/evidence.py
@@ -85,6 +85,18 @@ def _milestone_in_initiative(
""",
(milestone_id, tenant_id, initiative_id),
)
+ if cur.fetchone():
+ return True
+ cur.execute(
+ """
+ SELECT 1
+ FROM roadmap_items ri
+ JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
+ WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s
+ AND ri.item_type = 'milestone'
+ """,
+ (milestone_id, tenant_id, initiative_id),
+ )
return cur.fetchone() is not None
finally:
conn.close()
diff --git a/backend/services/milestones.py b/backend/services/milestones.py
index dc4d553..2a52a1b 100644
--- a/backend/services/milestones.py
+++ b/backend/services/milestones.py
@@ -1,46 +1,31 @@
-"""Milestone service — tenant-scoped CRUD (AP0.8d)."""
+"""Milestone service — Kompatibilitäts-Wrapper auf RoadmapItems (AP0.8d → AP1.4)."""
from __future__ import annotations
from datetime import date
from typing import Any, Literal, Optional
-from psycopg2.extras import RealDictCursor
-
-from db import get_connection
-from services.audit import log_audit
-from services.initiatives import get_initiative
+from services import roadmap as roadmap_service
MilestoneStatus = Literal[
"planned", "active", "at_risk", "reached", "moved", "discarded"
]
-MILESTONE_STATUSES = frozenset(
- {"planned", "active", "at_risk", "reached", "moved", "discarded"}
-)
+MILESTONE_STATUSES = roadmap_service.ROADMAP_ITEM_STATUSES
-def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
- result = dict(row)
- for key in ("id", "tenant_id", "initiative_id"):
- if result.get(key):
- result[key] = str(result[key])
- if result.get("target_date"):
- result["target_date"] = (
- result["target_date"].isoformat()
- if hasattr(result["target_date"], "isoformat")
- else str(result["target_date"])
- )
- if result.get("created_at"):
- result["created_at"] = result["created_at"].isoformat()
- if result.get("updated_at"):
- result["updated_at"] = result["updated_at"].isoformat()
- return result
-
-
-def _validate_status(status: str) -> None:
- if status not in MILESTONE_STATUSES:
- raise ValueError(f"Ungültiger Meilenstein-Status: {status}")
+def _as_milestone(item: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "id": item["id"],
+ "tenant_id": item["tenant_id"],
+ "initiative_id": item["initiative_id"],
+ "title": item["title"],
+ "goal_description": item.get("goal_description", ""),
+ "status": item["status"],
+ "target_date": item.get("target_date"),
+ "created_at": item["created_at"],
+ "updated_at": item["updated_at"],
+ }
def create_milestone(
@@ -53,80 +38,31 @@ def create_milestone(
target_date: Optional[date] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
- title = title.strip()
- if not title:
- raise ValueError("Titel ist erforderlich")
- _validate_status(status)
- if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
- raise ValueError("Initiative nicht gefunden")
-
- conn = get_connection()
- try:
- with conn.cursor(cursor_factory=RealDictCursor) as cur:
- cur.execute(
- """
- INSERT INTO milestones (
- tenant_id, initiative_id, title, goal_description, status, target_date
- )
- VALUES (%s, %s, %s, %s, %s, %s)
- RETURNING id, tenant_id, initiative_id, title, goal_description, status,
- target_date, created_at, updated_at
- """,
- (tenant_id, initiative_id, title, goal_description, status, target_date),
- )
- row = _serialize_row(dict(cur.fetchone()))
- conn.commit()
- finally:
- conn.close()
-
- log_audit(
- "milestone.created",
- user_id=user_id,
+ item = roadmap_service.create_roadmap_item(
tenant_id=tenant_id,
- details={"milestone_id": row["id"], "initiative_id": initiative_id, "title": title},
+ initiative_id=initiative_id,
+ title=title,
+ item_type="milestone",
+ goal_description=goal_description,
+ status=status,
+ target_date=target_date,
+ user_id=user_id,
)
- return row
+ return _as_milestone(item)
def list_milestones_for_initiative(*, tenant_id: str, initiative_id: str) -> list[dict[str, Any]]:
- if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
- raise ValueError("Initiative nicht gefunden")
-
- conn = get_connection()
- try:
- with conn.cursor(cursor_factory=RealDictCursor) as cur:
- cur.execute(
- """
- SELECT id, tenant_id, initiative_id, title, goal_description, status,
- target_date, created_at, updated_at
- FROM milestones
- WHERE tenant_id = %s AND initiative_id = %s
- ORDER BY target_date NULLS LAST, updated_at DESC, title
- """,
- (tenant_id, initiative_id),
- )
- return [_serialize_row(dict(r)) for r in cur.fetchall()]
- finally:
- conn.close()
+ items = roadmap_service.list_roadmap_items_for_initiative(
+ tenant_id=tenant_id, initiative_id=initiative_id
+ )
+ return [_as_milestone(i) for i in items if i["item_type"] == "milestone"]
def get_milestone(*, tenant_id: str, milestone_id: str) -> Optional[dict[str, Any]]:
- conn = get_connection()
- try:
- with conn.cursor(cursor_factory=RealDictCursor) as cur:
- cur.execute(
- """
- SELECT id, tenant_id, initiative_id, title, goal_description, status,
- target_date, created_at, updated_at
- FROM milestones
- WHERE id = %s AND tenant_id = %s
- """,
- (milestone_id, tenant_id),
- )
- row = cur.fetchone()
- return _serialize_row(dict(row)) if row else None
- finally:
- conn.close()
+ item = roadmap_service.get_roadmap_item(tenant_id=tenant_id, item_id=milestone_id)
+ if not item or item["item_type"] != "milestone":
+ return None
+ return _as_milestone(item)
def update_milestone(
@@ -140,78 +76,17 @@ def update_milestone(
target_date: Optional[date] = None,
clear_target_date: bool = False,
) -> Optional[dict[str, Any]]:
- existing = get_milestone(tenant_id=tenant_id, milestone_id=milestone_id)
- if not existing:
- return None
-
- old_status = existing["status"]
- updates: list[str] = []
- params: list[Any] = []
-
- if title is not None:
- title = title.strip()
- if not title:
- raise ValueError("Titel ist erforderlich")
- updates.append("title = %s")
- params.append(title)
- if goal_description is not None:
- updates.append("goal_description = %s")
- params.append(goal_description)
- if status is not None:
- _validate_status(status)
- updates.append("status = %s")
- params.append(status)
- if clear_target_date:
- updates.append("target_date = NULL")
- elif target_date is not None:
- updates.append("target_date = %s")
- params.append(target_date)
-
- if not updates:
- return existing
-
- updates.append("updated_at = NOW()")
- params.extend([milestone_id, tenant_id])
-
- conn = get_connection()
- try:
- with conn.cursor(cursor_factory=RealDictCursor) as cur:
- cur.execute(
- f"""
- UPDATE milestones
- SET {", ".join(updates)}
- WHERE id = %s AND tenant_id = %s
- RETURNING id, tenant_id, initiative_id, title, goal_description, status,
- target_date, created_at, updated_at
- """,
- params,
- )
- row = cur.fetchone()
- if not row:
- return None
- result = _serialize_row(dict(row))
- conn.commit()
- finally:
- conn.close()
-
- log_audit(
- "milestone.updated",
- user_id=user_id,
+ item = roadmap_service.update_roadmap_item(
tenant_id=tenant_id,
- details={"milestone_id": milestone_id},
+ item_id=milestone_id,
+ user_id=user_id,
+ title=title,
+ goal_description=goal_description,
+ status=status,
+ target_date=target_date,
+ clear_target_date=clear_target_date,
)
- if status is not None and status != old_status:
- log_audit(
- "milestone.status_changed",
- user_id=user_id,
- tenant_id=tenant_id,
- details={
- "milestone_id": milestone_id,
- "from_status": old_status,
- "to_status": status,
- },
- )
- return result
+ return _as_milestone(item) if item else None
def delete_milestone(
@@ -220,23 +95,9 @@ def delete_milestone(
milestone_id: str,
user_id: Optional[str] = None,
) -> bool:
- conn = get_connection()
- try:
- with conn.cursor() as cur:
- cur.execute(
- "DELETE FROM milestones WHERE id = %s AND tenant_id = %s RETURNING id",
- (milestone_id, tenant_id),
- )
- deleted = cur.fetchone() is not None
- conn.commit()
- finally:
- conn.close()
-
- if deleted:
- log_audit(
- "milestone.deleted",
- user_id=user_id,
- tenant_id=tenant_id,
- details={"milestone_id": milestone_id},
- )
- return deleted
+ item = roadmap_service.get_roadmap_item(tenant_id=tenant_id, item_id=milestone_id)
+ if not item or item["item_type"] != "milestone":
+ return False
+ return roadmap_service.delete_roadmap_item(
+ tenant_id=tenant_id, item_id=milestone_id, user_id=user_id
+ )
diff --git a/backend/services/roadmap.py b/backend/services/roadmap.py
new file mode 100644
index 0000000..824f08a
--- /dev/null
+++ b/backend/services/roadmap.py
@@ -0,0 +1,621 @@
+"""Roadmap service — tenant-scoped Plan/Gates (AP1.4)."""
+
+from __future__ import annotations
+
+import json
+from datetime import date
+from typing import Any, Literal, Optional
+
+from psycopg2.extras import RealDictCursor
+
+from db import get_connection
+from services.audit import log_audit
+from services.initiatives import get_initiative
+
+RoadmapItemType = Literal["milestone", "review_gate", "maturity_stage"]
+RoadmapItemStatus = Literal[
+ "planned", "active", "at_risk", "reached", "moved", "discarded"
+]
+SequencingMode = Literal["sequential", "parallel", "optional"]
+DependencyType = Literal["requires", "blocks", "related"]
+
+ROADMAP_ITEM_TYPES = frozenset({"milestone", "review_gate", "maturity_stage"})
+ROADMAP_ITEM_STATUSES = frozenset(
+ {"planned", "active", "at_risk", "reached", "moved", "discarded"}
+)
+SEQUENCING_MODES = frozenset({"sequential", "parallel", "optional"})
+DEPENDENCY_TYPES = frozenset({"requires", "blocks", "related"})
+
+TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"})
+
+
+def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
+ result = dict(row)
+ for key in ("id", "tenant_id", "roadmap_id", "initiative_id", "from_item_id", "to_item_id"):
+ if result.get(key):
+ result[key] = str(result[key])
+ if result.get("target_date"):
+ result["target_date"] = (
+ result["target_date"].isoformat()
+ if hasattr(result["target_date"], "isoformat")
+ else str(result["target_date"])
+ )
+ for ts in ("created_at", "updated_at"):
+ if result.get(ts):
+ result[ts] = result[ts].isoformat()
+ dod = result.get("definition_of_done")
+ if isinstance(dod, str):
+ result["definition_of_done"] = json.loads(dod)
+ return result
+
+
+def _validate_item_type(item_type: str) -> None:
+ if item_type not in ROADMAP_ITEM_TYPES:
+ raise ValueError(f"Ungültiger RoadmapItem-Typ: {item_type}")
+
+
+def _validate_status(status: str) -> None:
+ if status not in ROADMAP_ITEM_STATUSES:
+ raise ValueError(f"Ungültiger RoadmapItem-Status: {status}")
+
+
+def _validate_sequencing_mode(mode: str) -> None:
+ if mode not in SEQUENCING_MODES:
+ raise ValueError(f"Ungültiger sequencing_mode: {mode}")
+
+
+def _validate_dependency_type(dep_type: str) -> None:
+ if dep_type not in DEPENDENCY_TYPES:
+ raise ValueError(f"Ungültiger dependency_type: {dep_type}")
+
+
+def _sync_milestone_compat_row(
+ cur,
+ *,
+ tenant_id: str,
+ initiative_id: str,
+ item: dict[str, Any],
+) -> None:
+ """Hält milestones-Tabelle für Evidence/Review-FKs synchron (Compat bis Drop)."""
+ if item["item_type"] != "milestone":
+ cur.execute(
+ "DELETE FROM milestones WHERE id = %s AND tenant_id = %s",
+ (item["id"], tenant_id),
+ )
+ return
+
+ target_date = item.get("target_date")
+ if target_date and isinstance(target_date, str):
+ target_date = date.fromisoformat(target_date)
+
+ cur.execute(
+ """
+ INSERT INTO milestones (
+ id, tenant_id, initiative_id, title, goal_description, status, target_date
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
+ ON CONFLICT (id) DO UPDATE SET
+ title = EXCLUDED.title,
+ goal_description = EXCLUDED.goal_description,
+ status = EXCLUDED.status,
+ target_date = EXCLUDED.target_date,
+ updated_at = NOW()
+ """,
+ (
+ item["id"],
+ tenant_id,
+ initiative_id,
+ item["title"],
+ item.get("goal_description", ""),
+ item["status"],
+ target_date,
+ ),
+ )
+
+
+def ensure_roadmap(*, tenant_id: str, initiative_id: str) -> dict[str, Any]:
+ if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
+ raise ValueError("Initiative nicht gefunden")
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ SELECT id, tenant_id, initiative_id, title, created_at, updated_at
+ FROM roadmaps
+ WHERE tenant_id = %s AND initiative_id = %s
+ """,
+ (tenant_id, initiative_id),
+ )
+ row = cur.fetchone()
+ if row:
+ return _serialize_row(dict(row))
+
+ cur.execute(
+ """
+ INSERT INTO roadmaps (tenant_id, initiative_id, title)
+ VALUES (%s, %s, 'Plan')
+ RETURNING id, tenant_id, initiative_id, title, created_at, updated_at
+ """,
+ (tenant_id, initiative_id),
+ )
+ result = _serialize_row(dict(cur.fetchone()))
+ conn.commit()
+ return result
+ finally:
+ conn.close()
+
+
+def get_roadmap_for_initiative(*, tenant_id: str, initiative_id: str) -> Optional[dict[str, Any]]:
+ if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
+ raise ValueError("Initiative nicht gefunden")
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ SELECT id, tenant_id, initiative_id, title, created_at, updated_at
+ FROM roadmaps
+ WHERE tenant_id = %s AND initiative_id = %s
+ """,
+ (tenant_id, initiative_id),
+ )
+ row = cur.fetchone()
+ return _serialize_row(dict(row)) if row else None
+ finally:
+ conn.close()
+
+
+def list_roadmap_items_for_initiative(
+ *, tenant_id: str, initiative_id: str
+) -> list[dict[str, Any]]:
+ roadmap = get_roadmap_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
+ if not roadmap:
+ return []
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ SELECT
+ ri.id, ri.tenant_id, ri.roadmap_id, r.initiative_id,
+ ri.item_type, ri.title, ri.goal_description, ri.definition_of_done,
+ ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order,
+ ri.created_at, ri.updated_at
+ FROM roadmap_items ri
+ JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
+ WHERE ri.tenant_id = %s AND r.initiative_id = %s
+ ORDER BY ri.sort_order ASC, ri.target_date NULLS LAST, ri.title
+ """,
+ (tenant_id, initiative_id),
+ )
+ return [_serialize_row(dict(r)) for r in cur.fetchall()]
+ finally:
+ conn.close()
+
+
+def get_roadmap_item(*, tenant_id: str, item_id: str) -> Optional[dict[str, Any]]:
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ SELECT
+ ri.id, ri.tenant_id, ri.roadmap_id, r.initiative_id,
+ ri.item_type, ri.title, ri.goal_description, ri.definition_of_done,
+ ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order,
+ ri.created_at, ri.updated_at
+ FROM roadmap_items ri
+ JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
+ WHERE ri.id = %s AND ri.tenant_id = %s
+ """,
+ (item_id, tenant_id),
+ )
+ row = cur.fetchone()
+ return _serialize_row(dict(row)) if row else None
+ finally:
+ conn.close()
+
+
+def create_roadmap_item(
+ *,
+ tenant_id: str,
+ initiative_id: str,
+ title: str,
+ item_type: RoadmapItemType = "milestone",
+ goal_description: str = "",
+ definition_of_done: Optional[list[Any]] = None,
+ status: RoadmapItemStatus = "planned",
+ sequencing_mode: SequencingMode = "sequential",
+ target_date: Optional[date] = None,
+ sort_order: int = 0,
+ user_id: Optional[str] = None,
+) -> dict[str, Any]:
+ title = title.strip()
+ if not title:
+ raise ValueError("Titel ist erforderlich")
+ _validate_item_type(item_type)
+ _validate_status(status)
+ if status == "reached":
+ raise ValueError("Status 'reached' nur über verify-reached")
+ _validate_sequencing_mode(sequencing_mode)
+
+ roadmap = ensure_roadmap(tenant_id=tenant_id, initiative_id=initiative_id)
+ dod = definition_of_done if definition_of_done is not None else []
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ INSERT INTO roadmap_items (
+ tenant_id, roadmap_id, item_type, title, goal_description,
+ definition_of_done, status, sequencing_mode, target_date, sort_order
+ )
+ VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s)
+ RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
+ definition_of_done, status, sequencing_mode, target_date,
+ sort_order, created_at, updated_at
+ """,
+ (
+ tenant_id,
+ roadmap["id"],
+ item_type,
+ title,
+ goal_description,
+ json.dumps(dod),
+ status,
+ sequencing_mode,
+ target_date,
+ sort_order,
+ ),
+ )
+ row = _serialize_row(dict(cur.fetchone()))
+ row["initiative_id"] = initiative_id
+ _sync_milestone_compat_row(
+ cur,
+ tenant_id=tenant_id,
+ initiative_id=initiative_id,
+ item=row,
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ log_audit(
+ "roadmap_item.created",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={
+ "roadmap_item_id": row["id"],
+ "initiative_id": initiative_id,
+ "item_type": item_type,
+ "title": title,
+ },
+ )
+ return row
+
+
+def update_roadmap_item(
+ *,
+ tenant_id: str,
+ item_id: str,
+ user_id: Optional[str] = None,
+ title: Optional[str] = None,
+ goal_description: Optional[str] = None,
+ definition_of_done: Optional[list[Any]] = None,
+ item_type: Optional[RoadmapItemType] = None,
+ status: Optional[RoadmapItemStatus] = None,
+ sequencing_mode: Optional[SequencingMode] = None,
+ target_date: Optional[date] = None,
+ clear_target_date: bool = False,
+ sort_order: Optional[int] = None,
+) -> Optional[dict[str, Any]]:
+ existing = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
+ if not existing:
+ return None
+
+ old_status = existing["status"]
+ updates: list[str] = []
+ params: list[Any] = []
+
+ if title is not None:
+ title = title.strip()
+ if not title:
+ raise ValueError("Titel ist erforderlich")
+ updates.append("title = %s")
+ params.append(title)
+ if goal_description is not None:
+ updates.append("goal_description = %s")
+ params.append(goal_description)
+ if definition_of_done is not None:
+ updates.append("definition_of_done = %s::jsonb")
+ params.append(json.dumps(definition_of_done))
+ if item_type is not None:
+ _validate_item_type(item_type)
+ updates.append("item_type = %s")
+ params.append(item_type)
+ if status is not None:
+ _validate_status(status)
+ if status in TERMINAL_STATUSES and status == "reached":
+ raise ValueError(
+ "Status 'reached' nur über verify-reached — nicht direkt setzen"
+ )
+ if status in ("moved", "discarded"):
+ raise ValueError(
+ "Status moved/discarded erfordert Decision — noch nicht implementiert"
+ )
+ updates.append("status = %s")
+ params.append(status)
+ if sequencing_mode is not None:
+ _validate_sequencing_mode(sequencing_mode)
+ updates.append("sequencing_mode = %s")
+ params.append(sequencing_mode)
+ if clear_target_date:
+ updates.append("target_date = NULL")
+ elif target_date is not None:
+ updates.append("target_date = %s")
+ params.append(target_date)
+ if sort_order is not None:
+ updates.append("sort_order = %s")
+ params.append(sort_order)
+
+ if not updates:
+ return existing
+
+ updates.append("updated_at = NOW()")
+ params.extend([item_id, tenant_id])
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ f"""
+ UPDATE roadmap_items
+ SET {", ".join(updates)}
+ WHERE id = %s AND tenant_id = %s
+ RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
+ definition_of_done, status, sequencing_mode, target_date,
+ sort_order, created_at, updated_at
+ """,
+ params,
+ )
+ row = cur.fetchone()
+ if not row:
+ return None
+ result = _serialize_row(dict(row))
+ result["initiative_id"] = existing["initiative_id"]
+ _sync_milestone_compat_row(
+ cur,
+ tenant_id=tenant_id,
+ initiative_id=existing["initiative_id"],
+ item=result,
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ log_audit(
+ "roadmap_item.updated",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={"roadmap_item_id": item_id},
+ )
+ if status is not None and status != old_status:
+ log_audit(
+ "roadmap_item.status_changed",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={
+ "roadmap_item_id": item_id,
+ "from_status": old_status,
+ "to_status": status,
+ },
+ )
+ return result
+
+
+def delete_roadmap_item(
+ *, tenant_id: str, item_id: str, user_id: Optional[str] = None
+) -> bool:
+ conn = get_connection()
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ "DELETE FROM milestones WHERE id = %s AND tenant_id = %s",
+ (item_id, tenant_id),
+ )
+ cur.execute(
+ "DELETE FROM roadmap_items WHERE id = %s AND tenant_id = %s RETURNING id",
+ (item_id, tenant_id),
+ )
+ deleted = cur.fetchone() is not None
+ conn.commit()
+ finally:
+ conn.close()
+
+ if deleted:
+ log_audit(
+ "roadmap_item.deleted",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={"roadmap_item_id": item_id},
+ )
+ return deleted
+
+
+def list_dependencies(*, tenant_id: str, item_id: str) -> list[dict[str, Any]]:
+ item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
+ if not item:
+ raise ValueError("RoadmapItem nicht gefunden")
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ SELECT id, tenant_id, from_item_id, to_item_id, dependency_type, created_at
+ FROM roadmap_item_dependencies
+ WHERE tenant_id = %s AND (from_item_id = %s OR to_item_id = %s)
+ ORDER BY created_at ASC
+ """,
+ (tenant_id, item_id, item_id),
+ )
+ items = []
+ for row in cur.fetchall():
+ dep = dict(row)
+ for key in ("id", "tenant_id", "from_item_id", "to_item_id"):
+ dep[key] = str(dep[key])
+ if dep.get("created_at"):
+ dep["created_at"] = dep["created_at"].isoformat()
+ items.append(dep)
+ return items
+ finally:
+ conn.close()
+
+
+def add_dependency(
+ *,
+ tenant_id: str,
+ from_item_id: str,
+ to_item_id: str,
+ dependency_type: DependencyType = "requires",
+ user_id: Optional[str] = None,
+) -> dict[str, Any]:
+ _validate_dependency_type(dependency_type)
+ if from_item_id == to_item_id:
+ raise ValueError("Abhängigkeit auf sich selbst nicht erlaubt")
+
+ from_item = get_roadmap_item(tenant_id=tenant_id, item_id=from_item_id)
+ to_item = get_roadmap_item(tenant_id=tenant_id, item_id=to_item_id)
+ if not from_item or not to_item:
+ raise ValueError("RoadmapItem nicht gefunden")
+ if from_item["initiative_id"] != to_item["initiative_id"]:
+ raise ValueError("Abhängigkeiten nur innerhalb eines Vorhabens")
+
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ INSERT INTO roadmap_item_dependencies (
+ tenant_id, from_item_id, to_item_id, dependency_type
+ )
+ VALUES (%s, %s, %s, %s)
+ RETURNING id, tenant_id, from_item_id, to_item_id, dependency_type, created_at
+ """,
+ (tenant_id, from_item_id, to_item_id, dependency_type),
+ )
+ row = dict(cur.fetchone())
+ for key in ("id", "tenant_id", "from_item_id", "to_item_id"):
+ row[key] = str(row[key])
+ row["created_at"] = row["created_at"].isoformat()
+ conn.commit()
+ finally:
+ conn.close()
+
+ log_audit(
+ "roadmap_item.dependency_added",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={
+ "from_item_id": from_item_id,
+ "to_item_id": to_item_id,
+ "dependency_type": dependency_type,
+ },
+ )
+ return row
+
+
+def verify_reached(
+ *, tenant_id: str, item_id: str, user_id: Optional[str] = None
+) -> dict[str, Any]:
+ """Gate-Verify: reached nur mit accepted Evidence, completed Review oder gate_override Decision."""
+ item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
+ if not item:
+ raise ValueError("RoadmapItem nicht gefunden")
+ if item["status"] in TERMINAL_STATUSES:
+ raise ValueError(f"RoadmapItem bereits terminal: {item['status']}")
+
+ initiative_id = item["initiative_id"]
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ SELECT 1 FROM evidence
+ WHERE tenant_id = %s AND initiative_id = %s
+ AND status = 'accepted'
+ AND milestone_id = %s
+ LIMIT 1
+ """,
+ (tenant_id, initiative_id, item_id),
+ )
+ if cur.fetchone():
+ verify_reason = "evidence_accepted"
+ else:
+ cur.execute(
+ """
+ SELECT 1 FROM reviews
+ WHERE tenant_id = %s AND initiative_id = %s
+ AND status = 'completed'
+ AND (milestone_id = %s)
+ LIMIT 1
+ """,
+ (tenant_id, initiative_id, item_id),
+ )
+ if cur.fetchone():
+ verify_reason = "review_completed"
+ else:
+ cur.execute(
+ """
+ SELECT 1 FROM decisions
+ WHERE tenant_id = %s AND initiative_id = %s
+ AND status = 'decided'
+ AND title ILIKE %s
+ LIMIT 1
+ """,
+ (tenant_id, initiative_id, f"gate_override:{item_id}%"),
+ )
+ if cur.fetchone():
+ verify_reason = "gate_override_decision"
+ else:
+ raise ValueError(
+ "Verify fehlgeschlagen — accepted Evidence, completed Review "
+ "oder gate_override Decision erforderlich"
+ )
+
+ cur.execute(
+ """
+ UPDATE roadmap_items
+ SET status = 'reached', updated_at = NOW()
+ WHERE id = %s AND tenant_id = %s
+ RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
+ definition_of_done, status, sequencing_mode, target_date,
+ sort_order, created_at, updated_at
+ """,
+ (item_id, tenant_id),
+ )
+ row = _serialize_row(dict(cur.fetchone()))
+ row["initiative_id"] = initiative_id
+ row["verify_reason"] = verify_reason
+ _sync_milestone_compat_row(
+ cur,
+ tenant_id=tenant_id,
+ initiative_id=initiative_id,
+ item=row,
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+ log_audit(
+ "roadmap_item.reached",
+ user_id=user_id,
+ tenant_id=tenant_id,
+ details={"roadmap_item_id": item_id, "verify_reason": verify_reason},
+ )
+ return row
diff --git a/backend/steering/signals/default_rules.py b/backend/steering/signals/default_rules.py
index 00e4393..a3d8b12 100644
--- a/backend/steering/signals/default_rules.py
+++ b/backend/steering/signals/default_rules.py
@@ -256,19 +256,20 @@ def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]:
SELECT
'milestone_at_risk' AS kind,
'warning' AS severity,
- m.title AS title,
- 'Meilenstein als gefährdet markiert' AS summary,
+ ri.title AS title,
+ 'Plan-Element als gefährdet markiert' AS summary,
'milestone' AS scope_type,
- m.id AS scope_id,
- m.initiative_id,
+ ri.id AS scope_id,
+ r.initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS blocker_id,
- m.id AS milestone_id,
+ ri.id AS milestone_id,
'milestone_at_risk' AS reason_code,
- 'milestones' AS data_source
- FROM milestones m
- WHERE m.tenant_id = %s AND m.status = 'at_risk'
- ORDER BY m.updated_at DESC
+ 'roadmap_items' AS data_source
+ FROM roadmap_items ri
+ JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
+ WHERE ri.tenant_id = %s AND ri.status = 'at_risk'
+ ORDER BY ri.updated_at DESC
LIMIT 20
""",
(ctx.tenant_id,),
diff --git a/backend/steering/signals/snapshot_signals.py b/backend/steering/signals/snapshot_signals.py
new file mode 100644
index 0000000..72d4f25
--- /dev/null
+++ b/backend/steering/signals/snapshot_signals.py
@@ -0,0 +1,47 @@
+"""Initiative snapshot signals — erklärbare Heuristiken ohne operating_phase (AP1.2)."""
+
+from __future__ import annotations
+
+
+def derive_initiative_signals(
+ *,
+ initiative_status: str,
+ open_actions: int,
+ blocked_actions: int,
+ review_required_actions: int,
+ open_blockers: int,
+ backlog_new: int,
+ planned_reviews_due: int,
+) -> list[str]:
+ """Regelbasierte Signale für Steuerungs-Snapshot — kein Lifecycle-Ersatz."""
+ if initiative_status in ("completed", "archived"):
+ return ["initiative_terminal"]
+
+ signals: list[str] = []
+
+ if open_actions == 0 and backlog_new > 0:
+ signals.append("backlog_awaiting_commit")
+ return signals
+
+ if open_actions == 0 and backlog_new == 0:
+ signals.append("no_open_work")
+ return signals
+
+ if blocked_actions > 0:
+ signals.append("actions_blocked")
+ if open_blockers > 0:
+ signals.append("open_blockers")
+ if blocked_actions > 0 or open_blockers > 0:
+ return signals
+
+ if review_required_actions > 0:
+ signals.append("actions_need_review")
+ if planned_reviews_due > 0:
+ signals.append("reviews_due")
+ if review_required_actions > 0 or planned_reviews_due > 0:
+ return signals
+
+ if open_actions > 0:
+ signals.append("work_in_progress")
+
+ return signals
diff --git a/backend/steering/strategies/next_action/product_milestone_driven.py b/backend/steering/strategies/next_action/product_milestone_driven.py
index 53cc3d2..efbfa63 100644
--- a/backend/steering/strategies/next_action/product_milestone_driven.py
+++ b/backend/steering/strategies/next_action/product_milestone_driven.py
@@ -36,20 +36,22 @@ class ProductMilestoneDrivenStrategy:
"""
SELECT
'review_milestone' AS kind,
- m.title AS title,
- 'Meilenstein prüfen oder Status aktualisieren' AS summary,
- m.initiative_id,
+ ri.title AS title,
+ 'Plan-Element prüfen oder Status aktualisieren' AS summary,
+ r.initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS backlog_item_id,
'milestone_at_risk_or_active' AS reason_code,
- 'Meilenstein prüfen' AS recommended_action
- FROM milestones m
- WHERE m.tenant_id = %s AND m.initiative_id = %s
- AND m.status IN ('at_risk', 'active')
+ 'Plan-Element prüfen' AS recommended_action
+ FROM roadmap_items ri
+ JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
+ WHERE ri.tenant_id = %s AND r.initiative_id = %s
+ AND ri.item_type = 'milestone'
+ AND ri.status IN ('at_risk', 'active')
ORDER BY
- CASE m.status WHEN 'at_risk' THEN 0 ELSE 1 END,
- m.target_date ASC NULLS LAST,
- m.updated_at DESC
+ CASE ri.status WHEN 'at_risk' THEN 0 ELSE 1 END,
+ ri.target_date ASC NULLS LAST,
+ ri.updated_at DESC
LIMIT %s
""",
(ctx.tenant_id, initiative_id, limit),
diff --git a/backend/tests/test_ap10_integration.py b/backend/tests/test_ap10_integration.py
index d896172..e01aa22 100644
--- a/backend/tests/test_ap10_integration.py
+++ b/backend/tests/test_ap10_integration.py
@@ -30,17 +30,8 @@ def test_steering_snapshot_graph(client):
)
assert snap.status_code == 200
data = snap.json()
- assert data["operating_phase"] in (
- "execute",
- "triage",
- "structure",
- "review",
- "adapt",
- "commit",
- "capture",
- "verify",
- "closure",
- )
+ assert isinstance(data.get("signals"), list)
+ assert "operating_phase" not in data
linked = next(a for a in data["actions"] if a["id"] == action["id"])
assert len(linked["blockers"]) == 1
assert linked["open_blocker_count"] == 1
diff --git a/backend/tests/test_ap12_signals.py b/backend/tests/test_ap12_signals.py
new file mode 100644
index 0000000..f7db3c7
--- /dev/null
+++ b/backend/tests/test_ap12_signals.py
@@ -0,0 +1,44 @@
+"""AP1.2 — Snapshot signals without operating_phase."""
+
+from __future__ import annotations
+
+from steering.signals.snapshot_signals import derive_initiative_signals
+
+
+def test_derive_signals_backlog_awaiting_commit():
+ signals = derive_initiative_signals(
+ initiative_status="active",
+ open_actions=0,
+ blocked_actions=0,
+ review_required_actions=0,
+ open_blockers=0,
+ backlog_new=3,
+ planned_reviews_due=0,
+ )
+ assert signals == ["backlog_awaiting_commit"]
+
+
+def test_derive_signals_work_in_progress():
+ signals = derive_initiative_signals(
+ initiative_status="active",
+ open_actions=2,
+ blocked_actions=0,
+ review_required_actions=0,
+ open_blockers=0,
+ backlog_new=0,
+ planned_reviews_due=0,
+ )
+ assert signals == ["work_in_progress"]
+
+
+def test_derive_signals_terminal():
+ signals = derive_initiative_signals(
+ initiative_status="completed",
+ open_actions=0,
+ blocked_actions=0,
+ review_required_actions=0,
+ open_blockers=0,
+ backlog_new=0,
+ planned_reviews_due=0,
+ )
+ assert signals == ["initiative_terminal"]
diff --git a/backend/tests/test_ap14_roadmap.py b/backend/tests/test_ap14_roadmap.py
new file mode 100644
index 0000000..48cb814
--- /dev/null
+++ b/backend/tests/test_ap14_roadmap.py
@@ -0,0 +1,122 @@
+"""AP1.4 — RoadmapItem & Quality Gate tests."""
+
+from __future__ import annotations
+
+from tests.factories import provision_user_in_tenant
+from tests.test_initiatives_actions import (
+ _auth,
+ _create_initiative,
+ _login,
+)
+
+
+def _create_roadmap_item(client, token, initiative_id, **kwargs):
+ body = {"title": "Gate A", "item_type": "milestone", **kwargs}
+ return client.post(
+ f"/api/initiatives/{initiative_id}/roadmap/items",
+ json=body,
+ headers=_auth(token),
+ )
+
+
+def test_roadmap_item_crud(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+
+ created = _create_roadmap_item(
+ client,
+ token,
+ initiative_id,
+ sequencing_mode="parallel",
+ item_type="maturity_stage",
+ )
+ assert created.status_code == 201
+ item = created.json()
+ assert item["item_type"] == "maturity_stage"
+ assert item["sequencing_mode"] == "parallel"
+
+ listed = client.get(
+ f"/api/initiatives/{initiative_id}/roadmap/items",
+ headers=_auth(token),
+ )
+ assert listed.status_code == 200
+ assert len(listed.json()) >= 1
+
+ patched = client.patch(
+ f"/api/roadmap-items/{item['id']}",
+ json={"status": "at_risk"},
+ headers=_auth(token),
+ )
+ assert patched.status_code == 200
+ assert patched.json()["status"] == "at_risk"
+
+ deleted = client.delete(f"/api/roadmap-items/{item['id']}", headers=_auth(token))
+ assert deleted.status_code == 204
+
+
+def test_verify_reached_requires_evidence(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, status="active").json()
+
+ fail = client.post(
+ f"/api/roadmap-items/{item['id']}/verify-reached",
+ headers=_auth(token),
+ )
+ assert fail.status_code == 400
+
+ evidence = client.post(
+ f"/api/initiatives/{initiative_id}/evidence",
+ json={
+ "title": "Nachweis",
+ "milestone_id": item["id"],
+ "status": "accepted",
+ },
+ headers=_auth(token),
+ )
+ assert evidence.status_code == 201
+
+ ok = client.post(
+ f"/api/roadmap-items/{item['id']}/verify-reached",
+ headers=_auth(token),
+ )
+ assert ok.status_code == 200
+ assert ok.json()["status"] == "reached"
+ assert ok.json()["verify_reason"] == "evidence_accepted"
+
+
+def test_milestone_compat_api_uses_roadmap(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+
+ created = client.post(
+ f"/api/initiatives/{initiative_id}/milestones",
+ json={"title": "Legacy MS", "status": "planned"},
+ headers=_auth(token),
+ )
+ assert created.status_code == 201
+ ms_id = created.json()["id"]
+
+ roadmap = client.get(
+ f"/api/initiatives/{initiative_id}/roadmap/items",
+ headers=_auth(token),
+ ).json()
+ assert any(i["id"] == ms_id and i["item_type"] == "milestone" for i in roadmap)
+
+
+def test_steering_snapshot_reads_roadmap_items(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+ _create_roadmap_item(client, token, initiative_id, status="active", target_date="2026-12-31")
+
+ snap = client.get(
+ f"/api/initiatives/{initiative_id}/steering-snapshot",
+ headers=_auth(token),
+ ).json()
+ assert snap.get("roadmap_items")
+ assert snap.get("upcoming_milestones")
+ assert len(snap["upcoming_roadmap_items"]) >= 1
diff --git a/backend/tests/test_ap1_steering_foundation.py b/backend/tests/test_ap1_steering_foundation.py
index 30c54e2..1cbdd5f 100644
--- a/backend/tests/test_ap1_steering_foundation.py
+++ b/backend/tests/test_ap1_steering_foundation.py
@@ -41,7 +41,8 @@ def test_steering_snapshot_has_lifecycle(client):
assert snap.status_code == 200
data = snap.json()
assert "lifecycle_state" in data
- assert data.get("operating_phase_deprecated") is True
+ assert "signals" in data
+ assert "operating_phase" not in data
assert data["lifecycle_state"] in (
"intake",
"planning",
diff --git a/backend/version.py b/backend/version.py
index ab1fa76..93e66b0 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,3 +1,3 @@
-APP_VERSION = "0.12.0-ap1.2c"
-DB_SCHEMA_VERSION = "009"
+APP_VERSION = "0.13.0-ap1.4"
+DB_SCHEMA_VERSION = "010"
APP_NAME = "jinkendo-kairo"
diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
index 4682ae0..42db788 100644
--- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
+++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
@@ -50,10 +50,10 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Review | ✓ | |
| RecurringElement | ✓ | |
| Task (unter Action) | ✗ | |
-| Roadmap | ✗ | 📄 Target State |
-| RoadmapItem | ✗ | 📄; Milestone soll hierhin |
-| RoadmapItem Dependencies | ✗ | |
-| Plan-Ist-Verknüpfung | ✗ | Backlog/Action → Gate |
+| Roadmap | ◐ | 1 pro Initiative; Migration 010 |
+| RoadmapItem | ◐ | milestone/review_gate/maturity_stage; Milestone-API Compat |
+| RoadmapItem Dependencies | ◐ | requires/blocks/related |
+| Plan-Ist-Verknüpfung | ◐ | backlog/actions.roadmap_item_id; Gate-Verify AP1.4 |
---
@@ -70,7 +70,8 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Situativer Steuerungskontext (Next Action) | ✗ | 📄 Vision §7.6 |
| AttentionItem | ◐ | |
| Initiative Steering Snapshot | ✓ | Graph für Actions + linked |
-| operating_phase | ◐ | deprecated flag; noch im Snapshot |
+| operating_phase | ✗ | entfernt AP1.2; Snapshot nutzt `signals` + Lifecycle |
+| signals (Snapshot) | ✓ | `backend/steering/signals/snapshot_signals.py` |
| Hook Orchestrator | ✗ | AP1.3 geplant |
| Structure Builder | ✗ | 📄 |
| DoD Engine | ✗ | 📄 |
@@ -133,7 +134,8 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| AP | Erwartete Truth-Table-Änderung |
|----|--------------------------------|
| AP1.2c | IA-Skeleton ✓ |
-| AP1.4 | RoadmapItem ○→◐, Gate Verify ◐ |
+| AP1.2 | operating_phase ✗, signals ✓ |
+| AP1.4 | RoadmapItem ◐, Gate Verify ◐ |
| AP1.5 | Task ○, Project GUI ◐ |
| AP1.8 | Portfolio-Priorität ○, situativer Kontext ○ |
diff --git a/frontend/package.json b/frontend/package.json
index e7860f3..fb6eaac 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "kairo-jinkendo-frontend",
- "version": "0.12.0-ap1.2c",
+ "version": "0.13.0-ap1.4",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/api/roadmap.js b/frontend/src/api/roadmap.js
new file mode 100644
index 0000000..3226c5e
--- /dev/null
+++ b/frontend/src/api/roadmap.js
@@ -0,0 +1,40 @@
+import { apiFetch } from './client.js'
+
+export function listInitiativeRoadmapItems(initiativeId) {
+ return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`)
+}
+
+export function createInitiativeRoadmapItem(initiativeId, body) {
+ return apiFetch(`/api/initiatives/${initiativeId}/roadmap/items`, {
+ method: 'POST',
+ body: JSON.stringify(body),
+ })
+}
+
+export function updateRoadmapItem(itemId, body) {
+ return apiFetch(`/api/roadmap-items/${itemId}`, {
+ method: 'PATCH',
+ body: JSON.stringify(body),
+ })
+}
+
+export function deleteRoadmapItem(itemId) {
+ return apiFetch(`/api/roadmap-items/${itemId}`, { method: 'DELETE' })
+}
+
+export function verifyRoadmapItemReached(itemId) {
+ return apiFetch(`/api/roadmap-items/${itemId}/verify-reached`, {
+ method: 'POST',
+ })
+}
+
+export function listRoadmapItemDependencies(itemId) {
+ return apiFetch(`/api/roadmap-items/${itemId}/dependencies`)
+}
+
+export function addRoadmapItemDependency(itemId, body) {
+ return apiFetch(`/api/roadmap-items/${itemId}/dependencies`, {
+ method: 'POST',
+ body: JSON.stringify(body),
+ })
+}
diff --git a/frontend/src/components/InitiativeNavCards.jsx b/frontend/src/components/InitiativeNavCards.jsx
index 721248c..cdac7da 100644
--- a/frontend/src/components/InitiativeNavCards.jsx
+++ b/frontend/src/components/InitiativeNavCards.jsx
@@ -4,7 +4,7 @@ const AREAS = [
{
to: 'plan',
title: 'Plan',
- description: 'Meilensteine und Roadmap — überprüfbare Zielpunkte pflegen.',
+ description: 'Roadmap, Gates und Plan-Ist — methodenneutral pflegen.',
},
{
to: 'execution',
@@ -34,8 +34,8 @@ export function InitiativeNavCards({ initiativeId, counts = {} }) {
? `${counts.backlog} Backlog`
: area.to === 'execution' && counts.actionsOpen
? `${counts.actionsOpen} offen`
- : area.to === 'plan' && counts.milestones
- ? `${counts.milestones} Meilensteine`
+ : area.to === 'plan' && counts.roadmapItems
+ ? `${counts.roadmapItems} Plan-Elemente`
: null
return (
!['reached', 'moved', 'discarded'].includes(s)
+)
+
+function formatDate(value) {
+ if (!value) return null
+ try {
+ return new Date(value.includes('T') ? value : `${value}T12:00:00`).toLocaleDateString(
+ 'de-DE'
+ )
+ } catch {
+ return value
+ }
+}
+
+export function RoadmapPlanSection({
+ items,
+ canManage,
+ onCreate,
+ onUpdateStatus,
+ onUpdateItem,
+ onVerifyReached,
+ onDelete,
+ busy,
+}) {
+ const [title, setTitle] = useState('')
+ const [goalDescription, setGoalDescription] = useState('')
+ const [targetDate, setTargetDate] = useState('')
+ const [itemType, setItemType] = useState('milestone')
+ const [sequencingMode, setSequencingMode] = useState('sequential')
+ const [showForm, setShowForm] = useState(false)
+
+ async function handleSubmit(e) {
+ e.preventDefault()
+ if (!title.trim()) return
+ await onCreate({
+ title: title.trim(),
+ goal_description: goalDescription.trim(),
+ target_date: targetDate || undefined,
+ item_type: itemType,
+ sequencing_mode: sequencingMode,
+ })
+ setTitle('')
+ setGoalDescription('')
+ setTargetDate('')
+ setItemType('milestone')
+ setSequencingMode('sequential')
+ setShowForm(false)
+ }
+
+ return (
+
+ Roadmap-Elemente methodenneutral — Gates, Reifegrade oder Review-Punkte.
+ Erreicht nur über Verify (Evidence, Review oder Decision).
+
+ {ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
+ {' · '}
+ {SEQUENCING_MODE_LABELS[item.sequencing_mode] || item.sequencing_mode}
+ {item.goal_description}
+ Zieltermin: {formatDate(item.target_date)}
+ Plan
+
+ {items.map((item) => (
+
+
{phaseDesc}
} - - {phase_signals?.length > 0 && ( + {signals?.length > 0 && (- {phase_signals.map((s) => SIGNAL_LABELS[s] || s).join(' · ')} + {signals.map((s) => SIGNAL_LABELS[s] || s).join(' · ')}
)} diff --git a/frontend/src/constants/status.js b/frontend/src/constants/status.js index f9ac8c3..98e7d44 100644 --- a/frontend/src/constants/status.js +++ b/frontend/src/constants/status.js @@ -60,6 +60,18 @@ export const MILESTONE_STATUS_LABELS = { discarded: 'Verworfen', } +export const ROADMAP_ITEM_TYPE_LABELS = { + milestone: 'Meilenstein / Gate', + review_gate: 'Review-Gate', + maturity_stage: 'Reifegrad-Stufe', +} + +export const SEQUENCING_MODE_LABELS = { + sequential: 'Sequenziell', + parallel: 'Parallel', + optional: 'Optional', +} + export const EVIDENCE_STATUS_LABELS = { submitted: 'Eingereicht', accepted: 'Akzeptiert', diff --git a/frontend/src/context/InitiativeOperationsContext.jsx b/frontend/src/context/InitiativeOperationsContext.jsx index 6b0c509..fd4fdd0 100644 --- a/frontend/src/context/InitiativeOperationsContext.jsx +++ b/frontend/src/context/InitiativeOperationsContext.jsx @@ -28,11 +28,12 @@ import { convertBacklogToAction, } from '../api/backlog.js' import { - listInitiativeMilestones, - createInitiativeMilestone, - updateMilestone, - deleteMilestone, -} from '../api/milestones.js' + listInitiativeRoadmapItems, + createInitiativeRoadmapItem, + updateRoadmapItem, + deleteRoadmapItem, + verifyRoadmapItemReached, +} from '../api/roadmap.js' import { listInitiativeEvidence, createInitiativeEvidence, @@ -74,7 +75,7 @@ export function InitiativeOperationsProvider({ children }) { const [actions, setActions] = useState([]) const [blockers, setBlockers] = useState([]) const [backlogItems, setBacklogItems] = useState([]) - const [milestones, setMilestones] = useState([]) + const [roadmapItems, setRoadmapItems] = useState([]) const [evidenceItems, setEvidenceItems] = useState([]) const [decisions, setDecisions] = useState([]) const [reviews, setReviews] = useState([]) @@ -110,7 +111,7 @@ export function InitiativeOperationsProvider({ children }) { if (canReadOm) { loads.push(listInitiativeBlockers(id).then(setBlockers).catch(() => setBlockers([]))) loads.push(listInitiativeBacklog(id).then(setBacklogItems).catch(() => setBacklogItems([]))) - loads.push(listInitiativeMilestones(id).then(setMilestones).catch(() => setMilestones([]))) + loads.push(listInitiativeRoadmapItems(id).then(setRoadmapItems).catch(() => setRoadmapItems([]))) loads.push(listInitiativeEvidence(id).then(setEvidenceItems).catch(() => setEvidenceItems([]))) loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([]))) loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([]))) @@ -316,10 +317,10 @@ export function InitiativeOperationsProvider({ children }) { } } - async function handleCreateMilestone(body) { + async function handleCreateRoadmapItem(body) { setFormBusy(true) try { - await createInitiativeMilestone(id, body) + await createInitiativeRoadmapItem(id, body) await load() } catch (err) { setError(err.message) @@ -328,24 +329,50 @@ export function InitiativeOperationsProvider({ children }) { } } - async function handleMilestoneStatus(milestoneId, status) { + async function handleRoadmapItemStatus(itemId, status) { try { - await updateMilestone(milestoneId, { status }) + await updateRoadmapItem(itemId, { status }) await load() } catch (err) { setError(err.message) } } - async function handleDeleteMilestone(milestoneId) { + async function handleUpdateRoadmapItem(itemId, body) { try { - await deleteMilestone(milestoneId) + await updateRoadmapItem(itemId, body) await load() } catch (err) { setError(err.message) } } + async function handleVerifyRoadmapItem(itemId) { + setFormBusy(true) + try { + await verifyRoadmapItemReached(itemId) + await load() + } catch (err) { + setError(err.message) + } finally { + setFormBusy(false) + } + } + + async function handleDeleteRoadmapItem(itemId) { + try { + await deleteRoadmapItem(itemId) + await load() + } catch (err) { + setError(err.message) + } + } + + const milestones = useMemo( + () => roadmapItems.filter((item) => item.item_type === 'milestone'), + [roadmapItems] + ) + async function handleCreateEvidence(body) { setFormBusy(true) try { @@ -474,6 +501,7 @@ export function InitiativeOperationsProvider({ children }) { blockers, unlinkedBlockers, backlogItems, + roadmapItems, milestones, evidenceItems, decisions, @@ -513,9 +541,14 @@ export function InitiativeOperationsProvider({ children }) { handleBacklogStatus, handleConvertBacklog, handleDeleteBacklog, - handleCreateMilestone, - handleMilestoneStatus, - handleDeleteMilestone, + handleCreateRoadmapItem, + handleRoadmapItemStatus, + handleUpdateRoadmapItem, + handleVerifyRoadmapItem, + handleDeleteRoadmapItem, + handleCreateMilestone: handleCreateRoadmapItem, + handleMilestoneStatus: handleRoadmapItemStatus, + handleDeleteMilestone: handleDeleteRoadmapItem, handleCreateEvidence, handleEvidenceStatus, handleDeleteEvidence, diff --git a/frontend/src/pages/initiative/InitiativeOverviewPage.jsx b/frontend/src/pages/initiative/InitiativeOverviewPage.jsx index 6019a72..8bb8387 100644 --- a/frontend/src/pages/initiative/InitiativeOverviewPage.jsx +++ b/frontend/src/pages/initiative/InitiativeOverviewPage.jsx @@ -16,6 +16,7 @@ export function InitiativeOverviewPage() { capabilities, handleMethodChange, backlogItems, + roadmapItems, milestones, } = useInitiativeOperations() @@ -69,7 +70,7 @@ export function InitiativeOverviewPage() { initiativeId={initiativeId} counts={{ backlog: backlogItems.length, - milestones: milestones.length, + roadmapItems: roadmapItems.length, actionsOpen: counts.actions_open, }} /> diff --git a/frontend/src/pages/initiative/InitiativePlanPage.jsx b/frontend/src/pages/initiative/InitiativePlanPage.jsx index c599583..0bfffa6 100644 --- a/frontend/src/pages/initiative/InitiativePlanPage.jsx +++ b/frontend/src/pages/initiative/InitiativePlanPage.jsx @@ -1,14 +1,16 @@ import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' -import { MilestonesSection } from '../../components/MilestonesSection.jsx' +import { RoadmapPlanSection } from '../../components/RoadmapPlanSection.jsx' export function InitiativePlanPage() { const { - milestones, + roadmapItems, capabilities, formBusy, - handleCreateMilestone, - handleMilestoneStatus, - handleDeleteMilestone, + handleCreateRoadmapItem, + handleRoadmapItemStatus, + handleUpdateRoadmapItem, + handleVerifyRoadmapItem, + handleDeleteRoadmapItem, error, } = useInitiativeOperations() @@ -19,12 +21,14 @@ export function InitiativePlanPage() { return ( <> {error &&{error}
} -