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
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
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' })
|
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')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
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,
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
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