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
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:
parent
4ca0a2c460
commit
7e4936a2e4
|
|
@ -66,6 +66,7 @@ from routers import ( # noqa: E402
|
|||
evidence,
|
||||
features,
|
||||
initiatives,
|
||||
journey,
|
||||
me,
|
||||
milestones,
|
||||
projects,
|
||||
|
|
@ -84,6 +85,8 @@ app.include_router(features.router)
|
|||
app.include_router(prompts.router)
|
||||
app.include_router(config.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(projects.initiative_router)
|
||||
app.include_router(projects.items_router)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ class ActionUpdateRequest(BaseModel):
|
|||
clear_due_at: bool = False
|
||||
project_id: Optional[str] = None
|
||||
clear_project: bool = False
|
||||
roadmap_item_id: Optional[str] = None
|
||||
clear_roadmap_item: bool = False
|
||||
|
||||
|
||||
class TaskCreateRequest(BaseModel):
|
||||
|
|
@ -129,6 +131,8 @@ def update_action(
|
|||
clear_due_at=body.clear_due_at,
|
||||
project_id=body.project_id,
|
||||
clear_project=body.clear_project,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
clear_roadmap_item=body.clear_roadmap_item,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class BacklogCreateRequest(BaseModel):
|
|||
description: str = ""
|
||||
status: Literal["new", "triaged", "accepted", "rejected"] = "new"
|
||||
priority: Literal["low", "normal", "high"] = "normal"
|
||||
roadmap_item_id: Optional[str] = None
|
||||
|
||||
|
||||
class BacklogUpdateRequest(BaseModel):
|
||||
|
|
@ -25,6 +26,8 @@ class BacklogUpdateRequest(BaseModel):
|
|||
description: Optional[str] = None
|
||||
status: Optional[Literal["new", "triaged", "accepted", "rejected"]] = None
|
||||
priority: Optional[Literal["low", "normal", "high"]] = None
|
||||
roadmap_item_id: Optional[str] = None
|
||||
clear_roadmap_item: bool = False
|
||||
|
||||
|
||||
class BacklogConvertRequest(BaseModel):
|
||||
|
|
@ -59,6 +62,8 @@ def update_backlog_item(
|
|||
description=body.description,
|
||||
status=body.status,
|
||||
priority=body.priority,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
clear_roadmap_item=body.clear_roadmap_item,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ class ActionCreateRequest(BaseModel):
|
|||
priority: Literal["low", "normal", "high"] = "normal"
|
||||
due_at: Optional[str] = None
|
||||
project_id: Optional[str] = None
|
||||
roadmap_item_id: Optional[str] = None
|
||||
assigned_actor_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
|
|
@ -65,6 +66,7 @@ class BacklogCreateRequest(BaseModel):
|
|||
description: str = ""
|
||||
status: Literal["new", "triaged", "accepted", "rejected"] = "new"
|
||||
priority: Literal["low", "normal", "high"] = "normal"
|
||||
roadmap_item_id: Optional[str] = None
|
||||
|
||||
|
||||
class MilestoneCreateRequest(BaseModel):
|
||||
|
|
@ -251,6 +253,7 @@ def create_initiative_action(
|
|||
priority=body.priority,
|
||||
due_at=due_at,
|
||||
project_id=body.project_id,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
assigned_actor_ids=body.assigned_actor_ids,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
|
|
@ -330,6 +333,7 @@ def create_initiative_backlog_item(
|
|||
description=body.description,
|
||||
status=body.status,
|
||||
priority=body.priority,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
|
|
|
|||
44
backend/routers/journey.py
Normal file
44
backend/routers/journey.py
Normal 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
|
||||
|
|
@ -9,6 +9,7 @@ from psycopg2.extras import RealDictCursor
|
|||
from db import get_connection
|
||||
from services.audit import log_audit
|
||||
from services.initiatives import PRIORITIES, get_initiative
|
||||
from services.plan_ist import validate_roadmap_item_in_initiative
|
||||
from services.projects import project_is_leaf
|
||||
|
||||
ActionStatus = Literal[
|
||||
|
|
@ -23,14 +24,14 @@ OPEN_ACTION_STATUSES = frozenset(
|
|||
)
|
||||
|
||||
_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
|
||||
"""
|
||||
|
||||
|
||||
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
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):
|
||||
result[key] = str(result[key])
|
||||
if result.get("created_at"):
|
||||
|
|
@ -135,6 +136,7 @@ def create_action(
|
|||
priority: str = "normal",
|
||||
due_at: Optional[Any] = None,
|
||||
project_id: Optional[str] = None,
|
||||
roadmap_item_id: Optional[str] = None,
|
||||
assigned_actor_ids: Optional[list[str]] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
|
|
@ -157,16 +159,32 @@ def create_action(
|
|||
conn = get_connection()
|
||||
try:
|
||||
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(
|
||||
f"""
|
||||
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
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
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()))
|
||||
for actor_id in assigned_actor_ids:
|
||||
|
|
@ -254,6 +272,8 @@ def update_action(
|
|||
clear_due_at: bool = False,
|
||||
project_id: Optional[str] = None,
|
||||
clear_project: bool = False,
|
||||
roadmap_item_id: Optional[str] = None,
|
||||
clear_roadmap_item: bool = False,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
existing = get_action(tenant_id=tenant_id, action_id=action_id)
|
||||
if not existing:
|
||||
|
|
@ -295,6 +315,22 @@ def update_action(
|
|||
)
|
||||
updates.append("project_id = %s")
|
||||
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:
|
||||
return existing
|
||||
|
|
|
|||
|
|
@ -10,15 +10,21 @@ from db import get_connection
|
|||
from services.actions import create_action
|
||||
from services.audit import log_audit
|
||||
from services.initiatives import PRIORITIES, get_initiative
|
||||
from services.plan_ist import validate_roadmap_item_in_initiative
|
||||
|
||||
BacklogStatus = Literal["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]:
|
||||
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):
|
||||
result[key] = str(result[key])
|
||||
if result.get("created_at"):
|
||||
|
|
@ -46,6 +52,7 @@ def create_backlog_item(
|
|||
description: str = "",
|
||||
status: BacklogStatus = "new",
|
||||
priority: str = "normal",
|
||||
roadmap_item_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
title = title.strip()
|
||||
|
|
@ -59,16 +66,22 @@ def create_backlog_item(
|
|||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO backlog_items (
|
||||
tenant_id, initiative_id, title, description, status, priority
|
||||
validate_roadmap_item_in_initiative(
|
||||
cur,
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
roadmap_item_id=roadmap_item_id,
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id, tenant_id, initiative_id, title, description, status,
|
||||
priority, converted_action_id, created_at, updated_at
|
||||
cur.execute(
|
||||
f"""
|
||||
INSERT INTO backlog_items (
|
||||
tenant_id, initiative_id, title, description, status, priority,
|
||||
roadmap_item_id
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING {_BACKLOG_COLUMNS}
|
||||
""",
|
||||
(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()))
|
||||
conn.commit()
|
||||
|
|
@ -92,9 +105,8 @@ def list_backlog_for_initiative(*, tenant_id: str, initiative_id: str) -> list[d
|
|||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, tenant_id, initiative_id, title, description, status,
|
||||
priority, converted_action_id, created_at, updated_at
|
||||
f"""
|
||||
SELECT {_BACKLOG_COLUMNS}
|
||||
FROM backlog_items
|
||||
WHERE tenant_id = %s AND initiative_id = %s
|
||||
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:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, tenant_id, initiative_id, title, description, status,
|
||||
priority, converted_action_id, created_at, updated_at
|
||||
f"""
|
||||
SELECT {_BACKLOG_COLUMNS}
|
||||
FROM backlog_items
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
""",
|
||||
|
|
@ -134,6 +145,8 @@ def update_backlog_item(
|
|||
description: Optional[str] = None,
|
||||
status: Optional[BacklogStatus] = None,
|
||||
priority: Optional[str] = None,
|
||||
roadmap_item_id: Optional[str] = None,
|
||||
clear_roadmap_item: bool = False,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id)
|
||||
if not existing:
|
||||
|
|
@ -162,6 +175,22 @@ def update_backlog_item(
|
|||
_validate_priority(priority)
|
||||
updates.append("priority = %s")
|
||||
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:
|
||||
return existing
|
||||
|
|
@ -177,8 +206,7 @@ def update_backlog_item(
|
|||
UPDATE backlog_items
|
||||
SET {", ".join(updates)}
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
RETURNING id, tenant_id, initiative_id, title, description, status,
|
||||
priority, converted_action_id, created_at, updated_at
|
||||
RETURNING {_BACKLOG_COLUMNS}
|
||||
""",
|
||||
params,
|
||||
)
|
||||
|
|
@ -259,6 +287,7 @@ def convert_backlog_to_action(
|
|||
title=existing["title"],
|
||||
description=existing["description"] or "",
|
||||
priority=existing["priority"],
|
||||
roadmap_item_id=existing.get("roadmap_item_id"),
|
||||
assigned_actor_ids=assigned_actor_ids or [],
|
||||
user_id=user_id,
|
||||
)
|
||||
|
|
@ -273,8 +302,7 @@ def convert_backlog_to_action(
|
|||
converted_action_id = %s,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
RETURNING id, tenant_id, initiative_id, title, description, status,
|
||||
priority, converted_action_id, created_at, updated_at
|
||||
RETURNING {_BACKLOG_COLUMNS}
|
||||
""",
|
||||
(action["id"], backlog_item_id, tenant_id),
|
||||
)
|
||||
|
|
|
|||
322
backend/services/plan_ist.py
Normal file
322
backend/services/plan_ist.py
Normal 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
|
||||
94
backend/tests/test_ap16_plan_ist.py
Normal file
94
backend/tests/test_ap16_plan_ist.py
Normal 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
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
APP_VERSION = "0.14.1-ap1.5c"
|
||||
APP_VERSION = "0.15.0-ap1.6"
|
||||
DB_SCHEMA_VERSION = "014"
|
||||
APP_NAME = "jinkendo-kairo"
|
||||
|
|
|
|||
|
|
@ -68,7 +68,8 @@ Phase E Steering AP1.2, AP1.3 ◐
|
|||
Phase F Gate-Backend AP1.4 + AP1.4b ✓ (Backend); UI minimal
|
||||
Phase G Ist-Hierarchie AP1.5 ✓
|
||||
Phase G2 Rekursive Container AP1.5c–5d ✓ 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.4d–4e nach AP1.6
|
||||
Phase J Portfolio & Kontext AP1.8 geplant
|
||||
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.4 | 0.13.0-ap1.4 | RoadmapItem / Gates Minimal |
|
||||
| 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 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.
|
||||
|
||||
|
|
@ -288,8 +290,8 @@ MVP-nah wenn:
|
|||
0.13.1-ap1.4b ✓ Gate-Checkliste Backend
|
||||
0.14.0-ap1.5 ✓ Hierarchie (Project, Task flach)
|
||||
0.14.1-ap1.5c ✓ Rekursive Projects
|
||||
0.15.0-ap1.6 → Journey / Plan-Ist — NÄCHSTES
|
||||
0.15.1-ap1.5d → Rekursive Tasks / Roll-up
|
||||
0.15.0-ap1.6 ✓ Plan/Ist / Journey
|
||||
0.15.1-ap1.5d → Rekursive Tasks — NÄCHSTES
|
||||
0.15.2-ap1.4d → Gate-Graph
|
||||
0.16.0-ap1.7 → Agent Interface
|
||||
0.17.0-ap1.8 → Portfolio + Kontext
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Kairo — Implementation Truth Table v0.1
|
||||
|
||||
**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**
|
||||
|
||||
Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
||||
|
|
@ -40,9 +40,9 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|
|||
|---------|-------|-----------|
|
||||
| Initiative | ✓ | |
|
||||
| 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 | ✓ | |
|
||||
| BacklogItem | ✓ | |
|
||||
| BacklogItem | ✓ | Gate-Zuordnung AP1.6 |
|
||||
| Blocker | ✓ | `action_id` optional |
|
||||
| Milestone | ◐ | Tabelle MVP-Brücke; kein Gate |
|
||||
| Evidence | ✓ | |
|
||||
|
|
|
|||
9
frontend/src/api/journey.js
Normal file
9
frontend/src/api/journey.js
Normal 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`)
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { ACTION_STATUSES, PRIORITIES } from '../constants/status.js'
|
||||
import { ACTION_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js'
|
||||
import { ActorSelect } from './ActorSelect.jsx'
|
||||
import { GateSelect } from './GateSelect.jsx'
|
||||
import { flattenLeafProjectOptions, getLeafProjects } from '../utils/projectTree.js'
|
||||
|
||||
function isoToLocalInput(iso) {
|
||||
|
|
@ -13,6 +14,7 @@ function isoToLocalInput(iso) {
|
|||
export function ActionForm({
|
||||
initial = {},
|
||||
projects = [],
|
||||
roadmapItems = [],
|
||||
actors = [],
|
||||
actorsLoading = false,
|
||||
actorsError = null,
|
||||
|
|
@ -34,6 +36,7 @@ export function ActionForm({
|
|||
priority: form.priority.value,
|
||||
due_at: form.due_at.value ? new Date(form.due_at.value).toISOString() : null,
|
||||
project_id: form.project_id?.value || undefined,
|
||||
roadmap_item_id: form.roadmap_item_id?.value || undefined,
|
||||
assigned_actor_ids: selected,
|
||||
})
|
||||
}
|
||||
|
|
@ -93,6 +96,10 @@ export function ActionForm({
|
|||
</select>
|
||||
</label>
|
||||
)}
|
||||
<GateSelect
|
||||
roadmapItems={roadmapItems}
|
||||
defaultValue={initial.roadmap_item_id || ''}
|
||||
/>
|
||||
<ActorSelect
|
||||
actors={actors}
|
||||
defaultSelected={defaultActors}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { ACTION_STATUSES, ACTION_STATUS_LABELS } from '../constants/status.js'
|
|||
import { StatusBadge } from './StatusBadge.jsx'
|
||||
import { PriorityBadge } from './PriorityBadge.jsx'
|
||||
import { ActionForm } from './ActionForm.jsx'
|
||||
import { gateTitleById } from './GateSelect.jsx'
|
||||
|
||||
function formatDue(iso) {
|
||||
if (!iso) return null
|
||||
|
|
@ -51,6 +52,8 @@ export function ActionHubCard({
|
|||
actorsUsedFallback,
|
||||
onReloadActors,
|
||||
initiativeId,
|
||||
projects = [],
|
||||
roadmapItems = [],
|
||||
detailMode = false,
|
||||
}) {
|
||||
const due = formatDue(action.due_at)
|
||||
|
|
@ -61,6 +64,8 @@ export function ActionHubCard({
|
|||
<article className="action-hub-card action-hub-card--editing">
|
||||
<ActionForm
|
||||
initial={action}
|
||||
projects={projects}
|
||||
roadmapItems={roadmapItems}
|
||||
actors={actors}
|
||||
actorsLoading={actorsLoading}
|
||||
actorsError={actorsError}
|
||||
|
|
@ -91,6 +96,9 @@ export function ActionHubCard({
|
|||
<p className="action-hub-card-desc">{action.description}</p>
|
||||
)}
|
||||
<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 && (
|
||||
<span>{action.assigned_actor_ids.length} zugewiesen</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -7,23 +7,32 @@ import { StatusBadge } from './StatusBadge.jsx'
|
|||
import { PriorityBadge } from './PriorityBadge.jsx'
|
||||
import { EmptyState } from './EmptyState.jsx'
|
||||
|
||||
import { GateSelect, gateTitleById } from './GateSelect.jsx'
|
||||
|
||||
export function BacklogSection({
|
||||
items,
|
||||
roadmapItems = [],
|
||||
canManage,
|
||||
onCreate,
|
||||
onUpdateGate,
|
||||
onUpdateStatus,
|
||||
onConvert,
|
||||
onDelete,
|
||||
busy,
|
||||
}) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [gateId, setGateId] = useState('')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!title.trim()) return
|
||||
await onCreate({ title: title.trim() })
|
||||
await onCreate({
|
||||
title: title.trim(),
|
||||
roadmap_item_id: gateId || undefined,
|
||||
})
|
||||
setTitle('')
|
||||
setGateId('')
|
||||
setShowForm(false)
|
||||
}
|
||||
|
||||
|
|
@ -53,6 +62,19 @@ export function BacklogSection({
|
|||
required
|
||||
/>
|
||||
</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}>
|
||||
Anlegen
|
||||
</button>
|
||||
|
|
@ -69,10 +91,30 @@ export function BacklogSection({
|
|||
{item.description && (
|
||||
<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 className="list-item-meta action-controls">
|
||||
<StatusBadge kind="backlog" status={item.status} />
|
||||
<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' && (
|
||||
<>
|
||||
<select
|
||||
|
|
|
|||
93
frontend/src/components/GateContributionsSection.jsx
Normal file
93
frontend/src/components/GateContributionsSection.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
frontend/src/components/GateSelect.jsx
Normal file
28
frontend/src/components/GateSelect.jsx
Normal 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
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ export function InitiativeActionsHub({
|
|||
initiativeId,
|
||||
actions,
|
||||
projects = [],
|
||||
roadmapItems = [],
|
||||
actionContextById,
|
||||
hideDone,
|
||||
onHideDoneChange,
|
||||
|
|
@ -62,6 +63,7 @@ export function InitiativeActionsHub({
|
|||
<div className="inline-form-block">
|
||||
<ActionForm
|
||||
projects={projects}
|
||||
roadmapItems={roadmapItems}
|
||||
actors={actors}
|
||||
actorsLoading={actorsLoading}
|
||||
actorsError={actorsError}
|
||||
|
|
@ -100,6 +102,8 @@ export function InitiativeActionsHub({
|
|||
actorsUsedFallback={actorsUsedFallback}
|
||||
onReloadActors={onReloadActors}
|
||||
initiativeId={initiativeId}
|
||||
projects={projects}
|
||||
roadmapItems={roadmapItems}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
71
frontend/src/components/JourneyTimeline.jsx
Normal file
71
frontend/src/components/JourneyTimeline.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,9 +6,11 @@ import {
|
|||
containerKindLabel,
|
||||
flattenProjectOptions,
|
||||
} from '../utils/projectTree.js'
|
||||
import { GateSelect, gateTitleById } from './GateSelect.jsx'
|
||||
|
||||
function ProjectTreeNodes({
|
||||
projects,
|
||||
roadmapItems,
|
||||
parentId,
|
||||
depth,
|
||||
selectedProjectId,
|
||||
|
|
@ -42,6 +44,11 @@ function ProjectTreeNodes({
|
|||
{containerKindLabel(project.container_kind)}
|
||||
</span>
|
||||
)}
|
||||
{project.roadmap_item_id && (
|
||||
<span className="project-kind-badge muted">
|
||||
Gate: {gateTitleById(roadmapItems, project.roadmap_item_id)}
|
||||
</span>
|
||||
)}
|
||||
{project.description && (
|
||||
<p className="list-item-desc muted">{project.description}</p>
|
||||
)}
|
||||
|
|
@ -62,6 +69,7 @@ function ProjectTreeNodes({
|
|||
</div>
|
||||
<ProjectTreeNodes
|
||||
projects={projects}
|
||||
roadmapItems={roadmapItems}
|
||||
parentId={project.id}
|
||||
depth={depth + 1}
|
||||
selectedProjectId={selectedProjectId}
|
||||
|
|
@ -78,6 +86,7 @@ function ProjectTreeNodes({
|
|||
|
||||
export function ProjectsSection({
|
||||
projects,
|
||||
roadmapItems = [],
|
||||
selectedProjectId,
|
||||
onSelectProject,
|
||||
canManage,
|
||||
|
|
@ -89,6 +98,7 @@ export function ProjectsSection({
|
|||
const [description, setDescription] = useState('')
|
||||
const [parentProjectId, setParentProjectId] = useState('')
|
||||
const [containerKind, setContainerKind] = useState('')
|
||||
const [gateId, setGateId] = useState('')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
const parentOptions = useMemo(
|
||||
|
|
@ -104,11 +114,13 @@ export function ProjectsSection({
|
|||
description: description.trim(),
|
||||
parent_project_id: parentProjectId || undefined,
|
||||
container_kind: containerKind || undefined,
|
||||
roadmap_item_id: gateId || undefined,
|
||||
})
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
setParentProjectId('')
|
||||
setContainerKind('')
|
||||
setGateId('')
|
||||
setShowForm(false)
|
||||
}
|
||||
|
||||
|
|
@ -195,6 +207,19 @@ export function ProjectsSection({
|
|||
<option value="release">Release</option>
|
||||
</select>
|
||||
</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}>
|
||||
Anlegen
|
||||
</button>
|
||||
|
|
@ -218,6 +243,7 @@ export function ProjectsSection({
|
|||
</ul>
|
||||
<ProjectTreeNodes
|
||||
projects={projects}
|
||||
roadmapItems={roadmapItems}
|
||||
parentId=""
|
||||
depth={1}
|
||||
selectedProjectId={selectedProjectId}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,15 @@ import {
|
|||
} from '../api/tasks.js'
|
||||
import { TASK_STATUSES, TASK_STATUS_LABELS } from '../constants/status.js'
|
||||
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 [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [title, setTitle] = useState('')
|
||||
const [gateId, setGateId] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!actionId) return
|
||||
|
|
@ -37,8 +39,12 @@ export function TasksSection({ actionId, canManage }) {
|
|||
if (!title.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await createActionTask(actionId, { title: title.trim() })
|
||||
await createActionTask(actionId, {
|
||||
title: title.trim(),
|
||||
roadmap_item_id: gateId || undefined,
|
||||
})
|
||||
setTitle('')
|
||||
setGateId('')
|
||||
await load()
|
||||
} catch (err) {
|
||||
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) {
|
||||
setBusy(true)
|
||||
try {
|
||||
|
|
@ -79,7 +100,7 @@ export function TasksSection({ actionId, canManage }) {
|
|||
<div>
|
||||
<h3>Aufgaben</h3>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -98,6 +119,19 @@ export function TasksSection({ actionId, canManage }) {
|
|||
placeholder="Konkreter nächster Schritt"
|
||||
/>
|
||||
</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}>
|
||||
Hinzufügen
|
||||
</button>
|
||||
|
|
@ -113,8 +147,29 @@ export function TasksSection({ actionId, canManage }) {
|
|||
<li key={task.id} className="list-item card-list-item">
|
||||
<div className="list-item-main">
|
||||
<strong>{task.title}</strong>
|
||||
{task.roadmap_item_id && (
|
||||
<p className="list-item-sub muted">
|
||||
Gate: {gateTitleById(roadmapItems, task.roadmap_item_id)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<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 ? (
|
||||
<select
|
||||
className="inline-select"
|
||||
|
|
|
|||
|
|
@ -233,6 +233,8 @@ export function InitiativeOperationsProvider({ children }) {
|
|||
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 || undefined,
|
||||
clear_roadmap_item: payload.roadmap_item_id === '' || payload.roadmap_item_id === null,
|
||||
})
|
||||
if (capabilities.has('kairo.action.manage')) {
|
||||
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) {
|
||||
try {
|
||||
await updateBacklogItem(itemId, { status })
|
||||
|
|
@ -609,6 +623,7 @@ export function InitiativeOperationsProvider({ children }) {
|
|||
handleBlockerStatus,
|
||||
handleDeleteBlocker,
|
||||
handleCreateBacklog,
|
||||
handleBacklogGate,
|
||||
handleBacklogStatus,
|
||||
handleConvertBacklog,
|
||||
handleDeleteBacklog,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Link, useParams } from 'react-router-dom'
|
|||
import { getAction } from '../api/actions.js'
|
||||
import { getInitiativeSteeringSnapshot } from '../api/initiatives.js'
|
||||
import { listInitiativeProjects } from '../api/projects.js'
|
||||
import { listInitiativeRoadmapItems } from '../api/roadmap.js'
|
||||
import { updateAction, setActionAssignments } from '../api/actions.js'
|
||||
import { createInitiativeBlocker } from '../api/blockers.js'
|
||||
import { ActionHubCard } from '../components/ActionHubCard.jsx'
|
||||
|
|
@ -27,12 +28,14 @@ function ActionDetailBody({
|
|||
capabilities,
|
||||
actorsState,
|
||||
projects = [],
|
||||
roadmapItems = [],
|
||||
}) {
|
||||
if (editing) {
|
||||
return (
|
||||
<ActionForm
|
||||
initial={action}
|
||||
projects={projects}
|
||||
roadmapItems={roadmapItems}
|
||||
actors={actorsState.actors}
|
||||
actorsLoading={actorsState.loading}
|
||||
actorsError={actorsState.error}
|
||||
|
|
@ -70,6 +73,7 @@ function ActionDetailBody({
|
|||
<TasksSection
|
||||
actionId={action.id}
|
||||
canManage={capabilities.has('kairo.action.manage')}
|
||||
roadmapItems={roadmapItems}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
@ -81,6 +85,7 @@ function ActionDetailStandalone() {
|
|||
const actorsState = useActors()
|
||||
const [action, setAction] = useState(null)
|
||||
const [projects, setProjects] = useState([])
|
||||
const [roadmapItems, setRoadmapItems] = useState([])
|
||||
const [context, setContext] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
|
@ -93,11 +98,13 @@ function ActionDetailStandalone() {
|
|||
try {
|
||||
const actionData = await getAction(actionId)
|
||||
setAction(actionData)
|
||||
const [snap, projectData] = await Promise.all([
|
||||
const [snap, projectData, roadmapData] = await Promise.all([
|
||||
getInitiativeSteeringSnapshot(actionData.initiative_id),
|
||||
listInitiativeProjects(actionData.initiative_id).catch(() => []),
|
||||
listInitiativeRoadmapItems(actionData.initiative_id).catch(() => []),
|
||||
])
|
||||
setProjects(projectData)
|
||||
setRoadmapItems(roadmapData)
|
||||
setContext((snap.actions || []).find((a) => a.id === actionId) || null)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
|
|
@ -120,6 +127,10 @@ function ActionDetailStandalone() {
|
|||
priority: payload.priority,
|
||||
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')) {
|
||||
await setActionAssignments(action.id, payload.assigned_actor_ids || [])
|
||||
|
|
@ -204,6 +215,7 @@ function ActionDetailStandalone() {
|
|||
capabilities={capabilities}
|
||||
actorsState={actorsState}
|
||||
projects={projects}
|
||||
roadmapItems={roadmapItems}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
|
@ -245,6 +257,7 @@ function ActionDetailNested() {
|
|||
reload: ops.reloadActors,
|
||||
}}
|
||||
projects={ops.projects}
|
||||
roadmapItems={ops.roadmapItems}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export function InitiativeExecutionPage() {
|
|||
const {
|
||||
visibleActions,
|
||||
projects,
|
||||
roadmapItems,
|
||||
selectedProjectId,
|
||||
setSelectedProjectId,
|
||||
actionContextById,
|
||||
|
|
@ -22,6 +23,7 @@ export function InitiativeExecutionPage() {
|
|||
unlinkedBlockers,
|
||||
initiativeId,
|
||||
error,
|
||||
roadmapItems,
|
||||
} = ops
|
||||
|
||||
return (
|
||||
|
|
@ -30,6 +32,7 @@ export function InitiativeExecutionPage() {
|
|||
|
||||
<ProjectsSection
|
||||
projects={projects}
|
||||
roadmapItems={roadmapItems}
|
||||
selectedProjectId={selectedProjectId}
|
||||
onSelectProject={setSelectedProjectId}
|
||||
canManage={capabilities.has('kairo.project.manage')}
|
||||
|
|
@ -42,6 +45,7 @@ export function InitiativeExecutionPage() {
|
|||
initiativeId={initiativeId}
|
||||
actions={visibleActions}
|
||||
projects={projects}
|
||||
roadmapItems={roadmapItems}
|
||||
actionContextById={actionContextById}
|
||||
hideDone={hideDone}
|
||||
onHideDoneChange={setHideDone}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import { BacklogSection } from '../../components/BacklogSection.jsx'
|
|||
export function InitiativeInboxPage() {
|
||||
const {
|
||||
backlogItems,
|
||||
roadmapItems,
|
||||
capabilities,
|
||||
formBusy,
|
||||
error,
|
||||
handleCreateBacklog,
|
||||
handleBacklogGate,
|
||||
handleBacklogStatus,
|
||||
handleConvertBacklog,
|
||||
handleDeleteBacklog,
|
||||
|
|
@ -22,8 +24,10 @@ export function InitiativeInboxPage() {
|
|||
{error && <p className="error">{error}</p>}
|
||||
<BacklogSection
|
||||
items={backlogItems}
|
||||
roadmapItems={roadmapItems}
|
||||
canManage={capabilities.has('kairo.backlog.manage')}
|
||||
onCreate={handleCreateBacklog}
|
||||
onUpdateGate={handleBacklogGate}
|
||||
onUpdateStatus={handleBacklogStatus}
|
||||
onConvert={handleConvertBacklog}
|
||||
onDelete={handleDeleteBacklog}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,36 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||
import { listInitiativeJourney } from '../../api/journey.js'
|
||||
import { DecisionsSection } from '../../components/DecisionsSection.jsx'
|
||||
import { ReviewsSection } from '../../components/ReviewsSection.jsx'
|
||||
import { RecurringSection } from '../../components/RecurringSection.jsx'
|
||||
import { EvidenceSection } from '../../components/EvidenceSection.jsx'
|
||||
import { JourneyTimeline } from '../../components/JourneyTimeline.jsx'
|
||||
|
||||
export function InitiativeJourneyPage() {
|
||||
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')) {
|
||||
return null
|
||||
|
|
@ -15,9 +39,25 @@ export function InitiativeJourneyPage() {
|
|||
return (
|
||||
<>
|
||||
{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">
|
||||
<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">
|
||||
<DecisionsSection
|
||||
items={ops.decisions}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ import {
|
|||
ROADMAP_ITEM_TYPE_LABELS,
|
||||
} from '../../constants/status.js'
|
||||
import { useCapabilities } from '../../hooks/useCapabilities.js'
|
||||
import { listGateContributions } from '../../api/journey.js'
|
||||
import { GateContributionsSection } from '../../components/GateContributionsSection.jsx'
|
||||
|
||||
function CriterionDecisionForm({ label, onSubmit, busy }) {
|
||||
const [title, setTitle] = useState('')
|
||||
|
|
@ -85,6 +87,21 @@ export function RoadmapItemDetailPage() {
|
|||
const [newKind, setNewKind] = useState('manual')
|
||||
const [expandedDecision, setExpandedDecision] = useState(null)
|
||||
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 () => {
|
||||
setLoading(true)
|
||||
|
|
@ -108,6 +125,10 @@ export function RoadmapItemDetailPage() {
|
|||
load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
loadContributions()
|
||||
}, [loadContributions])
|
||||
|
||||
async function runAction(fn) {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
|
|
@ -155,6 +176,13 @@ export function RoadmapItemDetailPage() {
|
|||
Checkliste: {closedCount}/{totalCount} Kriterien abgeschlossen
|
||||
</p>
|
||||
|
||||
<GateContributionsSection
|
||||
contributions={contributions}
|
||||
initiativeId={initiativeId}
|
||||
loading={contributionsLoading}
|
||||
error={contributionsError}
|
||||
/>
|
||||
|
||||
<ul className="item-list criterion-list">
|
||||
{criteria.map((crit) => (
|
||||
<li key={crit.id} className="list-item card-list-item">
|
||||
|
|
|
|||
|
|
@ -458,6 +458,49 @@
|
|||
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 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user