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
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:
parent
e221b5b09f
commit
e65838118c
26
backend/migrations/026_sprint_planning_kinds.sql
Normal file
26
backend/migrations/026_sprint_planning_kinds.sql
Normal 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;
|
||||
|
|
@ -30,9 +30,11 @@ class ActionUpdateRequest(BaseModel):
|
|||
roadmap_item_id: Optional[str] = None
|
||||
clear_roadmap_item: bool = False
|
||||
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
|
||||
clear_work_cycle: bool = False
|
||||
parent_action_id: Optional[str] = None
|
||||
clear_parent_action: bool = False
|
||||
|
||||
|
||||
class TaskCreateRequest(BaseModel):
|
||||
|
|
@ -143,6 +145,8 @@ def update_action(
|
|||
clear_work_cycle=body.clear_work_cycle,
|
||||
sort_order=body.sort_order,
|
||||
action_kind=body.action_kind,
|
||||
parent_action_id=body.parent_action_id,
|
||||
clear_parent_action=body.clear_parent_action,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
|
@ -171,6 +175,24 @@ def set_action_assignments(
|
|||
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)
|
||||
def delete_action(
|
||||
action_id: str,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ class BacklogCreateRequest(BaseModel):
|
|||
priority: Literal["low", "normal", "high"] = "normal"
|
||||
roadmap_item_id: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
item_kind: Literal["story", "bug", "issue"] = "story"
|
||||
parent_action_id: Optional[str] = None
|
||||
|
||||
|
||||
class BacklogUpdateRequest(BaseModel):
|
||||
|
|
@ -30,6 +32,9 @@ class BacklogUpdateRequest(BaseModel):
|
|||
roadmap_item_id: Optional[str] = None
|
||||
clear_roadmap_item: bool = False
|
||||
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):
|
||||
|
|
@ -69,6 +74,9 @@ def update_backlog_item(
|
|||
roadmap_item_id=body.roadmap_item_id,
|
||||
clear_roadmap_item=body.clear_roadmap_item,
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
|
|
|||
|
|
@ -70,7 +70,8 @@ class ActionCreateRequest(BaseModel):
|
|||
work_cycle_id: Optional[str] = None
|
||||
assigned_actor_ids: list[str] = Field(default_factory=list)
|
||||
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):
|
||||
|
|
@ -87,6 +88,8 @@ class BacklogCreateRequest(BaseModel):
|
|||
status: Literal["new", "triaged", "accepted", "rejected"] = "new"
|
||||
priority: Literal["low", "normal", "high"] = "normal"
|
||||
roadmap_item_id: Optional[str] = None
|
||||
item_kind: Literal["story", "bug", "issue"] = "story"
|
||||
parent_action_id: Optional[str] = None
|
||||
|
||||
|
||||
class MilestoneCreateRequest(BaseModel):
|
||||
|
|
@ -339,6 +342,7 @@ def create_initiative_action(
|
|||
assigned_actor_ids=body.assigned_actor_ids,
|
||||
sort_order=body.sort_order,
|
||||
action_kind=body.action_kind,
|
||||
parent_action_id=body.parent_action_id,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
|
|
@ -418,6 +422,8 @@ def create_initiative_backlog_item(
|
|||
status=body.status,
|
||||
priority=body.priority,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
item_kind=body.item_kind,
|
||||
parent_action_id=body.parent_action_id,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
|
|
|
|||
|
|
@ -26,15 +26,25 @@ OPEN_ACTION_STATUSES = frozenset(
|
|||
|
||||
_ACTION_COLUMNS = """
|
||||
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]:
|
||||
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):
|
||||
result[key] = str(result[key])
|
||||
if result.get("created_at"):
|
||||
|
|
@ -46,6 +56,42 @@ def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
|||
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:
|
||||
if action_kind not in ACTION_KINDS:
|
||||
raise ValueError(f"Ungültiger action_kind: {action_kind}")
|
||||
|
|
@ -149,6 +195,7 @@ def create_action(
|
|||
assigned_actor_ids: Optional[list[str]] = None,
|
||||
sort_order: int = 0,
|
||||
action_kind: str = "delivery",
|
||||
parent_action_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
title = title.strip()
|
||||
|
|
@ -183,13 +230,20 @@ def create_action(
|
|||
work_cycle_id=work_cycle_id,
|
||||
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(
|
||||
f"""
|
||||
INSERT INTO actions (
|
||||
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}
|
||||
""",
|
||||
(
|
||||
|
|
@ -205,6 +259,7 @@ def create_action(
|
|||
due_at,
|
||||
sort_order,
|
||||
action_kind,
|
||||
parent_action_id,
|
||||
),
|
||||
)
|
||||
row = _serialize_row(dict(cur.fetchone()))
|
||||
|
|
@ -299,6 +354,8 @@ def update_action(
|
|||
clear_work_cycle: bool = False,
|
||||
sort_order: Optional[int] = None,
|
||||
action_kind: Optional[str] = None,
|
||||
parent_action_id: Optional[str] = None,
|
||||
clear_parent_action: bool = False,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
existing = get_action(tenant_id=tenant_id, action_id=action_id)
|
||||
if not existing:
|
||||
|
|
@ -373,6 +430,11 @@ def update_action(
|
|||
_validate_action_kind(action_kind)
|
||||
updates.append("action_kind = %s")
|
||||
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:
|
||||
return existing
|
||||
|
|
@ -383,6 +445,14 @@ def update_action(
|
|||
conn = get_connection()
|
||||
try:
|
||||
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(
|
||||
f"""
|
||||
UPDATE actions
|
||||
|
|
@ -533,3 +603,132 @@ def list_open_actions_for_actor(
|
|||
finally:
|
||||
conn.close()
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -13,18 +13,28 @@ from services.initiatives import PRIORITIES, get_initiative
|
|||
from services.plan_ist import validate_roadmap_item_in_initiative
|
||||
|
||||
BacklogStatus = Literal["new", "triaged", "accepted", "rejected", "converted"]
|
||||
BacklogItemKind = Literal["story", "bug", "issue"]
|
||||
|
||||
BACKLOG_STATUSES = frozenset({"new", "triaged", "accepted", "rejected", "converted"})
|
||||
BACKLOG_ITEM_KINDS = frozenset({"story", "bug", "issue"})
|
||||
|
||||
_BACKLOG_COLUMNS = """
|
||||
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]:
|
||||
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):
|
||||
result[key] = str(result[key])
|
||||
if result.get("created_at"):
|
||||
|
|
@ -44,6 +54,44 @@ def _validate_priority(priority: str) -> None:
|
|||
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(
|
||||
*,
|
||||
tenant_id: str,
|
||||
|
|
@ -54,6 +102,8 @@ def create_backlog_item(
|
|||
priority: str = "normal",
|
||||
roadmap_item_id: Optional[str] = None,
|
||||
sort_order: Optional[int] = None,
|
||||
item_kind: BacklogItemKind = "story",
|
||||
parent_action_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
title = title.strip()
|
||||
|
|
@ -61,6 +111,7 @@ def create_backlog_item(
|
|||
raise ValueError("Titel ist erforderlich")
|
||||
_validate_status(status)
|
||||
_validate_priority(priority)
|
||||
_validate_item_kind(item_kind)
|
||||
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
|
||||
raise ValueError("Initiative nicht gefunden")
|
||||
|
||||
|
|
@ -73,6 +124,12 @@ def create_backlog_item(
|
|||
initiative_id=initiative_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(
|
||||
"""
|
||||
SELECT COALESCE(MAX(sort_order), -10) + 10 AS next_order
|
||||
|
|
@ -86,9 +143,9 @@ def create_backlog_item(
|
|||
f"""
|
||||
INSERT INTO backlog_items (
|
||||
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}
|
||||
""",
|
||||
(
|
||||
|
|
@ -100,6 +157,8 @@ def create_backlog_item(
|
|||
priority,
|
||||
roadmap_item_id,
|
||||
sort_order if sort_order is not None else next_order,
|
||||
item_kind,
|
||||
parent_action_id,
|
||||
),
|
||||
)
|
||||
row = _serialize_row(dict(cur.fetchone()))
|
||||
|
|
@ -167,6 +226,9 @@ def update_backlog_item(
|
|||
roadmap_item_id: Optional[str] = None,
|
||||
clear_roadmap_item: bool = False,
|
||||
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]]:
|
||||
existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id)
|
||||
if not existing:
|
||||
|
|
@ -214,6 +276,15 @@ def update_backlog_item(
|
|||
if sort_order is not None:
|
||||
updates.append("sort_order = %s")
|
||||
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:
|
||||
return existing
|
||||
|
|
@ -224,6 +295,13 @@ def update_backlog_item(
|
|||
conn = get_connection()
|
||||
try:
|
||||
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(
|
||||
f"""
|
||||
UPDATE backlog_items
|
||||
|
|
@ -316,6 +394,7 @@ def convert_backlog_to_action(
|
|||
if active:
|
||||
resolved_cycle_id = active["id"]
|
||||
|
||||
item_kind = existing.get("item_kind") or "story"
|
||||
action = create_action(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=existing["initiative_id"],
|
||||
|
|
@ -325,6 +404,8 @@ def convert_backlog_to_action(
|
|||
roadmap_item_id=existing.get("roadmap_item_id"),
|
||||
work_cycle_id=resolved_cycle_id,
|
||||
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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -213,6 +213,12 @@ def complete_work_cycle(
|
|||
conn.close()
|
||||
|
||||
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(
|
||||
"work_cycle.completed",
|
||||
user_id=user_id,
|
||||
|
|
|
|||
172
backend/tests/test_ap22e_sprint_planning_lifecycle.py
Normal file
172
backend/tests/test_ap22e_sprint_planning_lifecycle.py
Normal 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
|
||||
|
|
@ -22,6 +22,10 @@ export function deleteAction(id) {
|
|||
return apiFetch(`/api/actions/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function unplanActionToBacklog(id) {
|
||||
return apiFetch(`/api/actions/${id}/unplan-to-backlog`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function listMyOpenActions() {
|
||||
return apiFetch('/api/actions/me/open')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
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 { PriorityBadge } from './PriorityBadge.jsx'
|
||||
import { ActionForm } from './ActionForm.jsx'
|
||||
import { gateTitleById } from './GateSelect.jsx'
|
||||
import { actionTitleById } from '../utils/actionReferences.js'
|
||||
|
||||
function formatDue(iso) {
|
||||
if (!iso) return null
|
||||
|
|
@ -43,6 +44,7 @@ export function ActionHubCard({
|
|||
onSubmitEdit,
|
||||
onQuickStatus,
|
||||
onCreateBlocker,
|
||||
onUnplanToBacklog,
|
||||
canManage,
|
||||
canManageBlocker,
|
||||
formBusy,
|
||||
|
|
@ -56,10 +58,15 @@ export function ActionHubCard({
|
|||
roadmapItems = [],
|
||||
workCycles = [],
|
||||
activeWorkCycle = null,
|
||||
allActions = [],
|
||||
detailMode = false,
|
||||
}) {
|
||||
const due = formatDue(action.due_at)
|
||||
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) {
|
||||
return (
|
||||
|
|
@ -94,6 +101,11 @@ export function ActionHubCard({
|
|||
<div className="action-hub-card-badges">
|
||||
<StatusBadge status={action.status} />
|
||||
<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>
|
||||
{action.description && (
|
||||
|
|
@ -103,6 +115,11 @@ export function ActionHubCard({
|
|||
{action.roadmap_item_id && (
|
||||
<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 && (
|
||||
<span>{action.assigned_actor_ids.length} zugewiesen</span>
|
||||
)}
|
||||
|
|
@ -174,6 +191,16 @@ export function ActionHubCard({
|
|||
Blocker melden
|
||||
</button>
|
||||
)}
|
||||
{canUnplan && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => onUnplanToBacklog(action)}
|
||||
disabled={formBusy}
|
||||
>
|
||||
Zurück in Eingang
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
import {
|
||||
BACKLOG_STATUSES,
|
||||
BACKLOG_STATUS_LABELS,
|
||||
BACKLOG_ITEM_KINDS,
|
||||
BACKLOG_ITEM_KIND_LABELS,
|
||||
PRIORITIES,
|
||||
PRIORITY_LABELS,
|
||||
} from '../constants/status.js'
|
||||
import { GateSelect } from './GateSelect.jsx'
|
||||
import { FeatureParentSelect } from './FeatureParentSelect.jsx'
|
||||
|
||||
export function BacklogItemForm({
|
||||
initial = {},
|
||||
roadmapItems = [],
|
||||
featureActions = [],
|
||||
onSubmit,
|
||||
onCancel,
|
||||
busy = false,
|
||||
|
|
@ -19,17 +23,23 @@ export function BacklogItemForm({
|
|||
e.preventDefault()
|
||||
const form = e.target
|
||||
const gateValue = form.roadmap_item_id?.value ?? ''
|
||||
const parentValue = form.parent_action_id?.value ?? ''
|
||||
await onSubmit({
|
||||
title: form.title.value.trim(),
|
||||
description: form.description.value,
|
||||
status: form.status?.value,
|
||||
priority: form.priority.value,
|
||||
item_kind: form.item_kind?.value || 'story',
|
||||
roadmap_item_id: gateValue || undefined,
|
||||
clear_roadmap_item: gateValue === '',
|
||||
parent_action_id: parentValue || undefined,
|
||||
clear_parent_action: parentValue === '',
|
||||
})
|
||||
}
|
||||
|
||||
const statusLocked = initial.status === 'converted'
|
||||
const showFeatureRef =
|
||||
(initial.item_kind || 'story') !== 'story' || featureActions.length > 0
|
||||
|
||||
return (
|
||||
<form className="form workspace-form backlog-item-form" onSubmit={handleSubmit}>
|
||||
|
|
@ -53,6 +63,20 @@ export function BacklogItemForm({
|
|||
/>
|
||||
</label>
|
||||
<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 && (
|
||||
<label>
|
||||
Status
|
||||
|
|
@ -69,6 +93,8 @@ export function BacklogItemForm({
|
|||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-row form-row--2">
|
||||
<label>
|
||||
Priorität
|
||||
<select name="priority" defaultValue={initial.priority || 'normal'} disabled={statusLocked}>
|
||||
|
|
@ -87,6 +113,13 @@ export function BacklogItemForm({
|
|||
disabled={statusLocked}
|
||||
/>
|
||||
)}
|
||||
{showFeatureRef && (
|
||||
<FeatureParentSelect
|
||||
featureActions={featureActions}
|
||||
defaultValue={initial.parent_action_id || ''}
|
||||
disabled={statusLocked}
|
||||
/>
|
||||
)}
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary" disabled={busy || statusLocked}>
|
||||
{busy ? 'Speichern …' : submitLabel}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { ReorderControls } from './ReorderControls.jsx'
|
|||
import { gateTitleById } from './GateSelect.jsx'
|
||||
import { computeDropPatches, computeMovePatches, sortByOrder } from '../utils/reorder.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'])
|
||||
/** Backend erlaubt Convert für diese Status — UI war nur bei „Freigegeben“ sichtbar. */
|
||||
|
|
@ -19,6 +21,7 @@ export function BacklogSection({
|
|||
roadmapItems = [],
|
||||
workCycles = [],
|
||||
activeWorkCycle = null,
|
||||
featureActions = [],
|
||||
initiativeId = '',
|
||||
sprintPlanningEnabled = false,
|
||||
canManage,
|
||||
|
|
@ -89,6 +92,8 @@ export function BacklogSection({
|
|||
description: payload.description,
|
||||
priority: payload.priority,
|
||||
roadmap_item_id: payload.roadmap_item_id,
|
||||
item_kind: payload.item_kind,
|
||||
parent_action_id: payload.parent_action_id,
|
||||
status: payload.status || 'new',
|
||||
})
|
||||
closeModal()
|
||||
|
|
@ -103,6 +108,9 @@ export function BacklogSection({
|
|||
priority: payload.priority,
|
||||
roadmap_item_id: payload.roadmap_item_id,
|
||||
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()
|
||||
}
|
||||
|
|
@ -302,9 +310,19 @@ export function BacklogSection({
|
|||
disabled={!canManage}
|
||||
>
|
||||
<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 && (
|
||||
<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 && (
|
||||
<p className="list-item-sub muted">
|
||||
Gate:{' '}
|
||||
|
|
@ -374,6 +392,7 @@ export function BacklogSection({
|
|||
{modalMode?.kind === 'create' && (
|
||||
<BacklogItemForm
|
||||
roadmapItems={roadmapItems}
|
||||
featureActions={featureActions}
|
||||
onSubmit={handleCreateSubmit}
|
||||
onCancel={closeModal}
|
||||
busy={busy}
|
||||
|
|
@ -384,6 +403,7 @@ export function BacklogSection({
|
|||
<BacklogItemForm
|
||||
initial={modalMode.item}
|
||||
roadmapItems={roadmapItems}
|
||||
featureActions={featureActions}
|
||||
onSubmit={handleEditSubmit}
|
||||
onCancel={closeModal}
|
||||
busy={busy}
|
||||
|
|
|
|||
25
frontend/src/components/FeatureParentSelect.jsx
Normal file
25
frontend/src/components/FeatureParentSelect.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ export function InitiativeActionsHub({
|
|||
onUpdateAction,
|
||||
onQuickStatus,
|
||||
onCreateBlockerForAction,
|
||||
onUnplanToBacklog,
|
||||
canManage,
|
||||
canManageBlocker,
|
||||
formBusy,
|
||||
|
|
@ -96,6 +97,7 @@ export function InitiativeActionsHub({
|
|||
onSubmitEdit={(payload) => onUpdateAction(action.id, payload)}
|
||||
onQuickStatus={(status) => onQuickStatus(action, status)}
|
||||
onCreateBlocker={() => onCreateBlockerForAction(action.id)}
|
||||
onUnplanToBacklog={onUnplanToBacklog}
|
||||
canManage={canManage}
|
||||
canManageBlocker={canManageBlocker}
|
||||
formBusy={formBusy}
|
||||
|
|
@ -109,6 +111,7 @@ export function InitiativeActionsHub({
|
|||
roadmapItems={roadmapItems}
|
||||
workCycles={workCycles}
|
||||
activeWorkCycle={activeWorkCycle}
|
||||
allActions={actions}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,18 @@ import { useState } from 'react'
|
|||
import { StatusBadge } from './StatusBadge.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({
|
||||
workCycles = [],
|
||||
activeWorkCycle = null,
|
||||
|
|
@ -31,6 +43,18 @@ export function WorkCyclesPanel({
|
|||
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 (
|
||||
<section className="card work-cycles-panel">
|
||||
<div className="section-header">
|
||||
|
|
@ -39,6 +63,7 @@ export function WorkCyclesPanel({
|
|||
<p className="section-lead muted">
|
||||
Sprint anklicken, um den geplanten Sprint-Backlog zu sehen — aktiv oder geplant.
|
||||
Committete Arbeitspakete aus dem Eingang erscheinen beim gewählten Sprint.
|
||||
Lebenszyklus: Planung → Ausführung → Abschluss (Scrum).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -95,6 +120,7 @@ export function WorkCyclesPanel({
|
|||
{workCycles.map((cycle) => {
|
||||
const isSelected = selectedSprintId === cycle.id
|
||||
const apCount = actionCountByCycleId[cycle.id] || 0
|
||||
const step = lifecycleStepIndex(cycle.status)
|
||||
return (
|
||||
<li
|
||||
key={cycle.id}
|
||||
|
|
@ -113,6 +139,25 @@ export function WorkCyclesPanel({
|
|||
<span className="muted work-cycle-row__count">
|
||||
{apCount} AP{apCount === 1 ? '' : 's'}
|
||||
</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 && (
|
||||
<p className="muted item-meta">{cycle.goal_description}</p>
|
||||
)}
|
||||
|
|
@ -123,7 +168,7 @@ export function WorkCyclesPanel({
|
|||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => onComplete(cycle.id)}
|
||||
onClick={() => handleComplete(cycle)}
|
||||
>
|
||||
Abschließen
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -61,6 +61,22 @@ export const BACKLOG_STATUS_LABELS = {
|
|||
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 = {
|
||||
planned: 'Geplant',
|
||||
active: 'Aktiv',
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
createInitiativeAction,
|
||||
updateInitiative,
|
||||
} 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 {
|
||||
listInitiativeBlockers,
|
||||
|
|
@ -84,6 +84,7 @@ import { useSession } from './SessionContext.jsx'
|
|||
import { useOptionalProgramScope } from './ProgramScopeContext.jsx'
|
||||
import { collectProjectSubtreeIds } from '../utils/projectTree.js'
|
||||
import { filterActionsForWorkCycle } from '../utils/workCycleActions.js'
|
||||
import { listFeatureParentActions } from '../utils/actionReferences.js'
|
||||
|
||||
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(
|
||||
() => Object.fromEntries((steeringSnapshot?.actions || []).map((a) => [a.id, a])),
|
||||
[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) {
|
||||
setFormBusy(true)
|
||||
try {
|
||||
|
|
@ -861,6 +899,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
|
|||
initiativeId: id,
|
||||
initiative,
|
||||
actions,
|
||||
featureParentActions,
|
||||
visibleActions,
|
||||
blockers,
|
||||
unlinkedBlockers,
|
||||
|
|
@ -919,6 +958,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
|
|||
handleBacklogStatus,
|
||||
handleConvertBacklog,
|
||||
handleBulkConvertBacklog,
|
||||
handleUnplanAction,
|
||||
handleCreateWorkCycle,
|
||||
handleActivateWorkCycle,
|
||||
handleCompleteWorkCycle,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export function InitiativeInboxPage() {
|
|||
initiativeId,
|
||||
backlogItems,
|
||||
roadmapItems,
|
||||
featureParentActions,
|
||||
workCycles,
|
||||
activeWorkCycle,
|
||||
capabilities,
|
||||
|
|
@ -39,6 +40,7 @@ export function InitiativeInboxPage() {
|
|||
items={backlogItems}
|
||||
initiativeId={initiativeId}
|
||||
roadmapItems={roadmapItems}
|
||||
featureActions={featureParentActions}
|
||||
workCycles={workCycles}
|
||||
activeWorkCycle={activeWorkCycle}
|
||||
sprintPlanningEnabled={isProductArchetype}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ function PlanSprintInner() {
|
|||
handleUpdateAction,
|
||||
handleQuickStatus,
|
||||
handleCreateBlockerForAction,
|
||||
handleUnplanAction,
|
||||
} = ops
|
||||
|
||||
const actionCountByCycleId = useMemo(
|
||||
|
|
@ -103,6 +104,7 @@ function PlanSprintInner() {
|
|||
onUpdateAction={handleUpdateAction}
|
||||
onQuickStatus={handleQuickStatus}
|
||||
onCreateBlockerForAction={handleCreateBlockerForAction}
|
||||
onUnplanToBacklog={handleUnplanAction}
|
||||
canManage={capabilities.has('kairo.action.manage')}
|
||||
canManageBlocker={capabilities.has('kairo.blocker.manage')}
|
||||
formBusy={formBusy}
|
||||
|
|
|
|||
23
frontend/src/utils/actionReferences.js
Normal file
23
frontend/src/utils/actionReferences.js
Normal 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 || ''
|
||||
}
|
||||
20
frontend/src/utils/actionReferences.test.js
Normal file
20
frontend/src/utils/actionReferences.test.js
Normal 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('')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user