AP1.2 + AP1.4: Signals konsolidieren, RoadmapItem mit Gate-Verify
Some checks failed
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Failing after 1m30s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Some checks failed
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Failing after 1m30s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Entfernt operating_phase aus dem Snapshot zugunsten von Lifecycle + signals. Führt methodenneutrale RoadmapItems (Migration 010), Verify-Pfad und Plan-UI ein. Milestone-API bleibt als Compat-Wrapper; Version 0.13.0-ap1.4. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a5d9eeb338
commit
09433b5d28
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
86
backend/migrations/010_roadmap_items.sql
Normal file
86
backend/migrations/010_roadmap_items.sql
Normal file
|
|
@ -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;
|
||||
198
backend/routers/roadmap.py
Normal file
198
backend/routers/roadmap.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
items = roadmap_service.list_roadmap_items_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
return [_serialize_row(dict(r)) for r in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
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},
|
||||
)
|
||||
if status is not None and status != old_status:
|
||||
log_audit(
|
||||
"milestone.status_changed",
|
||||
item_id=milestone_id,
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
details={
|
||||
"milestone_id": milestone_id,
|
||||
"from_status": old_status,
|
||||
"to_status": status,
|
||||
},
|
||||
title=title,
|
||||
goal_description=goal_description,
|
||||
status=status,
|
||||
target_date=target_date,
|
||||
clear_target_date=clear_target_date,
|
||||
)
|
||||
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),
|
||||
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
|
||||
)
|
||||
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
|
||||
|
|
|
|||
621
backend/services/roadmap.py
Normal file
621
backend/services/roadmap.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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,),
|
||||
|
|
|
|||
47
backend/steering/signals/snapshot_signals.py
Normal file
47
backend/steering/signals/snapshot_signals.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
44
backend/tests/test_ap12_signals.py
Normal file
44
backend/tests/test_ap12_signals.py
Normal file
|
|
@ -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"]
|
||||
122
backend/tests/test_ap14_roadmap.py
Normal file
122
backend/tests/test_ap14_roadmap.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ○ |
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
40
frontend/src/api/roadmap.js
Normal file
40
frontend/src/api/roadmap.js
Normal file
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Link
|
||||
|
|
|
|||
220
frontend/src/components/RoadmapPlanSection.jsx
Normal file
220
frontend/src/components/RoadmapPlanSection.jsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { useState } from 'react'
|
||||
import {
|
||||
MILESTONE_STATUSES,
|
||||
MILESTONE_STATUS_LABELS,
|
||||
ROADMAP_ITEM_TYPE_LABELS,
|
||||
SEQUENCING_MODE_LABELS,
|
||||
} from '../constants/status.js'
|
||||
import { StatusBadge } from './StatusBadge.jsx'
|
||||
import { EmptyState } from './EmptyState.jsx'
|
||||
|
||||
const ITEM_TYPES = ['milestone', 'review_gate', 'maturity_stage']
|
||||
const SEQUENCING_MODES = ['sequential', 'parallel', 'optional']
|
||||
const EDITABLE_STATUSES = MILESTONE_STATUSES.filter(
|
||||
(s) => !['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 (
|
||||
<section className="card">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Plan</h2>
|
||||
<p className="section-lead muted">
|
||||
Roadmap-Elemente methodenneutral — Gates, Reifegrade oder Review-Punkte.
|
||||
Erreicht nur über Verify (Evidence, Review oder Decision).
|
||||
</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-block-mobile"
|
||||
onClick={() => setShowForm((v) => !v)}
|
||||
>
|
||||
{showForm ? 'Abbrechen' : 'Plan-Element'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && canManage && (
|
||||
<form className="inline-form-block milestone-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Typ
|
||||
<select value={itemType} onChange={(e) => setItemType(e.target.value)}>
|
||||
{ITEM_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{ROADMAP_ITEM_TYPE_LABELS[t] || t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Titel
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={255}
|
||||
required
|
||||
placeholder="z. B. MVP nutzbar im Alltag"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Ziel / Definition of Done
|
||||
<textarea
|
||||
value={goalDescription}
|
||||
onChange={(e) => setGoalDescription(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Woran erkennst du, dass dieses Element erreicht ist?"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Abfolge
|
||||
<select
|
||||
value={sequencingMode}
|
||||
onChange={(e) => setSequencingMode(e.target.value)}
|
||||
>
|
||||
{SEQUENCING_MODES.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{SEQUENCING_MODE_LABELS[m] || m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Zieltermin
|
||||
<input
|
||||
type="date"
|
||||
value={targetDate}
|
||||
onChange={(e) => setTargetDate(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
Anlegen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{items.length === 0 && (
|
||||
<EmptyState message="Noch kein Plan — strukturiere das Vorhaben in überprüfbare Roadmap-Elemente." />
|
||||
)}
|
||||
|
||||
<ul className="item-list milestone-list">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="list-item card-list-item milestone-list-item">
|
||||
<div className="list-item-main">
|
||||
<strong>{item.title}</strong>
|
||||
<p className="muted list-item-sub">
|
||||
{ROADMAP_ITEM_TYPE_LABELS[item.item_type] || item.item_type}
|
||||
{' · '}
|
||||
{SEQUENCING_MODE_LABELS[item.sequencing_mode] || item.sequencing_mode}
|
||||
</p>
|
||||
{item.goal_description && (
|
||||
<p className="list-item-desc">{item.goal_description}</p>
|
||||
)}
|
||||
{item.target_date && (
|
||||
<p className="muted list-item-sub">
|
||||
Zieltermin: {formatDate(item.target_date)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="list-item-meta action-controls">
|
||||
<StatusBadge kind="milestone" status={item.status} />
|
||||
{canManage && item.status !== 'reached' && (
|
||||
<>
|
||||
<select
|
||||
className="inline-select"
|
||||
value={item.status}
|
||||
onChange={(e) => onUpdateStatus(item.id, e.target.value)}
|
||||
aria-label="Plan-Status"
|
||||
>
|
||||
{EDITABLE_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{MILESTONE_STATUS_LABELS[s]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="inline-select"
|
||||
value={item.sequencing_mode}
|
||||
onChange={(e) =>
|
||||
onUpdateItem(item.id, { sequencing_mode: e.target.value })
|
||||
}
|
||||
aria-label="Abfolge"
|
||||
>
|
||||
{SEQUENCING_MODES.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{SEQUENCING_MODE_LABELS[m]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{['planned', 'active', 'at_risk'].includes(item.status) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy}
|
||||
onClick={() => onVerifyReached(item.id)}
|
||||
>
|
||||
Verify → erreicht
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => onDelete(item.id)}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
import {
|
||||
LIFECYCLE_LABELS,
|
||||
PHASE_LABELS,
|
||||
PHASE_DESCRIPTIONS,
|
||||
SIGNAL_LABELS,
|
||||
NEXT_ACTION_KIND_LABELS,
|
||||
} from '../constants/operating.js'
|
||||
|
|
@ -51,9 +49,7 @@ export function SteeringSnapshotPanel({
|
|||
lifecycle_label,
|
||||
method_key,
|
||||
method_label,
|
||||
operating_phase,
|
||||
operating_phase_deprecated,
|
||||
phase_signals,
|
||||
signals = [],
|
||||
counts,
|
||||
upcoming_milestones = [],
|
||||
next_actions = [],
|
||||
|
|
@ -61,12 +57,7 @@ export function SteeringSnapshotPanel({
|
|||
const displayLabel =
|
||||
lifecycle_label ||
|
||||
LIFECYCLE_LABELS[lifecycle_state] ||
|
||||
PHASE_LABELS[operating_phase] ||
|
||||
lifecycle_state ||
|
||||
operating_phase
|
||||
const phaseDesc = lifecycle_state
|
||||
? null
|
||||
: PHASE_DESCRIPTIONS[operating_phase] || ''
|
||||
lifecycle_state
|
||||
|
||||
const statPills = [
|
||||
{ label: 'Offen', value: counts.actions_open, warn: false },
|
||||
|
|
@ -126,11 +117,9 @@ export function SteeringSnapshotPanel({
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
{phaseDesc && <p className="steering-phase-desc">{phaseDesc}</p>}
|
||||
|
||||
{phase_signals?.length > 0 && (
|
||||
{signals?.length > 0 && (
|
||||
<p className="muted snapshot-signals">
|
||||
{phase_signals.map((s) => SIGNAL_LABELS[s] || s).join(' · ')}
|
||||
{signals.map((s) => SIGNAL_LABELS[s] || s).join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 && <p className="error">{error}</p>}
|
||||
<MilestonesSection
|
||||
milestones={milestones}
|
||||
<RoadmapPlanSection
|
||||
items={roadmapItems}
|
||||
canManage={capabilities.has('kairo.milestone.manage')}
|
||||
onCreate={handleCreateMilestone}
|
||||
onUpdateStatus={handleMilestoneStatus}
|
||||
onDelete={handleDeleteMilestone}
|
||||
onCreate={handleCreateRoadmapItem}
|
||||
onUpdateStatus={handleRoadmapItemStatus}
|
||||
onUpdateItem={handleUpdateRoadmapItem}
|
||||
onVerifyReached={handleVerifyRoadmapItem}
|
||||
onDelete={handleDeleteRoadmapItem}
|
||||
busy={formBusy}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user