AP1.6: Plan/Ist-Verknuepfung und Journey-Timeline.
Some checks failed
Deploy Development / deploy (push) Failing after 37s
Test Suite / pytest-backend (push) Successful in 1m41s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 13s

Backlog, Actions, Projects und Tasks koennen Gates zugeordnet werden; Gate-Beitragsliste und chronologische Journey machen Plan/Ist nachvollziehbar.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-06 08:43:11 +02:00
parent 4ca0a2c460
commit 7e4936a2e4
29 changed files with 1072 additions and 40 deletions

View File

@ -66,6 +66,7 @@ from routers import ( # noqa: E402
evidence, evidence,
features, features,
initiatives, initiatives,
journey,
me, me,
milestones, milestones,
projects, projects,
@ -84,6 +85,8 @@ app.include_router(features.router)
app.include_router(prompts.router) app.include_router(prompts.router)
app.include_router(config.router) app.include_router(config.router)
app.include_router(initiatives.router) app.include_router(initiatives.router)
app.include_router(journey.initiative_router)
app.include_router(journey.roadmap_router)
app.include_router(actions.router) app.include_router(actions.router)
app.include_router(projects.initiative_router) app.include_router(projects.initiative_router)
app.include_router(projects.items_router) app.include_router(projects.items_router)

View File

@ -27,6 +27,8 @@ class ActionUpdateRequest(BaseModel):
clear_due_at: bool = False clear_due_at: bool = False
project_id: Optional[str] = None project_id: Optional[str] = None
clear_project: bool = False clear_project: bool = False
roadmap_item_id: Optional[str] = None
clear_roadmap_item: bool = False
class TaskCreateRequest(BaseModel): class TaskCreateRequest(BaseModel):
@ -129,6 +131,8 @@ def update_action(
clear_due_at=body.clear_due_at, clear_due_at=body.clear_due_at,
project_id=body.project_id, project_id=body.project_id,
clear_project=body.clear_project, clear_project=body.clear_project,
roadmap_item_id=body.roadmap_item_id,
clear_roadmap_item=body.clear_roadmap_item,
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc

View File

@ -18,6 +18,7 @@ class BacklogCreateRequest(BaseModel):
description: str = "" description: str = ""
status: Literal["new", "triaged", "accepted", "rejected"] = "new" status: Literal["new", "triaged", "accepted", "rejected"] = "new"
priority: Literal["low", "normal", "high"] = "normal" priority: Literal["low", "normal", "high"] = "normal"
roadmap_item_id: Optional[str] = None
class BacklogUpdateRequest(BaseModel): class BacklogUpdateRequest(BaseModel):
@ -25,6 +26,8 @@ class BacklogUpdateRequest(BaseModel):
description: Optional[str] = None description: Optional[str] = None
status: Optional[Literal["new", "triaged", "accepted", "rejected"]] = None status: Optional[Literal["new", "triaged", "accepted", "rejected"]] = None
priority: Optional[Literal["low", "normal", "high"]] = None priority: Optional[Literal["low", "normal", "high"]] = None
roadmap_item_id: Optional[str] = None
clear_roadmap_item: bool = False
class BacklogConvertRequest(BaseModel): class BacklogConvertRequest(BaseModel):
@ -59,6 +62,8 @@ def update_backlog_item(
description=body.description, description=body.description,
status=body.status, status=body.status,
priority=body.priority, priority=body.priority,
roadmap_item_id=body.roadmap_item_id,
clear_roadmap_item=body.clear_roadmap_item,
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc

View File

@ -49,6 +49,7 @@ class ActionCreateRequest(BaseModel):
priority: Literal["low", "normal", "high"] = "normal" priority: Literal["low", "normal", "high"] = "normal"
due_at: Optional[str] = None due_at: Optional[str] = None
project_id: Optional[str] = None project_id: Optional[str] = None
roadmap_item_id: Optional[str] = None
assigned_actor_ids: list[str] = Field(default_factory=list) assigned_actor_ids: list[str] = Field(default_factory=list)
@ -65,6 +66,7 @@ class BacklogCreateRequest(BaseModel):
description: str = "" description: str = ""
status: Literal["new", "triaged", "accepted", "rejected"] = "new" status: Literal["new", "triaged", "accepted", "rejected"] = "new"
priority: Literal["low", "normal", "high"] = "normal" priority: Literal["low", "normal", "high"] = "normal"
roadmap_item_id: Optional[str] = None
class MilestoneCreateRequest(BaseModel): class MilestoneCreateRequest(BaseModel):
@ -251,6 +253,7 @@ def create_initiative_action(
priority=body.priority, priority=body.priority,
due_at=due_at, due_at=due_at,
project_id=body.project_id, project_id=body.project_id,
roadmap_item_id=body.roadmap_item_id,
assigned_actor_ids=body.assigned_actor_ids, assigned_actor_ids=body.assigned_actor_ids,
user_id=ctx.user_id, user_id=ctx.user_id,
) )
@ -330,6 +333,7 @@ def create_initiative_backlog_item(
description=body.description, description=body.description,
status=body.status, status=body.status,
priority=body.priority, priority=body.priority,
roadmap_item_id=body.roadmap_item_id,
user_id=ctx.user_id, user_id=ctx.user_id,
) )
except ValueError as exc: except ValueError as exc:

View File

@ -0,0 +1,44 @@
"""Plan/Ist & Journey API — AP1.6."""
from __future__ import annotations
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from services import plan_ist as plan_ist_service
from tenant_context import TenantContext
initiative_router = APIRouter(prefix="/api/initiatives", tags=["journey"])
roadmap_router = APIRouter(prefix="/api/roadmap-items", tags=["journey"])
@initiative_router.get("/{initiative_id}/journey")
def list_initiative_journey(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
):
try:
events = plan_ist_service.list_journey_events(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
detail = str(exc)
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
return {"events": events}
@roadmap_router.get("/{roadmap_item_id}/contributions")
def list_gate_contributions(
roadmap_item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
return plan_ist_service.list_gate_contributions(
tenant_id=ctx.tenant_id, roadmap_item_id=roadmap_item_id
)
except ValueError as exc:
detail = str(exc)
if detail == "Gate nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc

View File

@ -9,6 +9,7 @@ from psycopg2.extras import RealDictCursor
from db import get_connection from db import get_connection
from services.audit import log_audit from services.audit import log_audit
from services.initiatives import PRIORITIES, get_initiative from services.initiatives import PRIORITIES, get_initiative
from services.plan_ist import validate_roadmap_item_in_initiative
from services.projects import project_is_leaf from services.projects import project_is_leaf
ActionStatus = Literal[ ActionStatus = Literal[
@ -23,14 +24,14 @@ OPEN_ACTION_STATUSES = frozenset(
) )
_ACTION_COLUMNS = """ _ACTION_COLUMNS = """
id, tenant_id, initiative_id, project_id, title, description, id, tenant_id, initiative_id, project_id, roadmap_item_id, title, description,
status, priority, due_at, created_at, updated_at status, priority, due_at, created_at, updated_at
""" """
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row) result = dict(row)
for key in ("id", "tenant_id", "initiative_id", "project_id", "owner_actor_id"): for key in ("id", "tenant_id", "initiative_id", "project_id", "roadmap_item_id", "owner_actor_id"):
if result.get(key): if result.get(key):
result[key] = str(result[key]) result[key] = str(result[key])
if result.get("created_at"): if result.get("created_at"):
@ -135,6 +136,7 @@ def create_action(
priority: str = "normal", priority: str = "normal",
due_at: Optional[Any] = None, due_at: Optional[Any] = None,
project_id: Optional[str] = None, project_id: Optional[str] = None,
roadmap_item_id: Optional[str] = None,
assigned_actor_ids: Optional[list[str]] = None, assigned_actor_ids: Optional[list[str]] = None,
user_id: Optional[str] = None, user_id: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
@ -157,16 +159,32 @@ def create_action(
conn = get_connection() conn = get_connection()
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
validate_roadmap_item_in_initiative(
cur,
tenant_id=tenant_id,
initiative_id=initiative_id,
roadmap_item_id=roadmap_item_id,
)
cur.execute( cur.execute(
f""" f"""
INSERT INTO actions ( INSERT INTO actions (
tenant_id, initiative_id, project_id, title, description, tenant_id, initiative_id, project_id, roadmap_item_id, title, description,
status, priority, due_at status, priority, due_at
) )
VALUES (%s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING {_ACTION_COLUMNS} RETURNING {_ACTION_COLUMNS}
""", """,
(tenant_id, initiative_id, project_id, title, description, status, priority, due_at), (
tenant_id,
initiative_id,
project_id,
roadmap_item_id,
title,
description,
status,
priority,
due_at,
),
) )
row = _serialize_row(dict(cur.fetchone())) row = _serialize_row(dict(cur.fetchone()))
for actor_id in assigned_actor_ids: for actor_id in assigned_actor_ids:
@ -254,6 +272,8 @@ def update_action(
clear_due_at: bool = False, clear_due_at: bool = False,
project_id: Optional[str] = None, project_id: Optional[str] = None,
clear_project: bool = False, clear_project: bool = False,
roadmap_item_id: Optional[str] = None,
clear_roadmap_item: bool = False,
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
existing = get_action(tenant_id=tenant_id, action_id=action_id) existing = get_action(tenant_id=tenant_id, action_id=action_id)
if not existing: if not existing:
@ -295,6 +315,22 @@ def update_action(
) )
updates.append("project_id = %s") updates.append("project_id = %s")
params.append(project_id) params.append(project_id)
if clear_roadmap_item:
updates.append("roadmap_item_id = NULL")
elif roadmap_item_id is not None:
conn = get_connection()
try:
with conn.cursor() as cur:
validate_roadmap_item_in_initiative(
cur,
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
roadmap_item_id=roadmap_item_id,
)
finally:
conn.close()
updates.append("roadmap_item_id = %s")
params.append(roadmap_item_id)
if not updates: if not updates:
return existing return existing

View File

@ -10,15 +10,21 @@ from db import get_connection
from services.actions import create_action from services.actions import create_action
from services.audit import log_audit from services.audit import log_audit
from services.initiatives import PRIORITIES, get_initiative from services.initiatives import PRIORITIES, get_initiative
from services.plan_ist import validate_roadmap_item_in_initiative
BacklogStatus = Literal["new", "triaged", "accepted", "rejected", "converted"] BacklogStatus = Literal["new", "triaged", "accepted", "rejected", "converted"]
BACKLOG_STATUSES = frozenset({"new", "triaged", "accepted", "rejected", "converted"}) BACKLOG_STATUSES = frozenset({"new", "triaged", "accepted", "rejected", "converted"})
_BACKLOG_COLUMNS = """
id, tenant_id, initiative_id, title, description, status,
priority, roadmap_item_id, converted_action_id, created_at, updated_at
"""
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row) result = dict(row)
for key in ("id", "tenant_id", "initiative_id", "converted_action_id"): for key in ("id", "tenant_id", "initiative_id", "converted_action_id", "roadmap_item_id"):
if result.get(key): if result.get(key):
result[key] = str(result[key]) result[key] = str(result[key])
if result.get("created_at"): if result.get("created_at"):
@ -46,6 +52,7 @@ def create_backlog_item(
description: str = "", description: str = "",
status: BacklogStatus = "new", status: BacklogStatus = "new",
priority: str = "normal", priority: str = "normal",
roadmap_item_id: Optional[str] = None,
user_id: Optional[str] = None, user_id: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
title = title.strip() title = title.strip()
@ -59,16 +66,22 @@ def create_backlog_item(
conn = get_connection() conn = get_connection()
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
validate_roadmap_item_in_initiative(
cur,
tenant_id=tenant_id,
initiative_id=initiative_id,
roadmap_item_id=roadmap_item_id,
)
cur.execute( cur.execute(
""" f"""
INSERT INTO backlog_items ( INSERT INTO backlog_items (
tenant_id, initiative_id, title, description, status, priority tenant_id, initiative_id, title, description, status, priority,
roadmap_item_id
) )
VALUES (%s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id, tenant_id, initiative_id, title, description, status, RETURNING {_BACKLOG_COLUMNS}
priority, converted_action_id, created_at, updated_at
""", """,
(tenant_id, initiative_id, title, description, status, priority), (tenant_id, initiative_id, title, description, status, priority, roadmap_item_id),
) )
row = _serialize_row(dict(cur.fetchone())) row = _serialize_row(dict(cur.fetchone()))
conn.commit() conn.commit()
@ -92,9 +105,8 @@ def list_backlog_for_initiative(*, tenant_id: str, initiative_id: str) -> list[d
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute( cur.execute(
""" f"""
SELECT id, tenant_id, initiative_id, title, description, status, SELECT {_BACKLOG_COLUMNS}
priority, converted_action_id, created_at, updated_at
FROM backlog_items FROM backlog_items
WHERE tenant_id = %s AND initiative_id = %s WHERE tenant_id = %s AND initiative_id = %s
ORDER BY updated_at DESC, title ORDER BY updated_at DESC, title
@ -111,9 +123,8 @@ def get_backlog_item(*, tenant_id: str, backlog_item_id: str) -> Optional[dict[s
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute( cur.execute(
""" f"""
SELECT id, tenant_id, initiative_id, title, description, status, SELECT {_BACKLOG_COLUMNS}
priority, converted_action_id, created_at, updated_at
FROM backlog_items FROM backlog_items
WHERE id = %s AND tenant_id = %s WHERE id = %s AND tenant_id = %s
""", """,
@ -134,6 +145,8 @@ def update_backlog_item(
description: Optional[str] = None, description: Optional[str] = None,
status: Optional[BacklogStatus] = None, status: Optional[BacklogStatus] = None,
priority: Optional[str] = None, priority: Optional[str] = None,
roadmap_item_id: Optional[str] = None,
clear_roadmap_item: bool = False,
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id) existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id)
if not existing: if not existing:
@ -162,6 +175,22 @@ def update_backlog_item(
_validate_priority(priority) _validate_priority(priority)
updates.append("priority = %s") updates.append("priority = %s")
params.append(priority) params.append(priority)
if clear_roadmap_item:
updates.append("roadmap_item_id = NULL")
elif roadmap_item_id is not None:
conn = get_connection()
try:
with conn.cursor() as cur:
validate_roadmap_item_in_initiative(
cur,
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
roadmap_item_id=roadmap_item_id,
)
finally:
conn.close()
updates.append("roadmap_item_id = %s")
params.append(roadmap_item_id)
if not updates: if not updates:
return existing return existing
@ -177,8 +206,7 @@ def update_backlog_item(
UPDATE backlog_items UPDATE backlog_items
SET {", ".join(updates)} SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, initiative_id, title, description, status, RETURNING {_BACKLOG_COLUMNS}
priority, converted_action_id, created_at, updated_at
""", """,
params, params,
) )
@ -259,6 +287,7 @@ def convert_backlog_to_action(
title=existing["title"], title=existing["title"],
description=existing["description"] or "", description=existing["description"] or "",
priority=existing["priority"], priority=existing["priority"],
roadmap_item_id=existing.get("roadmap_item_id"),
assigned_actor_ids=assigned_actor_ids or [], assigned_actor_ids=assigned_actor_ids or [],
user_id=user_id, user_id=user_id,
) )
@ -273,8 +302,7 @@ def convert_backlog_to_action(
converted_action_id = %s, converted_action_id = %s,
updated_at = NOW() updated_at = NOW()
WHERE id = %s AND tenant_id = %s WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, initiative_id, title, description, status, RETURNING {_BACKLOG_COLUMNS}
priority, converted_action_id, created_at, updated_at
""", """,
(action["id"], backlog_item_id, tenant_id), (action["id"], backlog_item_id, tenant_id),
) )

View File

@ -0,0 +1,322 @@
"""Plan/Ist-Verknüpfung — Gates, Journey, Beiträge (AP1.6)."""
from __future__ import annotations
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.initiatives import get_initiative
JourneyEventKind = Literal[
"decision",
"review",
"evidence",
"action",
"backlog",
"gate",
]
def validate_roadmap_item_in_initiative(
cur,
*,
tenant_id: str,
initiative_id: str,
roadmap_item_id: Optional[str],
) -> None:
if not roadmap_item_id:
return
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
""",
(roadmap_item_id, tenant_id, initiative_id),
)
if not cur.fetchone():
raise ValueError("Gate gehört nicht zu diesem Vorhaben")
def _iso(value: Any) -> Optional[str]:
if not value:
return None
return value.isoformat() if hasattr(value, "isoformat") else str(value)
def list_gate_contributions(
*, tenant_id: str, roadmap_item_id: str
) -> dict[str, Any]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT ri.id, ri.title, ri.status, r.initiative_id
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
""",
(roadmap_item_id, tenant_id),
)
gate = cur.fetchone()
if not gate:
raise ValueError("Gate nicht gefunden")
initiative_id = str(gate["initiative_id"])
cur.execute(
"""
SELECT id, title, status, priority, converted_action_id
FROM backlog_items
WHERE tenant_id = %s AND initiative_id = %s AND roadmap_item_id = %s
ORDER BY updated_at DESC
""",
(tenant_id, initiative_id, roadmap_item_id),
)
backlog = [dict(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT id, title, status, priority, project_id
FROM actions
WHERE tenant_id = %s AND initiative_id = %s AND roadmap_item_id = %s
ORDER BY updated_at DESC
""",
(tenant_id, initiative_id, roadmap_item_id),
)
actions = [dict(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT id, title, status, parent_project_id
FROM projects
WHERE tenant_id = %s AND initiative_id = %s AND roadmap_item_id = %s
ORDER BY updated_at DESC
""",
(tenant_id, initiative_id, roadmap_item_id),
)
projects = [dict(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT t.id, t.title, t.status, t.action_id, a.title AS action_title
FROM tasks t
JOIN actions a ON a.id = t.action_id AND a.tenant_id = t.tenant_id
WHERE t.tenant_id = %s AND a.initiative_id = %s AND t.roadmap_item_id = %s
ORDER BY t.updated_at DESC
""",
(tenant_id, initiative_id, roadmap_item_id),
)
tasks = [dict(r) for r in cur.fetchall()]
finally:
conn.close()
def _ids(rows: list[dict]) -> list[dict[str, Any]]:
out = []
for row in rows:
item = dict(row)
for key in item:
if item[key] is not None and key.endswith("_id"):
item[key] = str(item[key])
out.append(item)
return out
return {
"roadmap_item_id": roadmap_item_id,
"initiative_id": initiative_id,
"gate_title": gate["title"],
"gate_status": gate["status"],
"backlog_items": _ids(backlog),
"actions": _ids(actions),
"projects": _ids(projects),
"tasks": _ids(tasks),
"counts": {
"backlog": len(backlog),
"actions": len(actions),
"projects": len(projects),
"tasks": len(tasks),
"total": len(backlog) + len(actions) + len(projects) + len(tasks),
},
}
def list_journey_events(*, 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")
events: list[dict[str, Any]] = []
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, title, status, outcome, created_at, updated_at
FROM decisions
WHERE tenant_id = %s AND initiative_id = %s
""",
(tenant_id, initiative_id),
)
for row in cur.fetchall():
events.append(
{
"kind": "decision",
"entity_id": str(row["id"]),
"title": row["title"],
"status": row["status"],
"summary": row["outcome"] or row["status"],
"occurred_at": _iso(row["updated_at"] or row["created_at"]),
"roadmap_item_id": None,
}
)
cur.execute(
"""
SELECT id, title, status, summary, created_at, updated_at, milestone_id, roadmap_item_id
FROM reviews
WHERE tenant_id = %s AND initiative_id = %s
""",
(tenant_id, initiative_id),
)
for row in cur.fetchall():
rid = row.get("roadmap_item_id") or row.get("milestone_id")
events.append(
{
"kind": "review",
"entity_id": str(row["id"]),
"title": row["title"],
"status": row["status"],
"summary": row["summary"] or row["status"],
"occurred_at": _iso(row["updated_at"] or row["created_at"]),
"roadmap_item_id": str(rid) if rid else None,
}
)
cur.execute(
"""
SELECT id, title, status, description, created_at, updated_at,
roadmap_item_id, action_id
FROM evidence
WHERE tenant_id = %s AND initiative_id = %s
""",
(tenant_id, initiative_id),
)
for row in cur.fetchall():
events.append(
{
"kind": "evidence",
"entity_id": str(row["id"]),
"title": row["title"],
"status": row["status"],
"summary": row["description"] or row["status"],
"occurred_at": _iso(row["updated_at"] or row["created_at"]),
"roadmap_item_id": (
str(row["roadmap_item_id"]) if row.get("roadmap_item_id") else None
),
"action_id": str(row["action_id"]) if row.get("action_id") else None,
}
)
cur.execute(
"""
SELECT id, title, status, created_at, updated_at, roadmap_item_id, project_id
FROM actions
WHERE tenant_id = %s AND initiative_id = %s
""",
(tenant_id, initiative_id),
)
for row in cur.fetchall():
events.append(
{
"kind": "action",
"entity_id": str(row["id"]),
"title": row["title"],
"status": row["status"],
"summary": f"Arbeitspaket — {row['status']}",
"occurred_at": _iso(row["created_at"]),
"roadmap_item_id": (
str(row["roadmap_item_id"]) if row.get("roadmap_item_id") else None
),
"project_id": str(row["project_id"]) if row.get("project_id") else None,
}
)
if row["status"] in ("done", "discarded") and row["updated_at"] != row["created_at"]:
events.append(
{
"kind": "action",
"entity_id": str(row["id"]),
"title": row["title"],
"status": row["status"],
"summary": f"Arbeitspaket abgeschlossen — {row['status']}",
"occurred_at": _iso(row["updated_at"]),
"roadmap_item_id": (
str(row["roadmap_item_id"])
if row.get("roadmap_item_id")
else None
),
"project_id": (
str(row["project_id"]) if row.get("project_id") else None
),
}
)
cur.execute(
"""
SELECT id, title, status, created_at, updated_at, roadmap_item_id, converted_action_id
FROM backlog_items
WHERE tenant_id = %s AND initiative_id = %s
""",
(tenant_id, initiative_id),
)
for row in cur.fetchall():
if row["status"] == "converted":
events.append(
{
"kind": "backlog",
"entity_id": str(row["id"]),
"title": row["title"],
"status": row["status"],
"summary": "Backlog → Arbeitspaket committet",
"occurred_at": _iso(row["updated_at"]),
"roadmap_item_id": (
str(row["roadmap_item_id"])
if row.get("roadmap_item_id")
else None
),
"action_id": (
str(row["converted_action_id"])
if row.get("converted_action_id")
else None
),
}
)
cur.execute(
"""
SELECT ri.id, ri.title, ri.status, ri.updated_at, ri.created_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
AND ri.status IN ('reached', 'moved', 'discarded', 'at_risk')
""",
(tenant_id, initiative_id),
)
for row in cur.fetchall():
events.append(
{
"kind": "gate",
"entity_id": str(row["id"]),
"title": row["title"],
"status": row["status"],
"summary": f"Zielzustand — {row['status']}",
"occurred_at": _iso(row["updated_at"] or row["created_at"]),
"roadmap_item_id": str(row["id"]),
}
)
finally:
conn.close()
events = [e for e in events if e.get("occurred_at")]
events.sort(key=lambda e: e["occurred_at"], reverse=True)
return events

View File

@ -0,0 +1,94 @@
"""AP1.6 — Plan/Ist-Verknüpfung und Journey."""
from __future__ import annotations
from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import (
_auth,
_create_initiative,
_login,
)
def _create_gate(client, token, initiative_id, title="Gate A"):
item = client.post(
f"/api/initiatives/{initiative_id}/roadmap/items",
json={"title": title, "item_type": "milestone"},
headers=_auth(token),
)
assert item.status_code == 201
return item.json()
def test_backlog_and_action_gate_link(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
gate = _create_gate(client, token, initiative_id)
backlog = client.post(
f"/api/initiatives/{initiative_id}/backlog",
json={"title": "Idee", "roadmap_item_id": gate["id"]},
headers=_auth(token),
)
assert backlog.status_code == 201
assert backlog.json()["roadmap_item_id"] == gate["id"]
action = client.post(
f"/api/initiatives/{initiative_id}/actions",
json={"title": "Arbeit", "roadmap_item_id": gate["id"]},
headers=_auth(token),
)
assert action.status_code == 201
assert action.json()["roadmap_item_id"] == gate["id"]
def test_convert_backlog_keeps_gate_link(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
gate = _create_gate(client, token, initiative_id)
backlog = client.post(
f"/api/initiatives/{initiative_id}/backlog",
json={"title": "Commit me", "status": "accepted", "roadmap_item_id": gate["id"]},
headers=_auth(token),
).json()
converted = client.post(
f"/api/backlog/{backlog['id']}/convert-to-action",
json={},
headers=_auth(token),
)
assert converted.status_code == 201
assert converted.json()["action"]["roadmap_item_id"] == gate["id"]
def test_gate_contributions_and_journey(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
gate = _create_gate(client, token, initiative_id)
client.post(
f"/api/initiatives/{initiative_id}/actions",
json={"title": "Linked work", "roadmap_item_id": gate["id"]},
headers=_auth(token),
)
contributions = client.get(
f"/api/roadmap-items/{gate['id']}/contributions",
headers=_auth(token),
)
assert contributions.status_code == 200
body = contributions.json()
assert body["counts"]["actions"] == 1
assert body["counts"]["total"] == 1
journey = client.get(
f"/api/initiatives/{initiative_id}/journey",
headers=_auth(token),
)
assert journey.status_code == 200
kinds = {e["kind"] for e in journey.json()["events"]}
assert "action" in kinds

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.14.1-ap1.5c" APP_VERSION = "0.15.0-ap1.6"
DB_SCHEMA_VERSION = "014" DB_SCHEMA_VERSION = "014"
APP_NAME = "jinkendo-kairo" APP_NAME = "jinkendo-kairo"

View File

@ -68,7 +68,8 @@ Phase E Steering AP1.2, AP1.3 ◐
Phase F Gate-Backend AP1.4 + AP1.4b ✓ (Backend); UI minimal Phase F Gate-Backend AP1.4 + AP1.4b ✓ (Backend); UI minimal
Phase G Ist-Hierarchie AP1.5 ✓ Phase G Ist-Hierarchie AP1.5 ✓
Phase G2 Rekursive Container AP1.5c5d ✓ 5c / → 5d nach AP1.6 Phase G2 Rekursive Container AP1.5c5d ✓ 5c / → 5d nach AP1.6
Phase H Plan/Ist-Verknüpfung AP1.6 ← **NÄCHSTES CODE** Phase H Plan/Ist-Verknüpfung AP1.6 ✓
Phase H2 Rekursive Tasks AP1.5d ← **NÄCHSTES CODE**
Phase I Gate-Graph AP1.4d4e nach AP1.6 Phase I Gate-Graph AP1.4d4e nach AP1.6
Phase J Portfolio & Kontext AP1.8 geplant Phase J Portfolio & Kontext AP1.8 geplant
Phase K Agent Interface AP1.7 geplant (unverändert) Phase K Agent Interface AP1.7 geplant (unverändert)
@ -91,7 +92,8 @@ Phase L Validation AP0.10d (laufend PO) parallel
| AP1.2c | 0.12.0-ap1.2c | IA-Skeleton | | AP1.2c | 0.12.0-ap1.2c | IA-Skeleton |
| AP1.4 | 0.13.0-ap1.4 | RoadmapItem / Gates Minimal | | AP1.4 | 0.13.0-ap1.4 | RoadmapItem / Gates Minimal |
| AP1.4b | 0.13.1-ap1.4b | Gate-Checkliste Backend + Detail-Route | | AP1.4b | 0.13.1-ap1.4b | Gate-Checkliste Backend + Detail-Route |
| AP1.5 | 0.14.0-ap1.5 | Project, Task flach, Ausführung-Tab | | AP1.5c | 0.14.1-ap1.5c | Rekursive Projects |
| AP1.6 | 0.15.0-ap1.6 | Plan/Ist, Journey, Gate-Beiträge |
| DOC PO | 2026-07-06 | ADP Product Layering & IA-Reset | | DOC PO | 2026-07-06 | ADP Product Layering & IA-Reset |
| DOC Container | 2026-07-06 | ADP Recursive Containers & Execution Granularity | | DOC Container | 2026-07-06 | ADP Recursive Containers & Execution Granularity |
@ -144,7 +146,7 @@ Phase L Validation AP0.10d (laufend PO) parallel
--- ---
### AP1.6 — Plan/Ist-Verknüpfung & Journey (**NÄCHSTES CODE-PAKET**) ### AP1.6 — Plan/Ist-Verknüpfung & Journey
Backlog/Action/Project/Task → RoadmapItem; Decision-Spur; Journey-Timeline. Backlog/Action/Project/Task → RoadmapItem; Decision-Spur; Journey-Timeline.
@ -288,8 +290,8 @@ MVP-nah wenn:
0.13.1-ap1.4b ✓ Gate-Checkliste Backend 0.13.1-ap1.4b ✓ Gate-Checkliste Backend
0.14.0-ap1.5 ✓ Hierarchie (Project, Task flach) 0.14.0-ap1.5 ✓ Hierarchie (Project, Task flach)
0.14.1-ap1.5c ✓ Rekursive Projects 0.14.1-ap1.5c ✓ Rekursive Projects
0.15.0-ap1.6 → Journey / Plan-Ist — NÄCHSTES 0.15.0-ap1.6 ✓ Plan/Ist / Journey
0.15.1-ap1.5d → Rekursive Tasks / Roll-up 0.15.1-ap1.5d → Rekursive Tasks — NÄCHSTES
0.15.2-ap1.4d → Gate-Graph 0.15.2-ap1.4d → Gate-Graph
0.16.0-ap1.7 → Agent Interface 0.16.0-ap1.7 → Agent Interface
0.17.0-ap1.8 → Portfolio + Kontext 0.17.0-ap1.8 → Portfolio + Kontext

View File

@ -1,7 +1,7 @@
# Kairo — Implementation Truth Table v0.1 # Kairo — Implementation Truth Table v0.1
**Status:** living document — bei jedem AP aktualisieren **Status:** living document — bei jedem AP aktualisieren
**Stand:** 2026-07-06 (nach AP1.5c) **Stand:** 2026-07-06 (nach AP1.6)
**Zweck:** Ehrliche Trennung von **implementiert**, **teilweise**, **nur API/Schema**, **nur Dokumentiert** **Zweck:** Ehrliche Trennung von **implementiert**, **teilweise**, **nur API/Schema**, **nur Dokumentiert**
Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
@ -40,9 +40,9 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|---------|-------|-----------| |---------|-------|-----------|
| Initiative | ✓ | | | Initiative | ✓ | |
| Project | ◐ | AP1.5 + AP1.5c: Baum (`parent_project_id`), Blatt-Regel für Actions | | Project | ◐ | AP1.5 + AP1.5c: Baum (`parent_project_id`), Blatt-Regel für Actions |
| Action | ✓ | UI: Arbeitspaket (interim Maßnahme) | | Action | ✓ | Gate-Zuordnung AP1.6 |
| ActionAssignment | ✓ | | | ActionAssignment | ✓ | |
| BacklogItem | ✓ | | | BacklogItem | ✓ | Gate-Zuordnung AP1.6 |
| Blocker | ✓ | `action_id` optional | | Blocker | ✓ | `action_id` optional |
| Milestone | ◐ | Tabelle MVP-Brücke; kein Gate | | Milestone | ◐ | Tabelle MVP-Brücke; kein Gate |
| Evidence | ✓ | | | Evidence | ✓ | |

View File

@ -0,0 +1,9 @@
import { apiFetch } from './client.js'
export function listInitiativeJourney(initiativeId) {
return apiFetch(`/api/initiatives/${initiativeId}/journey`)
}
export function listGateContributions(roadmapItemId) {
return apiFetch(`/api/roadmap-items/${roadmapItemId}/contributions`)
}

View File

@ -1,6 +1,7 @@
import { ACTION_STATUSES, PRIORITIES } from '../constants/status.js' import { ACTION_STATUSES, PRIORITIES } from '../constants/status.js'
import { ACTION_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js' import { ACTION_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js'
import { ActorSelect } from './ActorSelect.jsx' import { ActorSelect } from './ActorSelect.jsx'
import { GateSelect } from './GateSelect.jsx'
import { flattenLeafProjectOptions, getLeafProjects } from '../utils/projectTree.js' import { flattenLeafProjectOptions, getLeafProjects } from '../utils/projectTree.js'
function isoToLocalInput(iso) { function isoToLocalInput(iso) {
@ -13,6 +14,7 @@ function isoToLocalInput(iso) {
export function ActionForm({ export function ActionForm({
initial = {}, initial = {},
projects = [], projects = [],
roadmapItems = [],
actors = [], actors = [],
actorsLoading = false, actorsLoading = false,
actorsError = null, actorsError = null,
@ -34,6 +36,7 @@ export function ActionForm({
priority: form.priority.value, priority: form.priority.value,
due_at: form.due_at.value ? new Date(form.due_at.value).toISOString() : null, due_at: form.due_at.value ? new Date(form.due_at.value).toISOString() : null,
project_id: form.project_id?.value || undefined, project_id: form.project_id?.value || undefined,
roadmap_item_id: form.roadmap_item_id?.value || undefined,
assigned_actor_ids: selected, assigned_actor_ids: selected,
}) })
} }
@ -93,6 +96,10 @@ export function ActionForm({
</select> </select>
</label> </label>
)} )}
<GateSelect
roadmapItems={roadmapItems}
defaultValue={initial.roadmap_item_id || ''}
/>
<ActorSelect <ActorSelect
actors={actors} actors={actors}
defaultSelected={defaultActors} defaultSelected={defaultActors}

View File

@ -3,6 +3,7 @@ import { ACTION_STATUSES, ACTION_STATUS_LABELS } from '../constants/status.js'
import { StatusBadge } from './StatusBadge.jsx' import { StatusBadge } from './StatusBadge.jsx'
import { PriorityBadge } from './PriorityBadge.jsx' import { PriorityBadge } from './PriorityBadge.jsx'
import { ActionForm } from './ActionForm.jsx' import { ActionForm } from './ActionForm.jsx'
import { gateTitleById } from './GateSelect.jsx'
function formatDue(iso) { function formatDue(iso) {
if (!iso) return null if (!iso) return null
@ -51,6 +52,8 @@ export function ActionHubCard({
actorsUsedFallback, actorsUsedFallback,
onReloadActors, onReloadActors,
initiativeId, initiativeId,
projects = [],
roadmapItems = [],
detailMode = false, detailMode = false,
}) { }) {
const due = formatDue(action.due_at) const due = formatDue(action.due_at)
@ -61,6 +64,8 @@ export function ActionHubCard({
<article className="action-hub-card action-hub-card--editing"> <article className="action-hub-card action-hub-card--editing">
<ActionForm <ActionForm
initial={action} initial={action}
projects={projects}
roadmapItems={roadmapItems}
actors={actors} actors={actors}
actorsLoading={actorsLoading} actorsLoading={actorsLoading}
actorsError={actorsError} actorsError={actorsError}
@ -91,6 +96,9 @@ export function ActionHubCard({
<p className="action-hub-card-desc">{action.description}</p> <p className="action-hub-card-desc">{action.description}</p>
)} )}
<div className="action-hub-card-meta muted"> <div className="action-hub-card-meta muted">
{action.roadmap_item_id && (
<span>Gate: {gateTitleById(roadmapItems, action.roadmap_item_id) || '…'}</span>
)}
{action.assigned_actor_ids?.length > 0 && ( {action.assigned_actor_ids?.length > 0 && (
<span>{action.assigned_actor_ids.length} zugewiesen</span> <span>{action.assigned_actor_ids.length} zugewiesen</span>
)} )}

View File

@ -7,23 +7,32 @@ import { StatusBadge } from './StatusBadge.jsx'
import { PriorityBadge } from './PriorityBadge.jsx' import { PriorityBadge } from './PriorityBadge.jsx'
import { EmptyState } from './EmptyState.jsx' import { EmptyState } from './EmptyState.jsx'
import { GateSelect, gateTitleById } from './GateSelect.jsx'
export function BacklogSection({ export function BacklogSection({
items, items,
roadmapItems = [],
canManage, canManage,
onCreate, onCreate,
onUpdateGate,
onUpdateStatus, onUpdateStatus,
onConvert, onConvert,
onDelete, onDelete,
busy, busy,
}) { }) {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [gateId, setGateId] = useState('')
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault() e.preventDefault()
if (!title.trim()) return if (!title.trim()) return
await onCreate({ title: title.trim() }) await onCreate({
title: title.trim(),
roadmap_item_id: gateId || undefined,
})
setTitle('') setTitle('')
setGateId('')
setShowForm(false) setShowForm(false)
} }
@ -53,6 +62,19 @@ export function BacklogSection({
required required
/> />
</label> </label>
{roadmapItems.length > 0 && (
<label>
Zielzustand (Gate)
<select value={gateId} onChange={(e) => setGateId(e.target.value)}>
<option value=""> keins </option>
{roadmapItems.map((item) => (
<option key={item.id} value={item.id}>
{item.title}
</option>
))}
</select>
</label>
)}
<button type="submit" className="btn btn-primary" disabled={busy}> <button type="submit" className="btn btn-primary" disabled={busy}>
Anlegen Anlegen
</button> </button>
@ -69,10 +91,30 @@ export function BacklogSection({
{item.description && ( {item.description && (
<p className="list-item-desc">{item.description}</p> <p className="list-item-desc">{item.description}</p>
)} )}
{item.roadmap_item_id && (
<p className="list-item-sub muted">
Gate: {gateTitleById(roadmapItems, item.roadmap_item_id) || item.roadmap_item_id}
</p>
)}
</div> </div>
<div className="list-item-meta action-controls"> <div className="list-item-meta action-controls">
<StatusBadge kind="backlog" status={item.status} /> <StatusBadge kind="backlog" status={item.status} />
<PriorityBadge priority={item.priority} /> <PriorityBadge priority={item.priority} />
{canManage && item.status !== 'converted' && roadmapItems.length > 0 && (
<select
className="inline-select"
value={item.roadmap_item_id || ''}
onChange={(e) => onUpdateGate(item.id, e.target.value)}
aria-label="Gate-Zuordnung"
>
<option value="">Gate </option>
{roadmapItems.map((gate) => (
<option key={gate.id} value={gate.id}>
{gate.title}
</option>
))}
</select>
)}
{canManage && item.status !== 'converted' && ( {canManage && item.status !== 'converted' && (
<> <>
<select <select

View File

@ -0,0 +1,93 @@
import { Link } from 'react-router-dom'
export function GateContributionsSection({ contributions, initiativeId, loading, error }) {
if (loading) return <p className="muted">Ist-Beiträge werden geladen</p>
if (error) return <p className="error">{error}</p>
if (!contributions) return null
const { counts } = contributions
if (counts.total === 0) {
return (
<section className="card gate-contributions">
<h2>Ist-Beiträge</h2>
<p className="section-lead muted">
Noch keine Verknüpfung ordne Backlog, Projekte, Arbeitspakete oder Aufgaben diesem Gate zu.
</p>
</section>
)
}
return (
<section className="card gate-contributions">
<h2>Ist-Beiträge</h2>
<p className="section-lead muted">
Operative Arbeit, die auf diesen Zielzustand einzahlt ({counts.total} Verknüpfung
{counts.total === 1 ? '' : 'en'}).
</p>
<div className="gate-contributions-grid">
{contributions.actions?.length > 0 && (
<div>
<h3>Arbeitspakete</h3>
<ul className="item-list compact-list">
{contributions.actions.map((item) => (
<li key={item.id}>
<Link
to={`/initiatives/${initiativeId}/actions/${item.id}`}
className="link-button"
>
{item.title}
</Link>
<span className="muted"> {item.status}</span>
</li>
))}
</ul>
</div>
)}
{contributions.backlog_items?.length > 0 && (
<div>
<h3>Backlog</h3>
<ul className="item-list compact-list">
{contributions.backlog_items.map((item) => (
<li key={item.id}>
{item.title}
<span className="muted"> {item.status}</span>
</li>
))}
</ul>
</div>
)}
{contributions.projects?.length > 0 && (
<div>
<h3>Projekte</h3>
<ul className="item-list compact-list">
{contributions.projects.map((item) => (
<li key={item.id}>
{item.title}
<span className="muted"> {item.status}</span>
</li>
))}
</ul>
</div>
)}
{contributions.tasks?.length > 0 && (
<div>
<h3>Aufgaben</h3>
<ul className="item-list compact-list">
{contributions.tasks.map((item) => (
<li key={item.id}>
<Link
to={`/initiatives/${initiativeId}/actions/${item.action_id}`}
className="link-button"
>
{item.title}
</Link>
<span className="muted"> ({item.action_title})</span>
</li>
))}
</ul>
</div>
)}
</div>
</section>
)
}

View File

@ -0,0 +1,28 @@
export function GateSelect({
roadmapItems = [],
name = 'roadmap_item_id',
defaultValue = '',
label = 'Zielzustand (Gate)',
allowEmpty = true,
}) {
if (roadmapItems.length === 0) return null
return (
<label>
{label}
<select name={name} defaultValue={defaultValue || ''}>
{allowEmpty && <option value=""> keins </option>}
{roadmapItems.map((item) => (
<option key={item.id} value={item.id}>
{item.title}
</option>
))}
</select>
</label>
)
}
export function gateTitleById(roadmapItems, gateId) {
if (!gateId) return null
return roadmapItems.find((item) => item.id === gateId)?.title || null
}

View File

@ -6,6 +6,7 @@ export function InitiativeActionsHub({
initiativeId, initiativeId,
actions, actions,
projects = [], projects = [],
roadmapItems = [],
actionContextById, actionContextById,
hideDone, hideDone,
onHideDoneChange, onHideDoneChange,
@ -62,6 +63,7 @@ export function InitiativeActionsHub({
<div className="inline-form-block"> <div className="inline-form-block">
<ActionForm <ActionForm
projects={projects} projects={projects}
roadmapItems={roadmapItems}
actors={actors} actors={actors}
actorsLoading={actorsLoading} actorsLoading={actorsLoading}
actorsError={actorsError} actorsError={actorsError}
@ -100,6 +102,8 @@ export function InitiativeActionsHub({
actorsUsedFallback={actorsUsedFallback} actorsUsedFallback={actorsUsedFallback}
onReloadActors={onReloadActors} onReloadActors={onReloadActors}
initiativeId={initiativeId} initiativeId={initiativeId}
projects={projects}
roadmapItems={roadmapItems}
/> />
))} ))}
</div> </div>

View File

@ -0,0 +1,71 @@
import { Link } from 'react-router-dom'
const KIND_LABELS = {
decision: 'Entscheidung',
review: 'Review',
evidence: 'Nachweis',
action: 'Arbeitspaket',
backlog: 'Backlog',
gate: 'Zielzustand',
}
function formatWhen(iso) {
if (!iso) return ''
try {
return new Date(iso).toLocaleString('de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
})
} catch {
return iso
}
}
export function JourneyTimeline({ events, initiativeId, roadmapItems = [] }) {
if (!events?.length) {
return <p className="muted">Noch keine Journey-Einträge Entscheidungen, Nachweise und committete Arbeit erscheinen hier.</p>
}
const gateTitles = Object.fromEntries(roadmapItems.map((item) => [item.id, item.title]))
return (
<ol className="journey-timeline">
{events.map((event, index) => (
<li key={`${event.kind}-${event.entity_id}-${index}`} className="journey-event card-list-item">
<div className="journey-event-meta muted">
<span className="journey-event-kind">{KIND_LABELS[event.kind] || event.kind}</span>
<time dateTime={event.occurred_at}>{formatWhen(event.occurred_at)}</time>
</div>
<strong>{event.title}</strong>
{event.summary && <p className="list-item-desc muted">{event.summary}</p>}
<div className="journey-event-links">
{event.roadmap_item_id && gateTitles[event.roadmap_item_id] && (
<Link
to={`/initiatives/${initiativeId}/plan/items/${event.roadmap_item_id}`}
className="link-button"
>
Gate: {gateTitles[event.roadmap_item_id]}
</Link>
)}
{event.kind === 'action' && (
<Link
to={`/initiatives/${initiativeId}/actions/${event.entity_id}`}
className="link-button"
>
Arbeitspaket öffnen
</Link>
)}
{event.kind === 'gate' && (
<Link
to={`/initiatives/${initiativeId}/plan/items/${event.entity_id}`}
className="link-button"
>
Gate öffnen
</Link>
)}
</div>
</li>
))}
</ol>
)
}

View File

@ -6,9 +6,11 @@ import {
containerKindLabel, containerKindLabel,
flattenProjectOptions, flattenProjectOptions,
} from '../utils/projectTree.js' } from '../utils/projectTree.js'
import { GateSelect, gateTitleById } from './GateSelect.jsx'
function ProjectTreeNodes({ function ProjectTreeNodes({
projects, projects,
roadmapItems,
parentId, parentId,
depth, depth,
selectedProjectId, selectedProjectId,
@ -42,6 +44,11 @@ function ProjectTreeNodes({
{containerKindLabel(project.container_kind)} {containerKindLabel(project.container_kind)}
</span> </span>
)} )}
{project.roadmap_item_id && (
<span className="project-kind-badge muted">
Gate: {gateTitleById(roadmapItems, project.roadmap_item_id)}
</span>
)}
{project.description && ( {project.description && (
<p className="list-item-desc muted">{project.description}</p> <p className="list-item-desc muted">{project.description}</p>
)} )}
@ -62,6 +69,7 @@ function ProjectTreeNodes({
</div> </div>
<ProjectTreeNodes <ProjectTreeNodes
projects={projects} projects={projects}
roadmapItems={roadmapItems}
parentId={project.id} parentId={project.id}
depth={depth + 1} depth={depth + 1}
selectedProjectId={selectedProjectId} selectedProjectId={selectedProjectId}
@ -78,6 +86,7 @@ function ProjectTreeNodes({
export function ProjectsSection({ export function ProjectsSection({
projects, projects,
roadmapItems = [],
selectedProjectId, selectedProjectId,
onSelectProject, onSelectProject,
canManage, canManage,
@ -89,6 +98,7 @@ export function ProjectsSection({
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [parentProjectId, setParentProjectId] = useState('') const [parentProjectId, setParentProjectId] = useState('')
const [containerKind, setContainerKind] = useState('') const [containerKind, setContainerKind] = useState('')
const [gateId, setGateId] = useState('')
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
const parentOptions = useMemo( const parentOptions = useMemo(
@ -104,11 +114,13 @@ export function ProjectsSection({
description: description.trim(), description: description.trim(),
parent_project_id: parentProjectId || undefined, parent_project_id: parentProjectId || undefined,
container_kind: containerKind || undefined, container_kind: containerKind || undefined,
roadmap_item_id: gateId || undefined,
}) })
setTitle('') setTitle('')
setDescription('') setDescription('')
setParentProjectId('') setParentProjectId('')
setContainerKind('') setContainerKind('')
setGateId('')
setShowForm(false) setShowForm(false)
} }
@ -195,6 +207,19 @@ export function ProjectsSection({
<option value="release">Release</option> <option value="release">Release</option>
</select> </select>
</label> </label>
{roadmapItems.length > 0 && (
<label>
Zielzustand (Gate)
<select value={gateId} onChange={(e) => setGateId(e.target.value)}>
<option value=""> keins </option>
{roadmapItems.map((item) => (
<option key={item.id} value={item.id}>
{item.title}
</option>
))}
</select>
</label>
)}
<button type="submit" className="btn btn-primary" disabled={busy}> <button type="submit" className="btn btn-primary" disabled={busy}>
Anlegen Anlegen
</button> </button>
@ -218,6 +243,7 @@ export function ProjectsSection({
</ul> </ul>
<ProjectTreeNodes <ProjectTreeNodes
projects={projects} projects={projects}
roadmapItems={roadmapItems}
parentId="" parentId=""
depth={1} depth={1}
selectedProjectId={selectedProjectId} selectedProjectId={selectedProjectId}

View File

@ -7,13 +7,15 @@ import {
} from '../api/tasks.js' } from '../api/tasks.js'
import { TASK_STATUSES, TASK_STATUS_LABELS } from '../constants/status.js' import { TASK_STATUSES, TASK_STATUS_LABELS } from '../constants/status.js'
import { EmptyState } from './EmptyState.jsx' import { EmptyState } from './EmptyState.jsx'
import { gateTitleById } from './GateSelect.jsx'
export function TasksSection({ actionId, canManage }) { export function TasksSection({ actionId, canManage, roadmapItems = [] }) {
const [tasks, setTasks] = useState([]) const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [gateId, setGateId] = useState('')
const load = useCallback(async () => { const load = useCallback(async () => {
if (!actionId) return if (!actionId) return
@ -37,8 +39,12 @@ export function TasksSection({ actionId, canManage }) {
if (!title.trim()) return if (!title.trim()) return
setBusy(true) setBusy(true)
try { try {
await createActionTask(actionId, { title: title.trim() }) await createActionTask(actionId, {
title: title.trim(),
roadmap_item_id: gateId || undefined,
})
setTitle('') setTitle('')
setGateId('')
await load() await load()
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
@ -59,6 +65,21 @@ export function TasksSection({ actionId, canManage }) {
} }
} }
async function handleGate(taskId, roadmapItemId) {
setBusy(true)
try {
await updateTask(taskId, {
roadmap_item_id: roadmapItemId || undefined,
clear_roadmap_item: !roadmapItemId,
})
await load()
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
async function handleDelete(taskId) { async function handleDelete(taskId) {
setBusy(true) setBusy(true)
try { try {
@ -79,7 +100,7 @@ export function TasksSection({ actionId, canManage }) {
<div> <div>
<h3>Aufgaben</h3> <h3>Aufgaben</h3>
<p className="section-lead muted"> <p className="section-lead muted">
Kleinste ausführbare Schritte später Zuordnung zu Gates (Zielerreichung). Kleinste ausführbare Schritte optional einem Zielzustand (Gate) zugeordnet.
</p> </p>
</div> </div>
</div> </div>
@ -98,6 +119,19 @@ export function TasksSection({ actionId, canManage }) {
placeholder="Konkreter nächster Schritt" placeholder="Konkreter nächster Schritt"
/> />
</label> </label>
{roadmapItems.length > 0 && (
<label>
Gate (optional)
<select value={gateId} onChange={(e) => setGateId(e.target.value)}>
<option value=""> keins </option>
{roadmapItems.map((item) => (
<option key={item.id} value={item.id}>
{item.title}
</option>
))}
</select>
</label>
)}
<button type="submit" className="btn btn-primary" disabled={busy}> <button type="submit" className="btn btn-primary" disabled={busy}>
Hinzufügen Hinzufügen
</button> </button>
@ -113,8 +147,29 @@ export function TasksSection({ actionId, canManage }) {
<li key={task.id} className="list-item card-list-item"> <li key={task.id} className="list-item card-list-item">
<div className="list-item-main"> <div className="list-item-main">
<strong>{task.title}</strong> <strong>{task.title}</strong>
{task.roadmap_item_id && (
<p className="list-item-sub muted">
Gate: {gateTitleById(roadmapItems, task.roadmap_item_id)}
</p>
)}
</div> </div>
<div className="list-item-meta action-controls"> <div className="list-item-meta action-controls">
{canManage && roadmapItems.length > 0 && (
<select
className="inline-select"
value={task.roadmap_item_id || ''}
disabled={busy}
onChange={(e) => handleGate(task.id, e.target.value)}
aria-label="Gate-Zuordnung"
>
<option value="">Gate </option>
{roadmapItems.map((gate) => (
<option key={gate.id} value={gate.id}>
{gate.title}
</option>
))}
</select>
)}
{canManage ? ( {canManage ? (
<select <select
className="inline-select" className="inline-select"

View File

@ -233,6 +233,8 @@ export function InitiativeOperationsProvider({ children }) {
clear_due_at: !payload.due_at, clear_due_at: !payload.due_at,
project_id: payload.project_id, project_id: payload.project_id,
clear_project: payload.project_id === '' || payload.project_id === null, clear_project: payload.project_id === '' || payload.project_id === null,
roadmap_item_id: payload.roadmap_item_id || undefined,
clear_roadmap_item: payload.roadmap_item_id === '' || payload.roadmap_item_id === null,
}) })
if (capabilities.has('kairo.action.manage')) { if (capabilities.has('kairo.action.manage')) {
await setActionAssignments(actionId, payload.assigned_actor_ids || []) await setActionAssignments(actionId, payload.assigned_actor_ids || [])
@ -315,6 +317,18 @@ export function InitiativeOperationsProvider({ children }) {
} }
} }
async function handleBacklogGate(itemId, roadmapItemId) {
try {
await updateBacklogItem(itemId, {
roadmap_item_id: roadmapItemId || undefined,
clear_roadmap_item: !roadmapItemId,
})
await load()
} catch (err) {
setError(err.message)
}
}
async function handleBacklogStatus(itemId, status) { async function handleBacklogStatus(itemId, status) {
try { try {
await updateBacklogItem(itemId, { status }) await updateBacklogItem(itemId, { status })
@ -609,6 +623,7 @@ export function InitiativeOperationsProvider({ children }) {
handleBlockerStatus, handleBlockerStatus,
handleDeleteBlocker, handleDeleteBlocker,
handleCreateBacklog, handleCreateBacklog,
handleBacklogGate,
handleBacklogStatus, handleBacklogStatus,
handleConvertBacklog, handleConvertBacklog,
handleDeleteBacklog, handleDeleteBacklog,

View File

@ -3,6 +3,7 @@ import { Link, useParams } from 'react-router-dom'
import { getAction } from '../api/actions.js' import { getAction } from '../api/actions.js'
import { getInitiativeSteeringSnapshot } from '../api/initiatives.js' import { getInitiativeSteeringSnapshot } from '../api/initiatives.js'
import { listInitiativeProjects } from '../api/projects.js' import { listInitiativeProjects } from '../api/projects.js'
import { listInitiativeRoadmapItems } from '../api/roadmap.js'
import { updateAction, setActionAssignments } from '../api/actions.js' import { updateAction, setActionAssignments } from '../api/actions.js'
import { createInitiativeBlocker } from '../api/blockers.js' import { createInitiativeBlocker } from '../api/blockers.js'
import { ActionHubCard } from '../components/ActionHubCard.jsx' import { ActionHubCard } from '../components/ActionHubCard.jsx'
@ -27,12 +28,14 @@ function ActionDetailBody({
capabilities, capabilities,
actorsState, actorsState,
projects = [], projects = [],
roadmapItems = [],
}) { }) {
if (editing) { if (editing) {
return ( return (
<ActionForm <ActionForm
initial={action} initial={action}
projects={projects} projects={projects}
roadmapItems={roadmapItems}
actors={actorsState.actors} actors={actorsState.actors}
actorsLoading={actorsState.loading} actorsLoading={actorsState.loading}
actorsError={actorsState.error} actorsError={actorsState.error}
@ -70,6 +73,7 @@ function ActionDetailBody({
<TasksSection <TasksSection
actionId={action.id} actionId={action.id}
canManage={capabilities.has('kairo.action.manage')} canManage={capabilities.has('kairo.action.manage')}
roadmapItems={roadmapItems}
/> />
</> </>
) )
@ -81,6 +85,7 @@ function ActionDetailStandalone() {
const actorsState = useActors() const actorsState = useActors()
const [action, setAction] = useState(null) const [action, setAction] = useState(null)
const [projects, setProjects] = useState([]) const [projects, setProjects] = useState([])
const [roadmapItems, setRoadmapItems] = useState([])
const [context, setContext] = useState(null) const [context, setContext] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
@ -93,11 +98,13 @@ function ActionDetailStandalone() {
try { try {
const actionData = await getAction(actionId) const actionData = await getAction(actionId)
setAction(actionData) setAction(actionData)
const [snap, projectData] = await Promise.all([ const [snap, projectData, roadmapData] = await Promise.all([
getInitiativeSteeringSnapshot(actionData.initiative_id), getInitiativeSteeringSnapshot(actionData.initiative_id),
listInitiativeProjects(actionData.initiative_id).catch(() => []), listInitiativeProjects(actionData.initiative_id).catch(() => []),
listInitiativeRoadmapItems(actionData.initiative_id).catch(() => []),
]) ])
setProjects(projectData) setProjects(projectData)
setRoadmapItems(roadmapData)
setContext((snap.actions || []).find((a) => a.id === actionId) || null) setContext((snap.actions || []).find((a) => a.id === actionId) || null)
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
@ -120,6 +127,10 @@ function ActionDetailStandalone() {
priority: payload.priority, priority: payload.priority,
due_at: payload.due_at, due_at: payload.due_at,
clear_due_at: !payload.due_at, clear_due_at: !payload.due_at,
project_id: payload.project_id,
clear_project: payload.project_id === '' || payload.project_id === null,
roadmap_item_id: payload.roadmap_item_id,
clear_roadmap_item: payload.roadmap_item_id === '' || payload.roadmap_item_id === null,
}) })
if (capabilities.has('kairo.action.manage')) { if (capabilities.has('kairo.action.manage')) {
await setActionAssignments(action.id, payload.assigned_actor_ids || []) await setActionAssignments(action.id, payload.assigned_actor_ids || [])
@ -204,6 +215,7 @@ function ActionDetailStandalone() {
capabilities={capabilities} capabilities={capabilities}
actorsState={actorsState} actorsState={actorsState}
projects={projects} projects={projects}
roadmapItems={roadmapItems}
/> />
</section> </section>
</div> </div>
@ -245,6 +257,7 @@ function ActionDetailNested() {
reload: ops.reloadActors, reload: ops.reloadActors,
}} }}
projects={ops.projects} projects={ops.projects}
roadmapItems={ops.roadmapItems}
/> />
</section> </section>
) )

View File

@ -8,6 +8,7 @@ export function InitiativeExecutionPage() {
const { const {
visibleActions, visibleActions,
projects, projects,
roadmapItems,
selectedProjectId, selectedProjectId,
setSelectedProjectId, setSelectedProjectId,
actionContextById, actionContextById,
@ -22,6 +23,7 @@ export function InitiativeExecutionPage() {
unlinkedBlockers, unlinkedBlockers,
initiativeId, initiativeId,
error, error,
roadmapItems,
} = ops } = ops
return ( return (
@ -30,6 +32,7 @@ export function InitiativeExecutionPage() {
<ProjectsSection <ProjectsSection
projects={projects} projects={projects}
roadmapItems={roadmapItems}
selectedProjectId={selectedProjectId} selectedProjectId={selectedProjectId}
onSelectProject={setSelectedProjectId} onSelectProject={setSelectedProjectId}
canManage={capabilities.has('kairo.project.manage')} canManage={capabilities.has('kairo.project.manage')}
@ -42,6 +45,7 @@ export function InitiativeExecutionPage() {
initiativeId={initiativeId} initiativeId={initiativeId}
actions={visibleActions} actions={visibleActions}
projects={projects} projects={projects}
roadmapItems={roadmapItems}
actionContextById={actionContextById} actionContextById={actionContextById}
hideDone={hideDone} hideDone={hideDone}
onHideDoneChange={setHideDone} onHideDoneChange={setHideDone}

View File

@ -4,10 +4,12 @@ import { BacklogSection } from '../../components/BacklogSection.jsx'
export function InitiativeInboxPage() { export function InitiativeInboxPage() {
const { const {
backlogItems, backlogItems,
roadmapItems,
capabilities, capabilities,
formBusy, formBusy,
error, error,
handleCreateBacklog, handleCreateBacklog,
handleBacklogGate,
handleBacklogStatus, handleBacklogStatus,
handleConvertBacklog, handleConvertBacklog,
handleDeleteBacklog, handleDeleteBacklog,
@ -22,8 +24,10 @@ export function InitiativeInboxPage() {
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
<BacklogSection <BacklogSection
items={backlogItems} items={backlogItems}
roadmapItems={roadmapItems}
canManage={capabilities.has('kairo.backlog.manage')} canManage={capabilities.has('kairo.backlog.manage')}
onCreate={handleCreateBacklog} onCreate={handleCreateBacklog}
onUpdateGate={handleBacklogGate}
onUpdateStatus={handleBacklogStatus} onUpdateStatus={handleBacklogStatus}
onConvert={handleConvertBacklog} onConvert={handleConvertBacklog}
onDelete={handleDeleteBacklog} onDelete={handleDeleteBacklog}

View File

@ -1,12 +1,36 @@
import { useCallback, useEffect, useState } from 'react'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { listInitiativeJourney } from '../../api/journey.js'
import { DecisionsSection } from '../../components/DecisionsSection.jsx' import { DecisionsSection } from '../../components/DecisionsSection.jsx'
import { ReviewsSection } from '../../components/ReviewsSection.jsx' import { ReviewsSection } from '../../components/ReviewsSection.jsx'
import { RecurringSection } from '../../components/RecurringSection.jsx' import { RecurringSection } from '../../components/RecurringSection.jsx'
import { EvidenceSection } from '../../components/EvidenceSection.jsx' import { EvidenceSection } from '../../components/EvidenceSection.jsx'
import { JourneyTimeline } from '../../components/JourneyTimeline.jsx'
export function InitiativeJourneyPage() { export function InitiativeJourneyPage() {
const ops = useInitiativeOperations() const ops = useInitiativeOperations()
const { capabilities, error, formBusy } = ops const { id, capabilities, error, formBusy, roadmapItems } = ops
const [journeyEvents, setJourneyEvents] = useState([])
const [journeyLoading, setJourneyLoading] = useState(true)
const [journeyError, setJourneyError] = useState(null)
const loadJourney = useCallback(async () => {
if (!id) return
setJourneyLoading(true)
setJourneyError(null)
try {
const data = await listInitiativeJourney(id)
setJourneyEvents(data.events || [])
} catch (err) {
setJourneyError(err.message)
} finally {
setJourneyLoading(false)
}
}, [id])
useEffect(() => {
loadJourney()
}, [loadJourney, ops.decisions, ops.reviews, ops.evidenceItems, ops.actions])
if (!capabilities.has('kairo.initiative.read')) { if (!capabilities.has('kairo.initiative.read')) {
return null return null
@ -15,9 +39,25 @@ export function InitiativeJourneyPage() {
return ( return (
<> <>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
<p className="section-lead muted initiative-journey-lead">
Entscheidungen, Reviews und Nachweise die Reise-Timeline folgt in AP1.6. <section className="card journey-timeline-section">
</p> <h2>Journey</h2>
<p className="section-lead muted">
Chronologie aus Entscheidungen, Nachweisen, committeter Arbeit und Zielzuständen Plan/Ist
nachvollziehbar.
</p>
{journeyError && <p className="error">{journeyError}</p>}
{journeyLoading ? (
<p className="muted">Timeline wird geladen</p>
) : (
<JourneyTimeline
events={journeyEvents}
initiativeId={id}
roadmapItems={roadmapItems}
/>
)}
</section>
<div className="initiative-journey-stack"> <div className="initiative-journey-stack">
<DecisionsSection <DecisionsSection
items={ops.decisions} items={ops.decisions}

View File

@ -25,6 +25,8 @@ import {
ROADMAP_ITEM_TYPE_LABELS, ROADMAP_ITEM_TYPE_LABELS,
} from '../../constants/status.js' } from '../../constants/status.js'
import { useCapabilities } from '../../hooks/useCapabilities.js' import { useCapabilities } from '../../hooks/useCapabilities.js'
import { listGateContributions } from '../../api/journey.js'
import { GateContributionsSection } from '../../components/GateContributionsSection.jsx'
function CriterionDecisionForm({ label, onSubmit, busy }) { function CriterionDecisionForm({ label, onSubmit, busy }) {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
@ -85,6 +87,21 @@ export function RoadmapItemDetailPage() {
const [newKind, setNewKind] = useState('manual') const [newKind, setNewKind] = useState('manual')
const [expandedDecision, setExpandedDecision] = useState(null) const [expandedDecision, setExpandedDecision] = useState(null)
const [reopenReason, setReopenReason] = useState('') const [reopenReason, setReopenReason] = useState('')
const [contributions, setContributions] = useState(null)
const [contributionsLoading, setContributionsLoading] = useState(true)
const [contributionsError, setContributionsError] = useState(null)
const loadContributions = useCallback(async () => {
setContributionsLoading(true)
setContributionsError(null)
try {
setContributions(await listGateContributions(itemId))
} catch (err) {
setContributionsError(err.message)
} finally {
setContributionsLoading(false)
}
}, [itemId])
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true) setLoading(true)
@ -108,6 +125,10 @@ export function RoadmapItemDetailPage() {
load() load()
}, [load]) }, [load])
useEffect(() => {
loadContributions()
}, [loadContributions])
async function runAction(fn) { async function runAction(fn) {
setBusy(true) setBusy(true)
setError(null) setError(null)
@ -155,6 +176,13 @@ export function RoadmapItemDetailPage() {
Checkliste: {closedCount}/{totalCount} Kriterien abgeschlossen Checkliste: {closedCount}/{totalCount} Kriterien abgeschlossen
</p> </p>
<GateContributionsSection
contributions={contributions}
initiativeId={initiativeId}
loading={contributionsLoading}
error={contributionsError}
/>
<ul className="item-list criterion-list"> <ul className="item-list criterion-list">
{criteria.map((crit) => ( {criteria.map((crit) => (
<li key={crit.id} className="list-item card-list-item"> <li key={crit.id} className="list-item card-list-item">

View File

@ -458,6 +458,49 @@
margin-left: 0.35rem; margin-left: 0.35rem;
} }
.journey-timeline {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.journey-event {
padding: 0.85rem 1rem;
}
.journey-event-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
font-size: 0.85rem;
margin-bottom: 0.35rem;
}
.journey-event-kind {
font-weight: 600;
}
.journey-event-links {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.5rem;
}
.gate-contributions-grid {
display: grid;
gap: 1rem;
}
@media (min-width: 640px) {
.gate-contributions-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.section-header h2 { .section-header h2 {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;