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,
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
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 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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
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"
|
DB_SCHEMA_VERSION = "014"
|
||||||
APP_NAME = "jinkendo-kairo"
|
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 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.5c–5d ✓ 5c / → 5d nach AP1.6
|
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 I Gate-Graph AP1.4d–4e 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
|
||||||
|
|
|
||||||
|
|
@ -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 | ✓ | |
|
||||||
|
|
|
||||||
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_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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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,
|
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>
|
||||||
|
|
|
||||||
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,
|
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}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user