AP2.2e: Unplan, Bug/Issue-Referenz und Sprint-Lebenszyklus.
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 2m52s
Test Suite / lint-backend (push) Successful in 2s
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

Ermöglicht APs zurück in den Eingang zu schieben, Bugs/Issues mit Feature-Bezug zu planen und den Scrum-Sprint-Ablauf in der UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-13 08:07:02 +02:00
parent e221b5b09f
commit e65838118c
21 changed files with 794 additions and 14 deletions

View File

@ -0,0 +1,26 @@
-- AP2.2e: Sprint-Planung — Item-Arten, Feature-Referenz, Unplan-Support
ALTER TABLE actions DROP CONSTRAINT IF EXISTS actions_action_kind_check;
ALTER TABLE actions
ADD CONSTRAINT actions_action_kind_check
CHECK (action_kind IN ('delivery', 'planning', 'review', 'bug', 'issue'));
ALTER TABLE actions
ADD COLUMN IF NOT EXISTS parent_action_id UUID NULL
REFERENCES actions (id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_actions_parent
ON actions (tenant_id, initiative_id, parent_action_id)
WHERE parent_action_id IS NOT NULL;
ALTER TABLE backlog_items
ADD COLUMN IF NOT EXISTS item_kind VARCHAR(32) NOT NULL DEFAULT 'story'
CHECK (item_kind IN ('story', 'bug', 'issue'));
ALTER TABLE backlog_items
ADD COLUMN IF NOT EXISTS parent_action_id UUID NULL
REFERENCES actions (id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_backlog_parent_action
ON backlog_items (tenant_id, initiative_id, parent_action_id)
WHERE parent_action_id IS NOT NULL;

View File

@ -30,9 +30,11 @@ class ActionUpdateRequest(BaseModel):
roadmap_item_id: Optional[str] = None roadmap_item_id: Optional[str] = None
clear_roadmap_item: bool = False clear_roadmap_item: bool = False
sort_order: Optional[int] = None sort_order: Optional[int] = None
action_kind: Optional[Literal["delivery", "planning", "review"]] = None action_kind: Optional[Literal["delivery", "planning", "review", "bug", "issue"]] = None
work_cycle_id: Optional[str] = None work_cycle_id: Optional[str] = None
clear_work_cycle: bool = False clear_work_cycle: bool = False
parent_action_id: Optional[str] = None
clear_parent_action: bool = False
class TaskCreateRequest(BaseModel): class TaskCreateRequest(BaseModel):
@ -143,6 +145,8 @@ def update_action(
clear_work_cycle=body.clear_work_cycle, clear_work_cycle=body.clear_work_cycle,
sort_order=body.sort_order, sort_order=body.sort_order,
action_kind=body.action_kind, action_kind=body.action_kind,
parent_action_id=body.parent_action_id,
clear_parent_action=body.clear_parent_action,
) )
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
@ -171,6 +175,24 @@ def set_action_assignments(
return item return item
@router.post("/{action_id}/unplan-to-backlog", status_code=200)
def unplan_action_to_backlog(
action_id: str,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
):
try:
return action_service.unplan_action_to_backlog(
tenant_id=ctx.tenant_id,
action_id=action_id,
user_id=ctx.user_id,
)
except ValueError as exc:
detail = str(exc)
if detail == "Arbeitspaket nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
@router.delete("/{action_id}", status_code=204) @router.delete("/{action_id}", status_code=204)
def delete_action( def delete_action(
action_id: str, action_id: str,

View File

@ -20,6 +20,8 @@ class BacklogCreateRequest(BaseModel):
priority: Literal["low", "normal", "high"] = "normal" priority: Literal["low", "normal", "high"] = "normal"
roadmap_item_id: Optional[str] = None roadmap_item_id: Optional[str] = None
sort_order: Optional[int] = None sort_order: Optional[int] = None
item_kind: Literal["story", "bug", "issue"] = "story"
parent_action_id: Optional[str] = None
class BacklogUpdateRequest(BaseModel): class BacklogUpdateRequest(BaseModel):
@ -30,6 +32,9 @@ class BacklogUpdateRequest(BaseModel):
roadmap_item_id: Optional[str] = None roadmap_item_id: Optional[str] = None
clear_roadmap_item: bool = False clear_roadmap_item: bool = False
sort_order: Optional[int] = None sort_order: Optional[int] = None
item_kind: Optional[Literal["story", "bug", "issue"]] = None
parent_action_id: Optional[str] = None
clear_parent_action: bool = False
class BacklogConvertRequest(BaseModel): class BacklogConvertRequest(BaseModel):
@ -69,6 +74,9 @@ def update_backlog_item(
roadmap_item_id=body.roadmap_item_id, roadmap_item_id=body.roadmap_item_id,
clear_roadmap_item=body.clear_roadmap_item, clear_roadmap_item=body.clear_roadmap_item,
sort_order=body.sort_order, sort_order=body.sort_order,
item_kind=body.item_kind,
parent_action_id=body.parent_action_id,
clear_parent_action=body.clear_parent_action,
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc

View File

@ -70,7 +70,8 @@ class ActionCreateRequest(BaseModel):
work_cycle_id: Optional[str] = None work_cycle_id: Optional[str] = None
assigned_actor_ids: list[str] = Field(default_factory=list) assigned_actor_ids: list[str] = Field(default_factory=list)
sort_order: int = 0 sort_order: int = 0
action_kind: Literal["delivery", "planning", "review"] = "delivery" action_kind: Literal["delivery", "planning", "review", "bug", "issue"] = "delivery"
parent_action_id: Optional[str] = None
class BlockerCreateRequest(BaseModel): class BlockerCreateRequest(BaseModel):
@ -87,6 +88,8 @@ class BacklogCreateRequest(BaseModel):
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 roadmap_item_id: Optional[str] = None
item_kind: Literal["story", "bug", "issue"] = "story"
parent_action_id: Optional[str] = None
class MilestoneCreateRequest(BaseModel): class MilestoneCreateRequest(BaseModel):
@ -339,6 +342,7 @@ def create_initiative_action(
assigned_actor_ids=body.assigned_actor_ids, assigned_actor_ids=body.assigned_actor_ids,
sort_order=body.sort_order, sort_order=body.sort_order,
action_kind=body.action_kind, action_kind=body.action_kind,
parent_action_id=body.parent_action_id,
user_id=ctx.user_id, user_id=ctx.user_id,
) )
except ValueError as exc: except ValueError as exc:
@ -418,6 +422,8 @@ def create_initiative_backlog_item(
status=body.status, status=body.status,
priority=body.priority, priority=body.priority,
roadmap_item_id=body.roadmap_item_id, roadmap_item_id=body.roadmap_item_id,
item_kind=body.item_kind,
parent_action_id=body.parent_action_id,
user_id=ctx.user_id, user_id=ctx.user_id,
) )
except ValueError as exc: except ValueError as exc:

View File

@ -26,15 +26,25 @@ OPEN_ACTION_STATUSES = frozenset(
_ACTION_COLUMNS = """ _ACTION_COLUMNS = """
id, tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id, title, description, id, tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id, title, description,
status, priority, due_at, sort_order, action_kind, created_at, updated_at status, priority, due_at, sort_order, action_kind, parent_action_id, created_at, updated_at
""" """
ACTION_KINDS = frozenset({"delivery", "planning", "review"}) ACTION_KINDS = frozenset({"delivery", "planning", "review", "bug", "issue"})
UNPLAN_ALLOWED_STATUSES = frozenset({"open", "ready"})
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", "roadmap_item_id", "work_cycle_id", "owner_actor_id"): for key in (
"id",
"tenant_id",
"initiative_id",
"project_id",
"roadmap_item_id",
"work_cycle_id",
"parent_action_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"):
@ -46,6 +56,42 @@ def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
return result return result
def _validate_parent_action_in_initiative(
*,
cur,
tenant_id: str,
initiative_id: str,
parent_action_id: Optional[str],
action_id: Optional[str] = None,
) -> None:
if not parent_action_id:
return
if action_id and parent_action_id == action_id:
raise ValueError("Arbeitspaket kann nicht auf sich selbst verweisen")
cur.execute(
"""
SELECT action_kind
FROM actions
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
""",
(parent_action_id, tenant_id, initiative_id),
)
row = cur.fetchone()
if not row:
raise ValueError("Referenz-Arbeitspaket (Feature) nicht gefunden")
action_kind = row["action_kind"] if isinstance(row, dict) else row[0]
if action_kind in ("bug", "issue"):
raise ValueError("Referenz muss ein Feature-Arbeitspaket sein, kein Bug/Issue")
def _action_kind_to_item_kind(action_kind: str) -> str:
if action_kind == "bug":
return "bug"
if action_kind == "issue":
return "issue"
return "story"
def _validate_action_kind(action_kind: str) -> None: def _validate_action_kind(action_kind: str) -> None:
if action_kind not in ACTION_KINDS: if action_kind not in ACTION_KINDS:
raise ValueError(f"Ungültiger action_kind: {action_kind}") raise ValueError(f"Ungültiger action_kind: {action_kind}")
@ -149,6 +195,7 @@ def create_action(
assigned_actor_ids: Optional[list[str]] = None, assigned_actor_ids: Optional[list[str]] = None,
sort_order: int = 0, sort_order: int = 0,
action_kind: str = "delivery", action_kind: str = "delivery",
parent_action_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()
@ -183,13 +230,20 @@ def create_action(
work_cycle_id=work_cycle_id, work_cycle_id=work_cycle_id,
cur=cur, cur=cur,
) )
_validate_parent_action_in_initiative(
cur=cur,
tenant_id=tenant_id,
initiative_id=initiative_id,
parent_action_id=parent_action_id,
)
cur.execute( cur.execute(
f""" f"""
INSERT INTO actions ( INSERT INTO actions (
tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id, tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id,
title, description, status, priority, due_at, sort_order, action_kind title, description, status, priority, due_at, sort_order, action_kind,
parent_action_id
) )
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING {_ACTION_COLUMNS} RETURNING {_ACTION_COLUMNS}
""", """,
( (
@ -205,6 +259,7 @@ def create_action(
due_at, due_at,
sort_order, sort_order,
action_kind, action_kind,
parent_action_id,
), ),
) )
row = _serialize_row(dict(cur.fetchone())) row = _serialize_row(dict(cur.fetchone()))
@ -299,6 +354,8 @@ def update_action(
clear_work_cycle: bool = False, clear_work_cycle: bool = False,
sort_order: Optional[int] = None, sort_order: Optional[int] = None,
action_kind: Optional[str] = None, action_kind: Optional[str] = None,
parent_action_id: Optional[str] = None,
clear_parent_action: 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:
@ -373,6 +430,11 @@ def update_action(
_validate_action_kind(action_kind) _validate_action_kind(action_kind)
updates.append("action_kind = %s") updates.append("action_kind = %s")
params.append(action_kind) params.append(action_kind)
if clear_parent_action:
updates.append("parent_action_id = NULL")
elif parent_action_id is not None:
updates.append("parent_action_id = %s")
params.append(parent_action_id)
if not updates: if not updates:
return existing return existing
@ -383,6 +445,14 @@ def update_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:
if parent_action_id is not None and not clear_parent_action:
_validate_parent_action_in_initiative(
cur=cur,
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
parent_action_id=parent_action_id,
action_id=action_id,
)
cur.execute( cur.execute(
f""" f"""
UPDATE actions UPDATE actions
@ -533,3 +603,132 @@ def list_open_actions_for_actor(
finally: finally:
conn.close() conn.close()
return _attach_assignments(actions, tenant_id=tenant_id) return _attach_assignments(actions, tenant_id=tenant_id)
def unplan_action_to_backlog(
*,
tenant_id: str,
action_id: str,
user_id: Optional[str] = None,
) -> dict[str, Any]:
"""Entfernt ein Arbeitspaket aus dem Sprint und stellt es im Eingang wieder her."""
existing = get_action(tenant_id=tenant_id, action_id=action_id)
if not existing:
raise ValueError("Arbeitspaket nicht gefunden")
if existing["status"] not in UNPLAN_ALLOWED_STATUSES:
raise ValueError(
"Nur offene oder bereite Arbeitspakete können zurück in den Eingang"
)
initiative_id = existing["initiative_id"]
item_kind = _action_kind_to_item_kind(existing.get("action_kind") or "delivery")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id
FROM backlog_items
WHERE tenant_id = %s AND converted_action_id = %s
""",
(tenant_id, action_id),
)
linked = cur.fetchone()
if linked:
backlog_id = str(linked["id"])
cur.execute(
"""
UPDATE backlog_items
SET status = 'accepted',
converted_action_id = NULL,
title = %s,
description = %s,
priority = %s,
roadmap_item_id = %s,
item_kind = %s,
parent_action_id = %s,
updated_at = NOW()
WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, initiative_id, title, description, status,
priority, roadmap_item_id, converted_action_id, sort_order,
item_kind, parent_action_id, created_at, updated_at
""",
(
existing["title"],
existing.get("description") or "",
existing["priority"],
existing.get("roadmap_item_id"),
item_kind,
existing.get("parent_action_id"),
backlog_id,
tenant_id,
),
)
backlog_row = _serialize_row(dict(cur.fetchone()))
else:
cur.execute(
"""
SELECT COALESCE(MAX(sort_order), -10) + 10 AS next_order
FROM backlog_items
WHERE tenant_id = %s AND initiative_id = %s
""",
(tenant_id, initiative_id),
)
next_order = int(cur.fetchone()["next_order"])
cur.execute(
"""
INSERT INTO backlog_items (
tenant_id, initiative_id, title, description, status, priority,
roadmap_item_id, sort_order, item_kind, parent_action_id
)
VALUES (%s, %s, %s, %s, 'accepted', %s, %s, %s, %s, %s)
RETURNING id, tenant_id, initiative_id, title, description, status,
priority, roadmap_item_id, converted_action_id, sort_order,
item_kind, parent_action_id, created_at, updated_at
""",
(
tenant_id,
initiative_id,
existing["title"],
existing.get("description") or "",
existing["priority"],
existing.get("roadmap_item_id"),
next_order,
item_kind,
existing.get("parent_action_id"),
),
)
backlog_row = _serialize_row(dict(cur.fetchone()))
cur.execute(
"""
UPDATE actions
SET status = 'discarded',
work_cycle_id = NULL,
updated_at = NOW()
WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, initiative_id, project_id, roadmap_item_id,
work_cycle_id, title, description, status, priority, due_at,
sort_order, action_kind, parent_action_id, created_at, updated_at
""",
(action_id, tenant_id),
)
action_row = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
action_result = get_action(tenant_id=tenant_id, action_id=action_id)
log_audit(
"action.unplanned_to_backlog",
user_id=user_id,
tenant_id=tenant_id,
details={
"action_id": action_id,
"backlog_item_id": backlog_row["id"],
"initiative_id": initiative_id,
},
)
return {"backlog_item": backlog_row, "action": action_result or action_row}

View File

@ -13,18 +13,28 @@ from services.initiatives import PRIORITIES, get_initiative
from services.plan_ist import validate_roadmap_item_in_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"]
BacklogItemKind = Literal["story", "bug", "issue"]
BACKLOG_STATUSES = frozenset({"new", "triaged", "accepted", "rejected", "converted"}) BACKLOG_STATUSES = frozenset({"new", "triaged", "accepted", "rejected", "converted"})
BACKLOG_ITEM_KINDS = frozenset({"story", "bug", "issue"})
_BACKLOG_COLUMNS = """ _BACKLOG_COLUMNS = """
id, tenant_id, initiative_id, title, description, status, id, tenant_id, initiative_id, title, description, status,
priority, roadmap_item_id, converted_action_id, sort_order, created_at, updated_at priority, roadmap_item_id, converted_action_id, sort_order, item_kind,
parent_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", "roadmap_item_id"): for key in (
"id",
"tenant_id",
"initiative_id",
"converted_action_id",
"roadmap_item_id",
"parent_action_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"):
@ -44,6 +54,44 @@ def _validate_priority(priority: str) -> None:
raise ValueError(f"Ungültige Priorität: {priority}") raise ValueError(f"Ungültige Priorität: {priority}")
def _validate_item_kind(item_kind: str) -> None:
if item_kind not in BACKLOG_ITEM_KINDS:
raise ValueError(f"Ungültiger Backlog-Typ: {item_kind}")
def _item_kind_to_action_kind(item_kind: str) -> str:
if item_kind == "bug":
return "bug"
if item_kind == "issue":
return "issue"
return "delivery"
def _validate_parent_action_in_initiative(
*,
cur,
tenant_id: str,
initiative_id: str,
parent_action_id: Optional[str],
) -> None:
if not parent_action_id:
return
cur.execute(
"""
SELECT action_kind, parent_action_id
FROM actions
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
""",
(parent_action_id, tenant_id, initiative_id),
)
row = cur.fetchone()
if not row:
raise ValueError("Referenz-Arbeitspaket (Feature) nicht gefunden")
action_kind = row["action_kind"] if isinstance(row, dict) else row[0]
if action_kind in ("bug", "issue"):
raise ValueError("Referenz muss ein Feature-Arbeitspaket sein, kein Bug/Issue")
def create_backlog_item( def create_backlog_item(
*, *,
tenant_id: str, tenant_id: str,
@ -54,6 +102,8 @@ def create_backlog_item(
priority: str = "normal", priority: str = "normal",
roadmap_item_id: Optional[str] = None, roadmap_item_id: Optional[str] = None,
sort_order: Optional[int] = None, sort_order: Optional[int] = None,
item_kind: BacklogItemKind = "story",
parent_action_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()
@ -61,6 +111,7 @@ def create_backlog_item(
raise ValueError("Titel ist erforderlich") raise ValueError("Titel ist erforderlich")
_validate_status(status) _validate_status(status)
_validate_priority(priority) _validate_priority(priority)
_validate_item_kind(item_kind)
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden") raise ValueError("Initiative nicht gefunden")
@ -73,6 +124,12 @@ def create_backlog_item(
initiative_id=initiative_id, initiative_id=initiative_id,
roadmap_item_id=roadmap_item_id, roadmap_item_id=roadmap_item_id,
) )
_validate_parent_action_in_initiative(
cur=cur,
tenant_id=tenant_id,
initiative_id=initiative_id,
parent_action_id=parent_action_id,
)
cur.execute( cur.execute(
""" """
SELECT COALESCE(MAX(sort_order), -10) + 10 AS next_order SELECT COALESCE(MAX(sort_order), -10) + 10 AS next_order
@ -86,9 +143,9 @@ def create_backlog_item(
f""" 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, sort_order roadmap_item_id, sort_order, item_kind, parent_action_id
) )
VALUES (%s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING {_BACKLOG_COLUMNS} RETURNING {_BACKLOG_COLUMNS}
""", """,
( (
@ -100,6 +157,8 @@ def create_backlog_item(
priority, priority,
roadmap_item_id, roadmap_item_id,
sort_order if sort_order is not None else next_order, sort_order if sort_order is not None else next_order,
item_kind,
parent_action_id,
), ),
) )
row = _serialize_row(dict(cur.fetchone())) row = _serialize_row(dict(cur.fetchone()))
@ -167,6 +226,9 @@ def update_backlog_item(
roadmap_item_id: Optional[str] = None, roadmap_item_id: Optional[str] = None,
clear_roadmap_item: bool = False, clear_roadmap_item: bool = False,
sort_order: Optional[int] = None, sort_order: Optional[int] = None,
item_kind: Optional[BacklogItemKind] = None,
parent_action_id: Optional[str] = None,
clear_parent_action: 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:
@ -214,6 +276,15 @@ def update_backlog_item(
if sort_order is not None: if sort_order is not None:
updates.append("sort_order = %s") updates.append("sort_order = %s")
params.append(sort_order) params.append(sort_order)
if item_kind is not None:
_validate_item_kind(item_kind)
updates.append("item_kind = %s")
params.append(item_kind)
if clear_parent_action:
updates.append("parent_action_id = NULL")
elif parent_action_id is not None:
updates.append("parent_action_id = %s")
params.append(parent_action_id)
if not updates: if not updates:
return existing return existing
@ -224,6 +295,13 @@ def update_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:
if parent_action_id is not None and not clear_parent_action:
_validate_parent_action_in_initiative(
cur=cur,
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
parent_action_id=parent_action_id,
)
cur.execute( cur.execute(
f""" f"""
UPDATE backlog_items UPDATE backlog_items
@ -316,6 +394,7 @@ def convert_backlog_to_action(
if active: if active:
resolved_cycle_id = active["id"] resolved_cycle_id = active["id"]
item_kind = existing.get("item_kind") or "story"
action = create_action( action = create_action(
tenant_id=tenant_id, tenant_id=tenant_id,
initiative_id=existing["initiative_id"], initiative_id=existing["initiative_id"],
@ -325,6 +404,8 @@ def convert_backlog_to_action(
roadmap_item_id=existing.get("roadmap_item_id"), roadmap_item_id=existing.get("roadmap_item_id"),
work_cycle_id=resolved_cycle_id, work_cycle_id=resolved_cycle_id,
assigned_actor_ids=assigned_actor_ids or [], assigned_actor_ids=assigned_actor_ids or [],
action_kind=_item_kind_to_action_kind(item_kind),
parent_action_id=existing.get("parent_action_id"),
user_id=user_id, user_id=user_id,
) )

View File

@ -213,6 +213,12 @@ def complete_work_cycle(
conn.close() conn.close()
result = _serialize_cycle({**dict(row), "initiative_id": initiative_id}) result = _serialize_cycle({**dict(row), "initiative_id": initiative_id})
open_count = count_actions_in_work_cycle(
tenant_id=tenant_id,
initiative_id=initiative_id,
work_cycle_id=work_cycle_id,
)
result["open_action_count_at_completion"] = open_count
log_audit( log_audit(
"work_cycle.completed", "work_cycle.completed",
user_id=user_id, user_id=user_id,

View File

@ -0,0 +1,172 @@
"""AP2.2e — Unplan, Bug/Issue mit Feature-Referenz, Sprint-Lifecycle."""
from __future__ import annotations
from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import _auth, _create_initiative, _login
def test_unplan_action_restores_backlog(client):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
created = _create_initiative(
client,
token,
title="Sprint Planung E2E",
archetype_key="initiative.product",
)
initiative_id = created.json()["id"]
cycle = client.post(
f"/api/initiatives/{initiative_id}/work-cycles",
json={"title": "Sprint 1", "status": "planned"},
headers=_auth(token),
)
assert cycle.status_code == 201
cycle_id = cycle.json()["id"]
backlog = client.post(
f"/api/initiatives/{initiative_id}/backlog",
json={"title": "Story A", "status": "accepted"},
headers=_auth(token),
)
assert backlog.status_code == 201
backlog_id = backlog.json()["id"]
converted = client.post(
f"/api/backlog/{backlog_id}/convert-to-action",
json={"work_cycle_id": cycle_id, "assign_active_sprint": False},
headers=_auth(token),
)
assert converted.status_code == 201
action_id = converted.json()["action"]["id"]
assert converted.json()["backlog_item"]["status"] == "converted"
unplan = client.post(
f"/api/actions/{action_id}/unplan-to-backlog",
headers=_auth(token),
)
assert unplan.status_code == 200
body = unplan.json()
assert body["backlog_item"]["status"] == "accepted"
assert body["backlog_item"]["id"] == backlog_id
assert body["backlog_item"]["converted_action_id"] is None
assert body["action"]["status"] == "discarded"
assert body["action"]["work_cycle_id"] is None
def test_unplan_rejects_in_progress(client):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
created = _create_initiative(
client,
token,
title="Unplan Guard",
archetype_key="initiative.product",
)
initiative_id = created.json()["id"]
action = client.post(
f"/api/initiatives/{initiative_id}/actions",
json={"title": "Laufendes AP", "status": "in_progress"},
headers=_auth(token),
)
assert action.status_code == 201
action_id = action.json()["id"]
unplan = client.post(
f"/api/actions/{action_id}/unplan-to-backlog",
headers=_auth(token),
)
assert unplan.status_code == 400
def test_backlog_bug_with_feature_reference(client):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
created = _create_initiative(
client,
token,
title="Bug Ref",
archetype_key="initiative.product",
)
initiative_id = created.json()["id"]
cycle = client.post(
f"/api/initiatives/{initiative_id}/work-cycles",
json={"title": "Sprint 1", "status": "planned"},
headers=_auth(token),
)
assert cycle.status_code == 201
cycle_id = cycle.json()["id"]
feature = client.post(
f"/api/initiatives/{initiative_id}/actions",
json={"title": "Login Feature", "action_kind": "delivery"},
headers=_auth(token),
)
assert feature.status_code == 201
feature_id = feature.json()["id"]
bug = client.post(
f"/api/initiatives/{initiative_id}/backlog",
json={
"title": "Login-Button kaputt",
"status": "accepted",
"item_kind": "bug",
"parent_action_id": feature_id,
},
headers=_auth(token),
)
assert bug.status_code == 201
assert bug.json()["item_kind"] == "bug"
assert bug.json()["parent_action_id"] == feature_id
converted = client.post(
f"/api/backlog/{bug.json()['id']}/convert-to-action",
json={"work_cycle_id": cycle_id, "assign_active_sprint": False},
headers=_auth(token),
)
assert converted.status_code == 201
action = converted.json()["action"]
assert action["action_kind"] == "bug"
assert action["parent_action_id"] == feature_id
assert action["work_cycle_id"] == cycle_id
def test_complete_sprint_reports_open_actions(client):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
created = _create_initiative(
client,
token,
title="Complete Stats",
archetype_key="initiative.product",
)
initiative_id = created.json()["id"]
cycle = client.post(
f"/api/initiatives/{initiative_id}/work-cycles",
json={"title": "Sprint Active", "status": "active"},
headers=_auth(token),
)
assert cycle.status_code == 201
cycle_id = cycle.json()["id"]
client.post(
f"/api/initiatives/{initiative_id}/actions",
json={"title": "Offen", "work_cycle_id": cycle_id, "status": "open"},
headers=_auth(token),
)
completed = client.post(
f"/api/initiatives/{initiative_id}/work-cycles/{cycle_id}/complete",
headers=_auth(token),
)
assert completed.status_code == 200
assert completed.json()["status"] == "reached"
assert completed.json()["open_action_count_at_completion"] == 1

View File

@ -22,6 +22,10 @@ export function deleteAction(id) {
return apiFetch(`/api/actions/${id}`, { method: 'DELETE' }) return apiFetch(`/api/actions/${id}`, { method: 'DELETE' })
} }
export function unplanActionToBacklog(id) {
return apiFetch(`/api/actions/${id}/unplan-to-backlog`, { method: 'POST' })
}
export function listMyOpenActions() { export function listMyOpenActions() {
return apiFetch('/api/actions/me/open') return apiFetch('/api/actions/me/open')
} }

View File

@ -1,9 +1,10 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { ACTION_STATUSES, ACTION_STATUS_LABELS } from '../constants/status.js' import { ACTION_STATUSES, ACTION_STATUS_LABELS, ACTION_KIND_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' import { gateTitleById } from './GateSelect.jsx'
import { actionTitleById } from '../utils/actionReferences.js'
function formatDue(iso) { function formatDue(iso) {
if (!iso) return null if (!iso) return null
@ -43,6 +44,7 @@ export function ActionHubCard({
onSubmitEdit, onSubmitEdit,
onQuickStatus, onQuickStatus,
onCreateBlocker, onCreateBlocker,
onUnplanToBacklog,
canManage, canManage,
canManageBlocker, canManageBlocker,
formBusy, formBusy,
@ -56,10 +58,15 @@ export function ActionHubCard({
roadmapItems = [], roadmapItems = [],
workCycles = [], workCycles = [],
activeWorkCycle = null, activeWorkCycle = null,
allActions = [],
detailMode = false, detailMode = false,
}) { }) {
const due = formatDue(action.due_at) const due = formatDue(action.due_at)
const isBlocked = action.status === 'blocked' || context?.has_open_blocker const isBlocked = action.status === 'blocked' || context?.has_open_blocker
const canUnplan =
typeof onUnplanToBacklog === 'function' &&
action.work_cycle_id &&
['open', 'ready'].includes(action.status)
if (editing) { if (editing) {
return ( return (
@ -94,6 +101,11 @@ export function ActionHubCard({
<div className="action-hub-card-badges"> <div className="action-hub-card-badges">
<StatusBadge status={action.status} /> <StatusBadge status={action.status} />
<PriorityBadge priority={action.priority} /> <PriorityBadge priority={action.priority} />
{action.action_kind && action.action_kind !== 'delivery' && (
<span className="badge badge--kind muted">
{ACTION_KIND_LABELS[action.action_kind] || action.action_kind}
</span>
)}
</div> </div>
</div> </div>
{action.description && ( {action.description && (
@ -103,6 +115,11 @@ export function ActionHubCard({
{action.roadmap_item_id && ( {action.roadmap_item_id && (
<span>Gate: {gateTitleById(roadmapItems, action.roadmap_item_id) || '…'}</span> <span>Gate: {gateTitleById(roadmapItems, action.roadmap_item_id) || '…'}</span>
)} )}
{action.parent_action_id && (
<span>
Feature: {actionTitleById(allActions, action.parent_action_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>
)} )}
@ -174,6 +191,16 @@ export function ActionHubCard({
Blocker melden Blocker melden
</button> </button>
)} )}
{canUnplan && (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => onUnplanToBacklog(action)}
disabled={formBusy}
>
Zurück in Eingang
</button>
)}
<button <button
type="button" type="button"
className="btn btn-ghost btn-sm" className="btn btn-ghost btn-sm"

View File

@ -1,14 +1,18 @@
import { import {
BACKLOG_STATUSES, BACKLOG_STATUSES,
BACKLOG_STATUS_LABELS, BACKLOG_STATUS_LABELS,
BACKLOG_ITEM_KINDS,
BACKLOG_ITEM_KIND_LABELS,
PRIORITIES, PRIORITIES,
PRIORITY_LABELS, PRIORITY_LABELS,
} from '../constants/status.js' } from '../constants/status.js'
import { GateSelect } from './GateSelect.jsx' import { GateSelect } from './GateSelect.jsx'
import { FeatureParentSelect } from './FeatureParentSelect.jsx'
export function BacklogItemForm({ export function BacklogItemForm({
initial = {}, initial = {},
roadmapItems = [], roadmapItems = [],
featureActions = [],
onSubmit, onSubmit,
onCancel, onCancel,
busy = false, busy = false,
@ -19,17 +23,23 @@ export function BacklogItemForm({
e.preventDefault() e.preventDefault()
const form = e.target const form = e.target
const gateValue = form.roadmap_item_id?.value ?? '' const gateValue = form.roadmap_item_id?.value ?? ''
const parentValue = form.parent_action_id?.value ?? ''
await onSubmit({ await onSubmit({
title: form.title.value.trim(), title: form.title.value.trim(),
description: form.description.value, description: form.description.value,
status: form.status?.value, status: form.status?.value,
priority: form.priority.value, priority: form.priority.value,
item_kind: form.item_kind?.value || 'story',
roadmap_item_id: gateValue || undefined, roadmap_item_id: gateValue || undefined,
clear_roadmap_item: gateValue === '', clear_roadmap_item: gateValue === '',
parent_action_id: parentValue || undefined,
clear_parent_action: parentValue === '',
}) })
} }
const statusLocked = initial.status === 'converted' const statusLocked = initial.status === 'converted'
const showFeatureRef =
(initial.item_kind || 'story') !== 'story' || featureActions.length > 0
return ( return (
<form className="form workspace-form backlog-item-form" onSubmit={handleSubmit}> <form className="form workspace-form backlog-item-form" onSubmit={handleSubmit}>
@ -53,6 +63,20 @@ export function BacklogItemForm({
/> />
</label> </label>
<div className="form-row form-row--2"> <div className="form-row form-row--2">
<label>
Typ
<select
name="item_kind"
defaultValue={initial.item_kind || 'story'}
disabled={statusLocked}
>
{BACKLOG_ITEM_KINDS.map((kind) => (
<option key={kind} value={kind}>
{BACKLOG_ITEM_KIND_LABELS[kind]}
</option>
))}
</select>
</label>
{allowStatus && ( {allowStatus && (
<label> <label>
Status Status
@ -69,6 +93,8 @@ export function BacklogItemForm({
</select> </select>
</label> </label>
)} )}
</div>
<div className="form-row form-row--2">
<label> <label>
Priorität Priorität
<select name="priority" defaultValue={initial.priority || 'normal'} disabled={statusLocked}> <select name="priority" defaultValue={initial.priority || 'normal'} disabled={statusLocked}>
@ -87,6 +113,13 @@ export function BacklogItemForm({
disabled={statusLocked} disabled={statusLocked}
/> />
)} )}
{showFeatureRef && (
<FeatureParentSelect
featureActions={featureActions}
defaultValue={initial.parent_action_id || ''}
disabled={statusLocked}
/>
)}
<div className="form-actions"> <div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={busy || statusLocked}> <button type="submit" className="btn btn-primary" disabled={busy || statusLocked}>
{busy ? 'Speichern …' : submitLabel} {busy ? 'Speichern …' : submitLabel}

View File

@ -8,6 +8,8 @@ import { ReorderControls } from './ReorderControls.jsx'
import { gateTitleById } from './GateSelect.jsx' import { gateTitleById } from './GateSelect.jsx'
import { computeDropPatches, computeMovePatches, sortByOrder } from '../utils/reorder.js' import { computeDropPatches, computeMovePatches, sortByOrder } from '../utils/reorder.js'
import { useMinWidth } from '../hooks/useMinWidth.js' import { useMinWidth } from '../hooks/useMinWidth.js'
import { BACKLOG_ITEM_KIND_LABELS } from '../constants/status.js'
import { actionTitleById } from '../utils/actionReferences.js'
const PLANNING_STATUSES = new Set(['planned', 'active', 'at_risk']) const PLANNING_STATUSES = new Set(['planned', 'active', 'at_risk'])
/** Backend erlaubt Convert für diese Status — UI war nur bei „Freigegeben“ sichtbar. */ /** Backend erlaubt Convert für diese Status — UI war nur bei „Freigegeben“ sichtbar. */
@ -19,6 +21,7 @@ export function BacklogSection({
roadmapItems = [], roadmapItems = [],
workCycles = [], workCycles = [],
activeWorkCycle = null, activeWorkCycle = null,
featureActions = [],
initiativeId = '', initiativeId = '',
sprintPlanningEnabled = false, sprintPlanningEnabled = false,
canManage, canManage,
@ -89,6 +92,8 @@ export function BacklogSection({
description: payload.description, description: payload.description,
priority: payload.priority, priority: payload.priority,
roadmap_item_id: payload.roadmap_item_id, roadmap_item_id: payload.roadmap_item_id,
item_kind: payload.item_kind,
parent_action_id: payload.parent_action_id,
status: payload.status || 'new', status: payload.status || 'new',
}) })
closeModal() closeModal()
@ -103,6 +108,9 @@ export function BacklogSection({
priority: payload.priority, priority: payload.priority,
roadmap_item_id: payload.roadmap_item_id, roadmap_item_id: payload.roadmap_item_id,
clear_roadmap_item: payload.clear_roadmap_item, clear_roadmap_item: payload.clear_roadmap_item,
item_kind: payload.item_kind,
parent_action_id: payload.parent_action_id,
clear_parent_action: payload.clear_parent_action,
}) })
if (ok !== false) closeModal() if (ok !== false) closeModal()
} }
@ -302,9 +310,19 @@ export function BacklogSection({
disabled={!canManage} disabled={!canManage}
> >
<strong>{item.title}</strong> <strong>{item.title}</strong>
{item.item_kind && item.item_kind !== 'story' && (
<span className="badge badge--kind muted">
{BACKLOG_ITEM_KIND_LABELS[item.item_kind] || item.item_kind}
</span>
)}
{item.description && ( {item.description && (
<p className="list-item-desc">{item.description}</p> <p className="list-item-desc">{item.description}</p>
)} )}
{item.parent_action_id && (
<p className="list-item-sub muted">
Feature: {actionTitleById(featureActions, item.parent_action_id) || '…'}
</p>
)}
{item.roadmap_item_id && ( {item.roadmap_item_id && (
<p className="list-item-sub muted"> <p className="list-item-sub muted">
Gate:{' '} Gate:{' '}
@ -374,6 +392,7 @@ export function BacklogSection({
{modalMode?.kind === 'create' && ( {modalMode?.kind === 'create' && (
<BacklogItemForm <BacklogItemForm
roadmapItems={roadmapItems} roadmapItems={roadmapItems}
featureActions={featureActions}
onSubmit={handleCreateSubmit} onSubmit={handleCreateSubmit}
onCancel={closeModal} onCancel={closeModal}
busy={busy} busy={busy}
@ -384,6 +403,7 @@ export function BacklogSection({
<BacklogItemForm <BacklogItemForm
initial={modalMode.item} initial={modalMode.item}
roadmapItems={roadmapItems} roadmapItems={roadmapItems}
featureActions={featureActions}
onSubmit={handleEditSubmit} onSubmit={handleEditSubmit}
onCancel={closeModal} onCancel={closeModal}
busy={busy} busy={busy}

View File

@ -0,0 +1,25 @@
export function FeatureParentSelect({
featureActions = [],
defaultValue = '',
disabled = false,
label = 'Bezug zu Feature (Arbeitspaket)',
}) {
return (
<label>
{label}
<select name="parent_action_id" defaultValue={defaultValue} disabled={disabled}>
<option value=""> kein Bezug </option>
{featureActions.map((action) => (
<option key={action.id} value={action.id}>
{action.title}
</option>
))}
</select>
{featureActions.length === 0 && (
<span className="muted form-hint">
Lege zuerst ein Feature-Arbeitspaket an (Story/Lieferung), um Bugs/Issues zu verknüpfen.
</span>
)}
</label>
)
}

View File

@ -21,6 +21,7 @@ export function InitiativeActionsHub({
onUpdateAction, onUpdateAction,
onQuickStatus, onQuickStatus,
onCreateBlockerForAction, onCreateBlockerForAction,
onUnplanToBacklog,
canManage, canManage,
canManageBlocker, canManageBlocker,
formBusy, formBusy,
@ -96,6 +97,7 @@ export function InitiativeActionsHub({
onSubmitEdit={(payload) => onUpdateAction(action.id, payload)} onSubmitEdit={(payload) => onUpdateAction(action.id, payload)}
onQuickStatus={(status) => onQuickStatus(action, status)} onQuickStatus={(status) => onQuickStatus(action, status)}
onCreateBlocker={() => onCreateBlockerForAction(action.id)} onCreateBlocker={() => onCreateBlockerForAction(action.id)}
onUnplanToBacklog={onUnplanToBacklog}
canManage={canManage} canManage={canManage}
canManageBlocker={canManageBlocker} canManageBlocker={canManageBlocker}
formBusy={formBusy} formBusy={formBusy}
@ -109,6 +111,7 @@ export function InitiativeActionsHub({
roadmapItems={roadmapItems} roadmapItems={roadmapItems}
workCycles={workCycles} workCycles={workCycles}
activeWorkCycle={activeWorkCycle} activeWorkCycle={activeWorkCycle}
allActions={actions}
/> />
))} ))}
</div> </div>

View File

@ -2,6 +2,18 @@ import { useState } from 'react'
import { StatusBadge } from './StatusBadge.jsx' import { StatusBadge } from './StatusBadge.jsx'
import { EmptyState } from './EmptyState.jsx' import { EmptyState } from './EmptyState.jsx'
const SPRINT_LIFECYCLE = [
{ key: 'planned', label: 'Planung' },
{ key: 'active', label: 'Ausführung' },
{ key: 'reached', label: 'Abgeschlossen' },
]
function lifecycleStepIndex(status) {
if (status === 'reached') return 2
if (status === 'active' || status === 'at_risk') return 1
return 0
}
export function WorkCyclesPanel({ export function WorkCyclesPanel({
workCycles = [], workCycles = [],
activeWorkCycle = null, activeWorkCycle = null,
@ -31,6 +43,18 @@ export function WorkCyclesPanel({
setGoal('') setGoal('')
} }
async function handleComplete(cycle) {
const openCount = actionCountByCycleId[cycle.id] || 0
if (openCount > 0) {
const ok = window.confirm(
`Sprint „${cycle.title}" hat noch ${openCount} offene Arbeitspaket${openCount === 1 ? '' : 'e'}. ` +
'Trotzdem abschließen? Offene APs bleiben dem Sprint zugeordnet und können in den Eingang zurückgeschoben werden.',
)
if (!ok) return
}
await onComplete(cycle.id)
}
return ( return (
<section className="card work-cycles-panel"> <section className="card work-cycles-panel">
<div className="section-header"> <div className="section-header">
@ -39,6 +63,7 @@ export function WorkCyclesPanel({
<p className="section-lead muted"> <p className="section-lead muted">
Sprint anklicken, um den geplanten Sprint-Backlog zu sehen aktiv oder geplant. Sprint anklicken, um den geplanten Sprint-Backlog zu sehen aktiv oder geplant.
Committete Arbeitspakete aus dem Eingang erscheinen beim gewählten Sprint. Committete Arbeitspakete aus dem Eingang erscheinen beim gewählten Sprint.
Lebenszyklus: Planung Ausführung Abschluss (Scrum).
</p> </p>
</div> </div>
</div> </div>
@ -95,6 +120,7 @@ export function WorkCyclesPanel({
{workCycles.map((cycle) => { {workCycles.map((cycle) => {
const isSelected = selectedSprintId === cycle.id const isSelected = selectedSprintId === cycle.id
const apCount = actionCountByCycleId[cycle.id] || 0 const apCount = actionCountByCycleId[cycle.id] || 0
const step = lifecycleStepIndex(cycle.status)
return ( return (
<li <li
key={cycle.id} key={cycle.id}
@ -113,6 +139,25 @@ export function WorkCyclesPanel({
<span className="muted work-cycle-row__count"> <span className="muted work-cycle-row__count">
{apCount} AP{apCount === 1 ? '' : 's'} {apCount} AP{apCount === 1 ? '' : 's'}
</span> </span>
<div className="work-cycle-lifecycle muted" aria-label="Sprint-Lebenszyklus">
{SPRINT_LIFECYCLE.map((phase, index) => (
<span
key={phase.key}
className={
'work-cycle-lifecycle__step' +
(index === step ? ' work-cycle-lifecycle__step--current' : '') +
(index < step ? ' work-cycle-lifecycle__step--done' : '')
}
>
{phase.label}
{index < SPRINT_LIFECYCLE.length - 1 && (
<span className="work-cycle-lifecycle__sep" aria-hidden="true">
</span>
)}
</span>
))}
</div>
{cycle.goal_description && ( {cycle.goal_description && (
<p className="muted item-meta">{cycle.goal_description}</p> <p className="muted item-meta">{cycle.goal_description}</p>
)} )}
@ -123,7 +168,7 @@ export function WorkCyclesPanel({
type="button" type="button"
className="btn btn-secondary btn-sm" className="btn btn-secondary btn-sm"
disabled={busy} disabled={busy}
onClick={() => onComplete(cycle.id)} onClick={() => handleComplete(cycle)}
> >
Abschließen Abschließen
</button> </button>

View File

@ -61,6 +61,22 @@ export const BACKLOG_STATUS_LABELS = {
converted: 'Umgewandelt', converted: 'Umgewandelt',
} }
export const BACKLOG_ITEM_KINDS = ['story', 'bug', 'issue']
export const BACKLOG_ITEM_KIND_LABELS = {
story: 'Story',
bug: 'Bug',
issue: 'Issue',
}
export const ACTION_KINDS = ['delivery', 'planning', 'review', 'bug', 'issue']
export const ACTION_KIND_LABELS = {
delivery: 'Lieferung',
planning: 'Planung',
review: 'Review',
bug: 'Bugfix',
issue: 'Issue',
}
export const MILESTONE_STATUS_LABELS = { export const MILESTONE_STATUS_LABELS = {
planned: 'Geplant', planned: 'Geplant',
active: 'Aktiv', active: 'Aktiv',

View File

@ -14,7 +14,7 @@ import {
createInitiativeAction, createInitiativeAction,
updateInitiative, updateInitiative,
} from '../api/initiatives.js' } from '../api/initiatives.js'
import { updateAction, setActionAssignments } from '../api/actions.js' import { updateAction, setActionAssignments, unplanActionToBacklog } from '../api/actions.js'
import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../api/entityFields.js' import { saveInitiativeDynamicFields, splitInitiativeFormPayload } from '../api/entityFields.js'
import { import {
listInitiativeBlockers, listInitiativeBlockers,
@ -84,6 +84,7 @@ import { useSession } from './SessionContext.jsx'
import { useOptionalProgramScope } from './ProgramScopeContext.jsx' import { useOptionalProgramScope } from './ProgramScopeContext.jsx'
import { collectProjectSubtreeIds } from '../utils/projectTree.js' import { collectProjectSubtreeIds } from '../utils/projectTree.js'
import { filterActionsForWorkCycle } from '../utils/workCycleActions.js' import { filterActionsForWorkCycle } from '../utils/workCycleActions.js'
import { listFeatureParentActions } from '../utils/actionReferences.js'
const InitiativeOperationsContext = createContext(null) const InitiativeOperationsContext = createContext(null)
@ -262,6 +263,29 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
} }
} }
function applyUnplanResult(result) {
if (!result) return
const backlogItem = result.backlog_item
const action = result.action
if (backlogItem?.id) {
setBacklogItems((prev) => {
const exists = prev.some((item) => item.id === backlogItem.id)
if (exists) {
return prev.map((item) => (item.id === backlogItem.id ? backlogItem : item))
}
return [...prev, backlogItem]
})
}
if (action?.id) {
setActions((prev) => prev.map((a) => (a.id === action.id ? action : a)))
}
}
const featureParentActions = useMemo(
() => listFeatureParentActions(actions),
[actions],
)
const actionContextById = useMemo( const actionContextById = useMemo(
() => Object.fromEntries((steeringSnapshot?.actions || []).map((a) => [a.id, a])), () => Object.fromEntries((steeringSnapshot?.actions || []).map((a) => [a.id, a])),
[steeringSnapshot] [steeringSnapshot]
@ -511,6 +535,20 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
} }
} }
async function handleUnplanAction(action) {
if (!action?.id) return
setFormBusy(true)
setError(null)
try {
const result = await unplanActionToBacklog(action.id)
applyUnplanResult(result)
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleCreateWorkCycle(body) { async function handleCreateWorkCycle(body) {
setFormBusy(true) setFormBusy(true)
try { try {
@ -861,6 +899,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
initiativeId: id, initiativeId: id,
initiative, initiative,
actions, actions,
featureParentActions,
visibleActions, visibleActions,
blockers, blockers,
unlinkedBlockers, unlinkedBlockers,
@ -919,6 +958,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
handleBacklogStatus, handleBacklogStatus,
handleConvertBacklog, handleConvertBacklog,
handleBulkConvertBacklog, handleBulkConvertBacklog,
handleUnplanAction,
handleCreateWorkCycle, handleCreateWorkCycle,
handleActivateWorkCycle, handleActivateWorkCycle,
handleCompleteWorkCycle, handleCompleteWorkCycle,

View File

@ -8,6 +8,7 @@ export function InitiativeInboxPage() {
initiativeId, initiativeId,
backlogItems, backlogItems,
roadmapItems, roadmapItems,
featureParentActions,
workCycles, workCycles,
activeWorkCycle, activeWorkCycle,
capabilities, capabilities,
@ -39,6 +40,7 @@ export function InitiativeInboxPage() {
items={backlogItems} items={backlogItems}
initiativeId={initiativeId} initiativeId={initiativeId}
roadmapItems={roadmapItems} roadmapItems={roadmapItems}
featureActions={featureParentActions}
workCycles={workCycles} workCycles={workCycles}
activeWorkCycle={activeWorkCycle} activeWorkCycle={activeWorkCycle}
sprintPlanningEnabled={isProductArchetype} sprintPlanningEnabled={isProductArchetype}

View File

@ -43,6 +43,7 @@ function PlanSprintInner() {
handleUpdateAction, handleUpdateAction,
handleQuickStatus, handleQuickStatus,
handleCreateBlockerForAction, handleCreateBlockerForAction,
handleUnplanAction,
} = ops } = ops
const actionCountByCycleId = useMemo( const actionCountByCycleId = useMemo(
@ -103,6 +104,7 @@ function PlanSprintInner() {
onUpdateAction={handleUpdateAction} onUpdateAction={handleUpdateAction}
onQuickStatus={handleQuickStatus} onQuickStatus={handleQuickStatus}
onCreateBlockerForAction={handleCreateBlockerForAction} onCreateBlockerForAction={handleCreateBlockerForAction}
onUnplanToBacklog={handleUnplanAction}
canManage={capabilities.has('kairo.action.manage')} canManage={capabilities.has('kairo.action.manage')}
canManageBlocker={capabilities.has('kairo.blocker.manage')} canManageBlocker={capabilities.has('kairo.blocker.manage')}
formBusy={formBusy} formBusy={formBusy}

View File

@ -0,0 +1,23 @@
/**
* Feature-Arbeitspakete, auf die Bugs/Issues referenzieren können.
* @param {Array<{ id: string, title: string, action_kind?: string, parent_action_id?: string, status?: string }>} actions
*/
export function listFeatureParentActions(actions) {
return (actions || []).filter(
(action) =>
action.status !== 'discarded' &&
action.status !== 'done' &&
!['bug', 'issue'].includes(action.action_kind) &&
!action.parent_action_id,
)
}
/**
* @param {Array<{ id: string, title: string }>} actions
* @param {string | null | undefined} actionId
*/
export function actionTitleById(actions, actionId) {
if (!actionId) return ''
const match = (actions || []).find((action) => action.id === actionId)
return match?.title || ''
}

View File

@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { listFeatureParentActions, actionTitleById } from './actionReferences.js'
describe('actionReferences', () => {
const actions = [
{ id: 'f1', title: 'Login', action_kind: 'delivery', status: 'open' },
{ id: 'b1', title: 'Bug', action_kind: 'bug', status: 'open', parent_action_id: 'f1' },
{ id: 'd1', title: 'Done', action_kind: 'delivery', status: 'done' },
]
it('listFeatureParentActions excludes bugs and done', () => {
const features = listFeatureParentActions(actions)
expect(features.map((a) => a.id)).toEqual(['f1'])
})
it('actionTitleById resolves title', () => {
expect(actionTitleById(actions, 'f1')).toBe('Login')
expect(actionTitleById(actions, 'missing')).toBe('')
})
})