Compare commits

...

2 Commits

Author SHA1 Message Date
82a5b9adec AP2.2d: B2b Product-Backlog und B3 Sprint-Backlog in UI.
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 2m40s
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 19s
Test Suite / playwright-smoke (push) Successful in 13s
Zeitbox-API im Frontend, Plan/Ausführen Sprint-Ansichten, Backlog-Convert in aktiven Sprint. agile_iteration bei aktiver Zeitbox für continuous_product.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 16:27:34 +02:00
0b57e2ec75 AP2.2b/c: A1-Stufenwechsel, Team-Fix, Agent-Anlage, Steuerungs-Hinweise.
AP2.0e rotiert Recurring bei maturity_stage reached. Maturity-Strategie priorisiert Routinen. Team zeigt Namen und Agent-Formular. Archetyp-Hints in Kontrolle und Stufe-A-Gruppierung bei Anlage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 16:19:55 +02:00
41 changed files with 1149 additions and 130 deletions

View File

@ -20,6 +20,11 @@ class ServiceTokenCreateRequest(BaseModel):
expires_in_days: Optional[int] = Field(default=365, ge=1, le=730)
class ActorCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=255)
actor_type: Literal["human", "agent", "working_group", "external_system"] = "agent"
@router.get("")
def list_actors(
actor_type: Optional[Literal["human", "agent", "working_group", "external_system"]] = None,
@ -38,6 +43,26 @@ def list_actors(
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("", status_code=201)
def create_actor(
body: ActorCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.actor.manage")),
):
if body.actor_type == "human":
raise HTTPException(
status_code=400,
detail="Human-Actors werden über User-Provisioning angelegt",
)
try:
return actor_service.create_actor(
tenant_id=ctx.tenant_id,
actor_type=body.actor_type,
name=body.name.strip(),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/{actor_id}")
def get_actor(
actor_id: str,

View File

@ -34,6 +34,8 @@ class BacklogUpdateRequest(BaseModel):
class BacklogConvertRequest(BaseModel):
assigned_actor_ids: list[str] = Field(default_factory=list)
work_cycle_id: Optional[str] = None
assign_active_sprint: bool = True
@router.get("/{backlog_item_id}")
@ -103,6 +105,8 @@ def convert_backlog_to_action(
backlog_item_id=backlog_item_id,
user_id=ctx.user_id,
assigned_actor_ids=assigned,
work_cycle_id=body.work_cycle_id,
assign_active_sprint=body.assign_active_sprint,
)
except ValueError as exc:
detail = str(exc)

View File

@ -2,6 +2,7 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional
from entity_archetypes import resolve_default_method_key
@ -222,6 +223,7 @@ def _apply_a1(
title="Tägliche Übung",
description="Regelmäßige Übung für die aktuelle Reifegrad-Stufe",
interval_days=1,
next_due_at=datetime.now(timezone.utc),
user_id=user_id,
)

View File

@ -295,6 +295,8 @@ def convert_backlog_to_action(
backlog_item_id: str,
user_id: Optional[str] = None,
assigned_actor_ids: Optional[list[str]] = None,
work_cycle_id: Optional[str] = None,
assign_active_sprint: bool = True,
) -> dict[str, Any]:
existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id)
if not existing:
@ -304,6 +306,16 @@ def convert_backlog_to_action(
if existing["status"] not in ("accepted", "triaged", "new"):
raise ValueError("Backlog-Item kann in diesem Status nicht konvertiert werden")
resolved_cycle_id = work_cycle_id
if not resolved_cycle_id and assign_active_sprint:
from services.work_cycle import get_active_work_cycle
active = get_active_work_cycle(
tenant_id=tenant_id, initiative_id=existing["initiative_id"]
)
if active:
resolved_cycle_id = active["id"]
action = create_action(
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
@ -311,6 +323,7 @@ def convert_backlog_to_action(
description=existing["description"] or "",
priority=existing["priority"],
roadmap_item_id=existing.get("roadmap_item_id"),
work_cycle_id=resolved_cycle_id,
assigned_actor_ids=assigned_actor_ids or [],
user_id=user_id,
)

View File

@ -0,0 +1,87 @@
"""AP2.0e — Recurring transition when a maturity_stage is reached."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional
from services.recurring import (
create_recurring_element,
list_recurring_for_initiative,
update_recurring_element,
)
from services.roadmap import get_roadmap_item, list_roadmap_items_for_initiative, update_roadmap_item
def on_maturity_stage_reached(
*,
tenant_id: str,
initiative_id: str,
reached_item_id: str,
user_id: Optional[str] = None,
) -> Optional[dict[str, Any]]:
"""Pause old routines, activate next stage, seed new routine for next stage."""
item = get_roadmap_item(tenant_id=tenant_id, item_id=reached_item_id)
if not item or item.get("item_type") != "maturity_stage":
return None
stages = [
s
for s in list_roadmap_items_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
if s.get("item_type") == "maturity_stage"
]
stages.sort(key=lambda s: (s.get("sort_order", 0), s.get("title", "")))
reached_index = next(
(index for index, stage in enumerate(stages) if stage["id"] == reached_item_id),
None,
)
if reached_index is None:
return None
next_stage = None
for stage in stages[reached_index + 1 :]:
if stage.get("status") in {"planned", "active", "at_risk"}:
next_stage = stage
break
paused: list[str] = []
for recurring in list_recurring_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
):
if recurring.get("status") == "active":
update_recurring_element(
tenant_id=tenant_id,
recurring_id=recurring["id"],
status="paused",
user_id=user_id,
)
paused.append(recurring["title"])
created_recurring = None
if next_stage:
update_roadmap_item(
tenant_id=tenant_id,
item_id=next_stage["id"],
status="active",
user_id=user_id,
)
created_recurring = create_recurring_element(
tenant_id=tenant_id,
initiative_id=initiative_id,
title=f"Übung — {next_stage['title']}",
description=f"Routine für {next_stage['title']} (automatisch nach Stufenübergang)",
status="active",
interval_days=1,
next_due_at=datetime.now(timezone.utc),
user_id=user_id,
)
return {
"reached_stage_id": reached_item_id,
"next_stage_id": next_stage["id"] if next_stage else None,
"paused_recurring": paused,
"new_recurring_id": created_recurring["id"] if created_recurring else None,
}

View File

@ -708,6 +708,17 @@ def verify_reached(
tenant_id=tenant_id,
details={"roadmap_item_id": item_id, "verify_reason": verify_reason},
)
if row.get("item_type") == "maturity_stage":
from services.maturity_stage_transition import on_maturity_stage_reached
transition = on_maturity_stage_reached(
tenant_id=tenant_id,
initiative_id=initiative_id,
reached_item_id=item_id,
user_id=user_id,
)
if transition:
row["maturity_transition"] = transition
return row

View File

@ -25,6 +25,25 @@ def _resolve_method_key(ctx: TenantContext, initiative_id: str | None) -> str:
return "generic_operating"
def _resolve_next_action_strategy_key(
ctx: TenantContext, initiative_id: str | None
) -> str:
method_key = _resolve_method_key(ctx, initiative_id)
method = get_method(method_key)
strategy_key = method.next_action_strategy_key if method else default_strategy.key
if not initiative_id:
return strategy_key
from services.work_cycle import get_active_work_cycle
active = get_active_work_cycle(tenant_id=ctx.tenant_id, initiative_id=initiative_id)
if active and strategy_key == "continuous_product":
agile = get_next_action_strategy("agile_iteration")
if agile:
return agile.key
return strategy_key
def evaluate(
ctx: TenantContext,
kind: SignalKind = "attention",
@ -35,10 +54,6 @@ def evaluate(
if kind == "attention":
return default_rules.get_attention_items(ctx)
method_key = _resolve_method_key(ctx, initiative_id)
method = get_method(method_key)
strategy_key = (
method.next_action_strategy_key if method else default_strategy.key
)
strategy_key = _resolve_next_action_strategy_key(ctx, initiative_id)
strategy = get_next_action_strategy(strategy_key) or default_strategy
return strategy.evaluate(ctx, initiative_id=initiative_id, limit=limit)

View File

@ -14,6 +14,10 @@ from steering.strategies.next_action.execution_ready import (
from steering.strategies.next_action.product_milestone_driven import (
ProductMilestoneDrivenStrategy,
)
from steering.strategies.next_action.recurring_helpers import (
active_recurring_candidates,
recurring_due_candidates,
)
from steering.strategies.next_action.registry import (
get_next_action_strategy,
register_next_action_strategy,
@ -39,26 +43,37 @@ class MaturityProgressionStrategy:
if not initiative_id:
return _default.evaluate(ctx, limit=limit)
gate_scope = first_active_gate_id(ctx, initiative_id)
ready = list_execution_ready_candidates(
ctx,
initiative_id,
limit=limit,
scope_roadmap_item_id=gate_scope,
prefer_critical_path=True,
)
recurring = recurring_due_candidates(ctx, initiative_id, limit=limit)
if not recurring:
recurring = active_recurring_candidates(ctx, initiative_id, limit=1)
remaining = limit - len(ready)
gate_items: list[dict[str, Any]] = []
remaining = limit - len(recurring)
gate_scope = first_active_gate_id(ctx, initiative_id)
ready: list[dict[str, Any]] = []
if remaining > 0:
ready = list_execution_ready_candidates(
ctx,
initiative_id,
limit=remaining,
scope_roadmap_item_id=gate_scope,
prefer_critical_path=True,
)
merged = merge_candidates(recurring, ready, limit=limit)
if len(merged) >= limit:
return merged[:limit]
gate_items: list[dict[str, Any]] = []
rest_limit = limit - len(merged)
if rest_limit > 0:
gate_items = _milestone.evaluate(
ctx, initiative_id=initiative_id, limit=remaining
ctx, initiative_id=initiative_id, limit=rest_limit
)
gate_items = [
item for item in gate_items if item.get("kind") == "review_milestone"
]
merged = merge_candidates(ready, gate_items, limit=limit)
merged = merge_candidates(merged, gate_items, limit=limit)
if len(merged) >= limit:
return merged[:limit]

View File

@ -4,8 +4,7 @@ from __future__ import annotations
from typing import Any
from db import get_connection
from psycopg2.extras import RealDictCursor
from steering.strategies.next_action.recurring_helpers import recurring_due_candidates
from steering.signals import default_rules
from steering.strategies.next_action.default_strategy import DefaultNextActionStrategy
from steering.strategies.next_action.execution_ready import (
@ -21,43 +20,6 @@ from tenant_context import TenantContext
_default = DefaultNextActionStrategy()
def _recurring_due_candidates(
ctx: TenantContext, initiative_id: str, *, limit: int
) -> list[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
'recurring_due' AS kind,
re.title AS title,
'Wiederkehrendes Element fällig' AS summary,
re.initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS backlog_item_id,
'recurring_due' AS reason_code,
'Rhythmus-Element bearbeiten' AS recommended_action
FROM recurring_elements re
WHERE re.tenant_id = %s AND re.initiative_id = %s
AND re.status = 'active'
AND re.next_due_at IS NOT NULL
AND re.next_due_at <= NOW()
ORDER BY re.next_due_at ASC
LIMIT %s
""",
(ctx.tenant_id, initiative_id, limit),
)
items: list[dict[str, Any]] = []
for row in cur.fetchall():
item = dict(row)
item["initiative_id"] = str(item["initiative_id"])
items.append(item)
return items
finally:
conn.close()
class RecurringControlStrategy:
key = "recurring_control"
@ -73,7 +35,7 @@ class RecurringControlStrategy:
if not initiative_id:
return _default.evaluate(ctx, limit=limit)
recurring = _recurring_due_candidates(ctx, initiative_id, limit=limit)
recurring = recurring_due_candidates(ctx, initiative_id, limit=limit)
remaining = limit - len(recurring)
ready: list[dict[str, Any]] = []
if remaining > 0:

View File

@ -0,0 +1,82 @@
"""Shared recurring NextAction helpers."""
from __future__ import annotations
from typing import Any
from db import get_connection
from psycopg2.extras import RealDictCursor
from tenant_context import TenantContext
def recurring_due_candidates(
ctx: TenantContext, initiative_id: str, *, limit: int
) -> list[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
'recurring_due' AS kind,
re.title AS title,
'Wiederkehrendes Element fällig' AS summary,
re.initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS backlog_item_id,
'recurring_due' AS reason_code,
'Rhythmus-Element bearbeiten' AS recommended_action
FROM recurring_elements re
WHERE re.tenant_id = %s AND re.initiative_id = %s
AND re.status = 'active'
AND re.next_due_at IS NOT NULL
AND re.next_due_at <= NOW()
ORDER BY re.next_due_at ASC
LIMIT %s
""",
(ctx.tenant_id, initiative_id, limit),
)
items: list[dict[str, Any]] = []
for row in cur.fetchall():
item = dict(row)
item["initiative_id"] = str(item["initiative_id"])
items.append(item)
return items
finally:
conn.close()
def active_recurring_candidates(
ctx: TenantContext, initiative_id: str, *, limit: int = 1
) -> list[dict[str, Any]]:
"""Active recurring as 'heutige Übung' when nothing is due yet."""
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
'recurring_due' AS kind,
re.title AS title,
'Heutige Übung / Routine' AS summary,
re.initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS backlog_item_id,
'recurring_active' AS reason_code,
'Routine ausführen' AS recommended_action
FROM recurring_elements re
WHERE re.tenant_id = %s AND re.initiative_id = %s
AND re.status = 'active'
ORDER BY re.next_due_at ASC NULLS LAST, re.title
LIMIT %s
""",
(ctx.tenant_id, initiative_id, limit),
)
items: list[dict[str, Any]] = []
for row in cur.fetchall():
item = dict(row)
item["initiative_id"] = str(item["initiative_id"])
items.append(item)
return items
finally:
conn.close()

View File

@ -0,0 +1,62 @@
"""AP2.0e — maturity stage transition on verify reached."""
from __future__ import annotations
from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import _auth, _login
def test_maturity_stage_reached_rotates_recurring(client):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
created = client.post(
"/api/initiatives",
json={
"title": "Spagat Test",
"archetype_key": "initiative.maturity_journey",
},
headers=_auth(token),
)
assert created.status_code == 201
initiative_id = created.json()["id"]
items = client.get(
f"/api/initiatives/{initiative_id}/roadmap/items",
headers=_auth(token),
).json()
stage1 = next(i for i in items if i["title"] == "Stufe 1 — Basis")
client.post(
f"/api/initiatives/{initiative_id}/evidence",
json={
"title": "Stufe 1 geschafft",
"roadmap_item_id": stage1["id"],
"status": "accepted",
},
headers=_auth(token),
)
verify = client.post(
f"/api/roadmap-items/{stage1['id']}/verify-reached",
headers=_auth(token),
)
assert verify.status_code == 200
body = verify.json()
assert body["status"] == "reached"
assert body.get("maturity_transition", {}).get("new_recurring_id")
recurring = client.get(
f"/api/initiatives/{initiative_id}/recurring",
headers=_auth(token),
).json()
titles = {r["title"]: r["status"] for r in recurring}
assert titles.get("Tägliche Übung") == "paused"
assert any(t.startswith("Übung — Stufe 2") for t in titles)
items_after = client.get(
f"/api/initiatives/{initiative_id}/roadmap/items",
headers=_auth(token),
).json()
stage2 = next(i for i in items_after if i["title"] == "Stufe 2 — Aufbau")
assert stage2["status"] == "active"

View File

@ -0,0 +1,18 @@
"""Actor create API."""
from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import _auth, _login
def test_create_agent_actor(client):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
created = client.post(
"/api/actors",
json={"name": "Cursor Agent", "actor_type": "agent"},
headers=_auth(token),
)
assert created.status_code == 201
assert created.json()["name"] == "Cursor Agent"
assert created.json()["actor_type"] == "agent"

View File

@ -0,0 +1,44 @@
"""AP2.2d — Backlog convert assigns active sprint."""
from __future__ import annotations
from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import _auth, _create_initiative, _login
def test_convert_backlog_assigns_active_work_cycle(client):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
created = _create_initiative(
client,
token,
title="Product Backlog",
archetype_key="initiative.product",
)
initiative_id = created.json()["id"]
cycle = client.post(
f"/api/initiatives/{initiative_id}/work-cycles",
json={"title": "Sprint R1", "status": "active"},
headers=_auth(token),
)
assert cycle.status_code == 201
cycle_id = cycle.json()["id"]
backlog = client.post(
f"/api/initiatives/{initiative_id}/backlog",
json={"title": "Feature X", "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={"assign_active_sprint": True},
headers=_auth(token),
)
assert converted.status_code == 201
action = converted.json()["action"]
assert action["work_cycle_id"] == cycle_id

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.20.0-ap2.2a"
APP_VERSION = "0.20.2-ap2.2d"
DB_SCHEMA_VERSION = "025"
APP_NAME = "jinkendo-kairo"

View File

@ -148,8 +148,8 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|----------|------------|------------------|-----------|--------|-------|
| A1 Reifegrad | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2c + AP2.0e |
| A2 Linear | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2b |
| B2b Product | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2d |
| B3 Sprint-Profil | ◐ | ◐ | ◐ | ◐ | AP2.2d |
| B2b Product | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2d ✓ |
| B3 Sprint-Profil | ◐ | ◐ | ◐ | ◐ | AP2.2d ✓ |
| B2a Programm | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2e |
---

View File

@ -71,11 +71,11 @@ Legende: **Anlage** · **Struktur** · **Ist** · **Kontrolle** · **UI-Default*
```text
Phase 0 DOC + Kurskorrektur (dieses Dokument) ← jetzt
Phase 1 AP2.2a Archetyp-geführte Anlage + Starter-Kits ← NÄCHSTES CODE
Phase 2 AP1.9d Methoden-Default-Ansichten (Anti-Todo-Wand) parallel möglich
Phase 3 AP2.2b A2 Linear End-to-End
AP2.2c A1 Reifegrad + AP2.0e
AP2.2d B2b Product + B3 Sprint
Phase 1 AP2.2a Archetyp-geführte Anlage + Starter-Kits ✓
Phase 2 AP1.9d Methoden-Default-Ansichten (Anti-Todo-Wand) ◐ (Hints + Recurring)
Phase 3 AP2.2b A2 Linear End-to-End ◐
AP2.2c A1 Reifegrad + AP2.0e ◐ Code
AP2.2d B2b Product + B3 Sprint ✓
AP2.2e B2a Programm (optional vor AP2.1)
Phase 4 AP1.7b Operational API — Pflege-Parität
Phase 5 AP2.1 Validation Report v0.3 (alle Stufe-A-Szenarien)

View File

@ -1,6 +1,6 @@
{
"name": "kairo-jinkendo-frontend",
"version": "0.20.0-ap2.2a",
"version": "0.20.2-ap2.2d",
"private": true,
"type": "module",
"scripts": {

View File

@ -63,6 +63,7 @@ function AppRoutes() {
<Route path="/work" element={<WorkLayout />}>
<Route index element={<WorkIndexRedirect />} />
<Route path="today" element={<MODE_ROUTE_COMPONENTS.workToday />} />
<Route path="sprint" element={<MODE_ROUTE_COMPONENTS.workSprint />} />
<Route path="mine" element={<MODE_ROUTE_COMPONENTS.workMine />} />
</Route>
@ -73,6 +74,7 @@ function AppRoutes() {
<Route path="structure" element={<MODE_ROUTE_COMPONENTS.planStructure />} />
<Route path="gates" element={<MODE_ROUTE_COMPONENTS.planGates />} />
<Route path="inbox" element={<MODE_ROUTE_COMPONENTS.planInbox />} />
<Route path="sprint" element={<MODE_ROUTE_COMPONENTS.planSprint />} />
<Route path="work" element={<MODE_ROUTE_COMPONENTS.planWork />} />
</Route>

View File

@ -16,6 +16,13 @@ export function getActor(id) {
return apiFetch(`/api/actors/${id}`)
}
export function createActor(body) {
return apiFetch('/api/actors', {
method: 'POST',
body: JSON.stringify(body),
})
}
/** Fallback wenn Actor Directory nicht erreichbar (AP0.6-Kompatibilität). */
export function actorsFromContext(context) {
if (!context?.actor?.id) return []

View File

@ -0,0 +1,22 @@
import { apiFetch } from './client.js'
export function listInitiativeWorkCycles(initiativeId) {
return apiFetch(`/api/initiatives/${initiativeId}/work-cycles`)
}
export function getActiveWorkCycle(initiativeId) {
return apiFetch(`/api/initiatives/${initiativeId}/work-cycles/active`)
}
export function createWorkCycle(initiativeId, body) {
return apiFetch(`/api/initiatives/${initiativeId}/work-cycles`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export function activateWorkCycle(initiativeId, workCycleId) {
return apiFetch(`/api/initiatives/${initiativeId}/work-cycles/${workCycleId}/activate`, {
method: 'POST',
})
}

View File

@ -15,6 +15,8 @@ export function ActionForm({
initial = {},
projects = [],
roadmapItems = [],
workCycles = [],
activeWorkCycle = null,
actors = [],
actorsLoading = false,
actorsError = null,
@ -37,6 +39,7 @@ export function ActionForm({
due_at: form.due_at.value ? new Date(form.due_at.value).toISOString() : null,
project_id: form.project_id?.value || undefined,
roadmap_item_id: form.roadmap_item_id?.value || undefined,
work_cycle_id: form.work_cycle_id?.value || undefined,
assigned_actor_ids: selected,
})
}
@ -44,6 +47,9 @@ export function ActionForm({
const defaultActors = initial.assigned_actor_ids || []
const leafProjects = getLeafProjects(projects)
const projectOptions = flattenLeafProjectOptions(projects)
const cycleOptions = workCycles.filter((c) => c.status !== 'completed')
const defaultCycleId =
initial.work_cycle_id || (activeWorkCycle?.id && !initial.id ? activeWorkCycle.id : '')
return (
<form className="form workspace-form" onSubmit={handleSubmit}>
@ -100,6 +106,20 @@ export function ActionForm({
roadmapItems={roadmapItems}
defaultValue={initial.roadmap_item_id || ''}
/>
{cycleOptions.length > 0 && (
<label>
Sprint / Zeitbox (optional)
<select name="work_cycle_id" defaultValue={defaultCycleId}>
<option value="">— kein Sprint —</option>
{cycleOptions.map((cycle) => (
<option key={cycle.id} value={cycle.id}>
{cycle.title}
{cycle.status === 'active' ? ' (aktiv)' : ''}
</option>
))}
</select>
</label>
)}
<ActorSelect
actors={actors}
defaultSelected={defaultActors}

View File

@ -54,6 +54,8 @@ export function ActionHubCard({
initiativeId,
projects = [],
roadmapItems = [],
workCycles = [],
activeWorkCycle = null,
detailMode = false,
}) {
const due = formatDue(action.due_at)
@ -66,6 +68,8 @@ export function ActionHubCard({
initial={action}
projects={projects}
roadmapItems={roadmapItems}
workCycles={workCycles}
activeWorkCycle={activeWorkCycle}
actors={actors}
actorsLoading={actorsLoading}
actorsError={actorsError}

View File

@ -0,0 +1,44 @@
import { Link } from 'react-router-dom'
import { scopedPath } from '../utils/routes.js'
const HINTS = {
'initiative.maturity_journey': {
text: 'Reifegrad: Heutige Übung (Rhythmus) und aktive Stufe steuern den nächsten Schritt.',
link: { to: '/control/journey', label: 'Rhythmen & Journey' },
},
'initiative.linear_project': {
text: 'Linear: Nächster Schritt am kritischen Pfad — Abhängigkeiten im Gate-Graph.',
link: { to: '/plan/gates', label: 'Gate-Graph öffnen' },
},
'initiative.product': {
text: 'Product: Eingang triagieren → committen in Sprint-Backlog (aktive Zeitbox).',
link: { to: '/plan/sprint', label: 'Sprint planen' },
secondaryLink: { to: '/work/sprint', label: 'Sprint ausführen' },
},
'initiative.program': {
text: 'Programm: Gate-Horizont und Abschluss im Blick behalten.',
link: { to: '/plan/gates', label: 'Phasen / Gates' },
},
}
export function ArchetypeSteeringHints({ archetypeKey }) {
const hint = HINTS[archetypeKey]
if (!hint) return null
return (
<p className="steering-archetype-hints muted">
{hint.text}{' '}
<Link to={scopedPath(hint.link.to)} className="link-inline">
{hint.link.label}
</Link>
{hint.secondaryLink && (
<>
{' · '}
<Link to={scopedPath(hint.secondaryLink.to)} className="link-inline">
{hint.secondaryLink.label}
</Link>
</>
)}
</p>
)
}

View File

@ -19,6 +19,8 @@ export function BacklogSection({
onConvert,
onDelete,
busy,
sectionTitle = 'Product Backlog',
sectionLead = 'Eingang vor dem Commit — triagieren, dann in Arbeitspaket (Sprint) umwandeln.',
}) {
const [modalMode, setModalMode] = useState(null)
const [dragItemId, setDragItemId] = useState('')
@ -106,10 +108,8 @@ export function BacklogSection({
<section className="card backlog-section">
<div className="section-header">
<div>
<h2>Backlog</h2>
<p className="section-lead muted">
Reihenfolge per Drag &amp; Drop (Desktop) oder ↑/↓ (Mobile).
</p>
<h2>{sectionTitle}</h2>
<p className="section-lead muted">{sectionLead}</p>
</div>
{canManage && (
<button

View File

@ -7,6 +7,8 @@ export function InitiativeActionsHub({
actions,
projects = [],
roadmapItems = [],
workCycles = [],
activeWorkCycle = null,
actionContextById,
hideDone,
onHideDoneChange,
@ -27,16 +29,15 @@ export function InitiativeActionsHub({
actorsError,
actorsUsedFallback,
onReloadActors,
sectionTitle = 'Arbeitspakete',
sectionLead = 'Committete operative Einheit — Aufgaben im Detail. Blocker, Nachweise und Reviews hängen am Arbeitspaket.',
}) {
return (
<section className="card initiative-actions-hub">
<div className="section-header">
<div>
<h2>Arbeitspakete</h2>
<p className="section-lead muted">
Committete operative Einheit — Aufgaben im Detail. Blocker, Nachweise und Reviews
hängen am Arbeitspaket.
</p>
<h2>{sectionTitle}</h2>
<p className="section-lead muted">{sectionLead}</p>
</div>
<div className="section-actions">
<label className="checkbox-label inline-filter">
@ -64,6 +65,8 @@ export function InitiativeActionsHub({
<ActionForm
projects={projects}
roadmapItems={roadmapItems}
workCycles={workCycles}
activeWorkCycle={activeWorkCycle}
actors={actors}
actorsLoading={actorsLoading}
actorsError={actorsError}
@ -104,6 +107,8 @@ export function InitiativeActionsHub({
initiativeId={initiativeId}
projects={projects}
roadmapItems={roadmapItems}
workCycles={workCycles}
activeWorkCycle={activeWorkCycle}
/>
))}
</div>

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { INITIATIVE_STATUSES, PRIORITIES } from '../constants/status.js'
import { INITIATIVE_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js'
import { INITIATIVE_ARCHETYPE_LABELS } from '../constants/initiativeArchetypes.js'
import { INITIATIVE_ARCHETYPE_LABELS, STUFE_A_ARCHETYPE_KEYS } from '../constants/initiativeArchetypes.js'
import { listEntityArchetypes, getArchetypeStarterPreview } from '../api/entityArchetypes.js'
import {
getInitiativeFields,
@ -129,6 +129,8 @@ export function InitiativeForm({
const formReady = archetypes && !fieldsLoading
const selectedArchetype = (archetypes || []).find((item) => item.key === archetypeKey)
const stufeA = (archetypes || []).filter((item) => STUFE_A_ARCHETYPE_KEYS.has(item.key))
const otherArchetypes = (archetypes || []).filter((item) => !STUFE_A_ARCHETYPE_KEYS.has(item.key))
return (
<form className="form workspace-form initiative-form" onSubmit={handleSubmit}>
@ -146,11 +148,24 @@ export function InitiativeForm({
onChange={(e) => setArchetypeKey(e.target.value)}
disabled={!archetypes}
>
{(archetypes || []).map((item) => (
<option key={item.key} value={item.key}>
{item.label}
</option>
))}
{stufeA.length > 0 && (
<optgroup label="MVP Stufe A (empfohlen)">
{stufeA.map((item) => (
<option key={item.key} value={item.key}>
{item.label}
</option>
))}
</optgroup>
)}
{otherArchetypes.length > 0 && (
<optgroup label="Weitere Archetypen">
{otherArchetypes.map((item) => (
<option key={item.key} value={item.key}>
{item.label}
</option>
))}
</optgroup>
)}
</select>
{selectedArchetype?.description && (
<span className="muted form-hint">{selectedArchetype.description}</span>

View File

@ -38,6 +38,8 @@ export function PlanActionsSection({
onReloadActors,
busy,
executionGraph = null,
sectionTitle = 'Arbeit',
sectionLead = 'Committete Arbeitspakete und zugehörige Aufgaben — Ausführung über die Objektseite.',
}) {
const [showCreate, setShowCreate] = useState(false)
const [expandedActions, setExpandedActions] = useState(() => new Set())
@ -73,10 +75,8 @@ export function PlanActionsSection({
<section className="card plan-actions-section">
<div className="section-header">
<div>
<h2>Arbeit</h2>
<p className="section-lead muted">
Committete Arbeitspakete und zugehörige Aufgaben — Ausführung über die Objektseite.
</p>
<h2>{sectionTitle}</h2>
<p className="section-lead muted">{sectionLead}</p>
</div>
<div className="section-actions">
<label className="checkbox-label inline-filter">

View File

@ -5,6 +5,7 @@ import {
} from '../constants/operating.js'
import { MILESTONE_STATUS_LABELS } from '../constants/status.js'
import { methodDescriptionForKey } from '../utils/archetypes.js'
import { ArchetypeSteeringHints } from './ArchetypeSteeringHints.jsx'
import { Link } from 'react-router-dom'
import { scopedPath } from '../utils/routes.js'
@ -106,6 +107,7 @@ export function SteeringSnapshotPanel({
{steering_guidance && (
<p className="steering-guidance">{steering_guidance}</p>
)}
<ArchetypeSteeringHints archetypeKey={archetype_key} />
</div>
<span className="badge status-badge status-active steering-lifecycle-badge">
{displayLabel}

View File

@ -0,0 +1,119 @@
import { useState } from 'react'
import { StatusBadge } from './StatusBadge.jsx'
import { EmptyState } from './EmptyState.jsx'
export function WorkCyclesPanel({
workCycles = [],
activeWorkCycle = null,
canManage,
onCreate,
onActivate,
busy = false,
}) {
const [title, setTitle] = useState('')
const [goal, setGoal] = useState('')
const [activateOnCreate, setActivateOnCreate] = useState(true)
async function handleSubmit(e) {
e.preventDefault()
const trimmed = title.trim()
if (!trimmed) return
await onCreate({
title: trimmed,
goal_description: goal.trim(),
status: activateOnCreate ? 'active' : 'planned',
})
setTitle('')
setGoal('')
}
return (
<section className="card work-cycles-panel">
<div className="section-header">
<div>
<h2>Sprint / Zeitbox</h2>
<p className="section-lead muted">
Aktive Zeitbox definiert den Sprint-Backlog — committete Actions mit Zeitbox-Zuweisung
erscheinen unter Ausführen → Sprint.
</p>
</div>
</div>
{activeWorkCycle ? (
<p className="work-cycle-active-banner">
Aktiv: <strong>{activeWorkCycle.title}</strong>
{activeWorkCycle.goal_description && (
<span className="muted"> — {activeWorkCycle.goal_description}</span>
)}
{typeof activeWorkCycle.open_action_count === 'number' && (
<span className="muted"> ({activeWorkCycle.open_action_count} offene APs)</span>
)}
</p>
) : (
<p className="muted">Keine aktive Zeitbox — alle committeten Actions gelten als Product-Ist.</p>
)}
{canManage && (
<form className="form inline-form-block" onSubmit={handleSubmit}>
<label>
Titel
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="z. B. Iteration R1"
maxLength={255}
required
/>
</label>
<label>
Ziel (optional)
<input
value={goal}
onChange={(e) => setGoal(e.target.value)}
placeholder="Sprint-Ziel"
/>
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={activateOnCreate}
onChange={(e) => setActivateOnCreate(e.target.checked)}
/>
Sofort aktivieren
</label>
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Anlegen …' : 'Zeitbox anlegen'}
</button>
</form>
)}
{workCycles.length === 0 ? (
<EmptyState message="Noch keine Zeitboxen angelegt." />
) : (
<ul className="item-list">
{workCycles.map((cycle) => (
<li key={cycle.id} className="list-item card-list-item work-cycle-row">
<div>
<strong>{cycle.title}</strong>{' '}
<StatusBadge status={cycle.status} />
{cycle.goal_description && (
<p className="muted item-meta">{cycle.goal_description}</p>
)}
</div>
{canManage && cycle.status !== 'active' && (
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={busy}
onClick={() => onActivate(cycle.id)}
>
Aktivieren
</button>
)}
</li>
))}
</ul>
)}
</section>
)
}

View File

@ -13,6 +13,7 @@ describe('controlNav', () => {
describe('workNav', () => {
it('resolves active work sub-route', () => {
expect(resolveWorkNavActiveKey('/work/sprint')).toBe('sprint')
expect(resolveWorkNavActiveKey('/work/today')).toBe('today')
expect(resolveWorkNavActiveKey('/work/mine')).toBe('mine')
})

View File

@ -7,6 +7,7 @@
/** @type {ModeNavItem[]} */
export const WORK_NAV_ITEMS = [
{ key: 'sprint', to: '/work/sprint', label: 'Sprint' },
{ key: 'today', to: '/work/today', label: 'Heute' },
{ key: 'mine', to: '/work/mine', label: 'Meine Queue' },
]

View File

@ -1,4 +1,10 @@
/** Fallback-Labels bis API geladen (AP1.10a / AP2.0). */
/** MVP Stufe A — geführte Referenz-Archetypen (AP2.2a). */
export const STUFE_A_ARCHETYPE_KEYS = new Set([
'initiative.maturity_journey',
'initiative.linear_project',
'initiative.product',
'initiative.program',
])
export const INITIATIVE_ARCHETYPE_LABELS = {
'initiative.generic': 'Allgemeines Vorhaben',
'initiative.maturity_journey': 'Reifegrad-Entwicklung',

View File

@ -71,6 +71,12 @@ import {
updateInitiativeSteeringMethod,
updateInitiativeMethodProfile,
} from '../api/steering.js'
import {
listInitiativeWorkCycles,
getActiveWorkCycle,
createWorkCycle,
activateWorkCycle,
} from '../api/workCycles.js'
import { useCapabilities } from '../hooks/useCapabilities.js'
import { useActors } from '../hooks/useActors.js'
import { useSession } from './SessionContext.jsx'
@ -98,6 +104,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
const [decisions, setDecisions] = useState([])
const [reviews, setReviews] = useState([])
const [recurringItems, setRecurringItems] = useState([])
const [workCycles, setWorkCycles] = useState([])
const [activeWorkCycle, setActiveWorkCycle] = useState(null)
const [steeringSnapshot, setSteeringSnapshot] = useState(null)
const [steeringSnapshotLoading, setSteeringSnapshotLoading] = useState(false)
const [steeringSnapshotError, setSteeringSnapshotError] = useState(null)
@ -135,6 +143,12 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([])))
loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([])))
loads.push(listInitiativeRecurring(id).then(setRecurringItems).catch(() => setRecurringItems([])))
loads.push(listInitiativeWorkCycles(id).then(setWorkCycles).catch(() => setWorkCycles([])))
loads.push(
getActiveWorkCycle(id)
.then((item) => setActiveWorkCycle(item || null))
.catch(() => setActiveWorkCycle(null))
)
loads.push(
listSteeringMethods().then(setSteeringMethods).catch(() => setSteeringMethods([]))
)
@ -186,6 +200,20 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
[actions, hideDone, selectedProjectId, projects]
)
const sprintActions = useMemo(() => {
if (!activeWorkCycle?.id) return []
const cycleId = activeWorkCycle.id
let list = hideDone
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
: actions
list = list.filter((a) => a.work_cycle_id === cycleId)
if (selectedProjectId) {
const subtreeIds = collectProjectSubtreeIds(projects, selectedProjectId)
list = list.filter((a) => a.project_id && subtreeIds.has(a.project_id))
}
return list
}, [actions, hideDone, activeWorkCycle?.id, selectedProjectId, projects])
const actionContextById = useMemo(
() => Object.fromEntries((steeringSnapshot?.actions || []).map((a) => [a.id, a])),
[steeringSnapshot]
@ -257,6 +285,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
clear_project: payload.project_id === '' || payload.project_id === null,
roadmap_item_id: payload.roadmap_item_id || undefined,
clear_roadmap_item: payload.roadmap_item_id === '' || payload.roadmap_item_id === null,
work_cycle_id: payload.work_cycle_id || undefined,
clear_work_cycle: payload.work_cycle_id === '' || payload.work_cycle_id === null,
})
if (capabilities.has('kairo.action.manage')) {
await setActionAssignments(actionId, payload.assigned_actor_ids || [])
@ -393,7 +423,31 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
async function handleConvertBacklog(itemId) {
setFormBusy(true)
try {
await convertBacklogToAction(itemId)
await convertBacklogToAction(itemId, { assign_active_sprint: true })
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleCreateWorkCycle(body) {
setFormBusy(true)
try {
await createWorkCycle(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleActivateWorkCycle(workCycleId) {
setFormBusy(true)
try {
await activateWorkCycle(id, workCycleId)
await load()
} catch (err) {
setError(err.message)
@ -727,6 +781,9 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
decisions,
reviews,
recurringItems,
workCycles,
activeWorkCycle,
sprintActions,
steeringSnapshot,
steeringSnapshotLoading,
steeringSnapshotError,
@ -764,6 +821,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
handleBacklogGate,
handleBacklogStatus,
handleConvertBacklog,
handleCreateWorkCycle,
handleActivateWorkCycle,
handleDeleteBacklog,
handleCreateRoadmapItem,
handleRoadmapItemStatus,

View File

@ -0,0 +1,111 @@
import { useMemo } from 'react'
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
import { WorkCyclesPanel } from '../../components/WorkCyclesPanel.jsx'
import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx'
import { LoadingState } from '../../components/LoadingState.jsx'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
function PlanSprintInner() {
const ops = useInitiativeOperations()
const {
initiativeId,
activeWorkCycle,
workCycles,
sprintActions,
projects,
roadmapItems,
capabilities,
hideDone,
setHideDone,
loading,
error,
formBusy,
showActionForm,
setShowActionForm,
editingAction,
setEditingAction,
actionContextById,
actors,
actorsLoading,
actorsError,
actorsUsedFallback,
reloadActors,
handleCreateWorkCycle,
handleActivateWorkCycle,
handleCreateAction,
handleUpdateAction,
handleQuickStatus,
handleCreateBlockerForAction,
} = ops
const createSprintAction = useMemo(
() => async (payload) => {
await handleCreateAction({
...payload,
work_cycle_id: payload.work_cycle_id || activeWorkCycle?.id || undefined,
})
},
[handleCreateAction, activeWorkCycle?.id],
)
if (loading) {
return <LoadingState message="Lade Sprint-Planung …" />
}
return (
<>
{error && <p className="error">{error}</p>}
<WorkCyclesPanel
workCycles={workCycles}
activeWorkCycle={activeWorkCycle}
canManage={capabilities.has('kairo.milestone.manage')}
onCreate={handleCreateWorkCycle}
onActivate={handleActivateWorkCycle}
busy={formBusy}
/>
{activeWorkCycle && (
<InitiativeActionsHub
initiativeId={initiativeId}
actions={sprintActions}
projects={projects}
roadmapItems={roadmapItems}
actionContextById={actionContextById}
hideDone={hideDone}
onHideDoneChange={setHideDone}
showForm={showActionForm}
onToggleForm={() => setShowActionForm((v) => !v)}
editingActionId={editingAction?.id}
onEditAction={setEditingAction}
onCancelEdit={() => setEditingAction(null)}
onCreateAction={createSprintAction}
onUpdateAction={handleUpdateAction}
onQuickStatus={handleQuickStatus}
onCreateBlockerForAction={handleCreateBlockerForAction}
canManage={capabilities.has('kairo.action.manage')}
canManageBlocker={capabilities.has('kairo.blocker.manage')}
formBusy={formBusy}
actors={actors}
actorsLoading={actorsLoading}
actorsError={actorsError}
actorsUsedFallback={actorsUsedFallback}
onReloadActors={reloadActors}
workCycles={workCycles}
activeWorkCycle={activeWorkCycle}
sectionTitle="Sprint-Backlog"
sectionLead="Arbeitspakete in der aktiven Zeitbox — committet aus dem Product Backlog (Eingang) oder direkt hier."
/>
)}
</>
)
}
export function PlanSprintPage() {
return (
<RequireInitiativeScope lead="Sprint-Zeitboxen gehören zu einem Vorhaben im Scope.">
<ScopedInitiativeProvider>
<PlanSprintInner />
</ScopedInitiativeProvider>
</RequireInitiativeScope>
)
}

View File

@ -75,6 +75,8 @@ function PlanWorkInner() {
onReloadActors={reloadActors}
busy={formBusy}
executionGraph={executionGraph}
sectionTitle="Alle Arbeitspakete"
sectionLead="Gesamt-Ist am Vorhaben — Sprint-fokussierte Arbeit unter Ausführen → Sprint."
/>
</>
)

View File

@ -1,64 +1,135 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { getActorWorkload } from '../../api/workspace.js'
import { createActor, listActors } from '../../api/actors.js'
import { useSession } from '../../context/SessionContext.jsx'
import { ModeShell } from '../../components/ModeShell.jsx'
import { LoadingState } from '../../components/LoadingState.jsx'
import { ErrorState } from '../../components/ErrorState.jsx'
import { scopedPath } from '../../utils/routes.js'
const ACTOR_TYPE_LABELS = {
human: 'Person',
agent: 'Agent / Vibe-Coder',
working_group: 'Gruppe',
external_system: 'Externes System',
}
export function TeamPage() {
const { hasCapability } = useSession()
const [rows, setRows] = useState(null)
const [actors, setActors] = useState([])
const [error, setError] = useState(null)
const [agentName, setAgentName] = useState('')
const [busy, setBusy] = useState(false)
async function load() {
setError(null)
try {
const [workload, directory] = await Promise.all([
getActorWorkload(),
listActors({ include_inactive: false }),
])
setRows(workload)
setActors(directory)
} catch (err) {
setError(err.message)
}
}
useEffect(() => {
let cancelled = false
getActorWorkload()
.then((data) => {
if (!cancelled) setRows(data)
})
.catch((err) => {
if (!cancelled) setError(err.message)
})
return () => {
cancelled = true
}
load()
}, [])
async function handleCreateAgent(e) {
e.preventDefault()
const name = agentName.trim()
if (!name) return
setBusy(true)
try {
await createActor({ name, actor_type: 'agent' })
setAgentName('')
await load()
} catch (err) {
setError(err.message)
} finally {
setBusy(false)
}
}
const canManage = hasCapability('kairo.actor.manage')
return (
<div className="app-page">
<ModeShell modeKey="team" />
{rows === null && !error && <LoadingState message="Lade Team-Auslastung …" />}
{error && <ErrorState message={error} />}
{rows === null && !error && <LoadingState message="Lade Team …" />}
{error && <ErrorState message={error} onRetry={load} />}
{rows && (
<section className="card">
<h2 className="card-title">Auslastung nach Actor</h2>
{rows.length === 0 ? (
<p className="muted">Keine Zuweisungen im Portfolio.</p>
) : (
<ul className="team-workload-list">
{rows.map((row) => (
<li key={row.actor_id} className="team-workload-list__item">
<div>
<strong>{row.actor_name || row.actor_id}</strong>
<span className="muted">
{' '}
· {row.open_actions ?? 0} offen
{(row.blocked_actions ?? 0) > 0
? ` · ${row.blocked_actions} blockiert`
: ''}
</span>
</div>
<Link to={scopedPath('/work/mine')} className="link-inline">
Queue ansehen
</Link>
</li>
))}
</ul>
)}
<p className="page-footer-link muted">
AP1.9b erweitert Team um Filter nach Vorhaben und direkte Zuweisungslinks.
</p>
</section>
<>
<section className="card">
<h2 className="card-title">Actor-Verzeichnis</h2>
{actors.length === 0 ? (
<p className="muted">Keine Actors im Tenant.</p>
) : (
<ul className="team-workload-list">
{actors.map((actor) => (
<li key={actor.id} className="team-workload-list__item">
<div>
<strong>{actor.name}</strong>
<span className="muted">
{' '}
· {ACTOR_TYPE_LABELS[actor.actor_type] || actor.actor_type}
</span>
</div>
</li>
))}
</ul>
)}
{canManage && (
<form className="form inline-form team-agent-form" onSubmit={handleCreateAgent}>
<label>
Agent anlegen (Vibe-Coder)
<input
value={agentName}
onChange={(e) => setAgentName(e.target.value)}
placeholder="z. B. Cursor Agent"
maxLength={255}
/>
</label>
<button type="submit" className="btn btn-primary btn-sm" disabled={busy}>
{busy ? 'Anlegen …' : 'Agent anlegen'}
</button>
</form>
)}
</section>
<section className="card">
<h2 className="card-title">Auslastung nach Actor</h2>
{rows.length === 0 ? (
<p className="muted">Keine Zuweisungen im Portfolio.</p>
) : (
<ul className="team-workload-list">
{rows.map((row) => (
<li key={row.actor_id} className="team-workload-list__item">
<div>
<strong>{row.display_name || row.actor_name || row.actor_id}</strong>
<span className="muted">
{' '}
· {row.open_actions ?? 0} offen
{(row.blocked_actions ?? 0) > 0
? ` · ${row.blocked_actions} blockiert`
: ''}
</span>
</div>
<Link to={scopedPath('/work/mine')} className="link-inline">
Queue ansehen
</Link>
</li>
))}
</ul>
)}
</section>
</>
)}
</div>
)

View File

@ -1,8 +1,12 @@
import { Navigate, Outlet } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { ModeAreaShell } from '../../components/ModeAreaShell.jsx'
import { ModeAreaNav } from '../../components/ModeAreaNav.jsx'
import { ModeShell } from '../../components/ModeShell.jsx'
import { WORK_NAV_ITEMS, resolveWorkNavActiveKey } from '../../config/workNav.js'
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
import { getActiveWorkCycle } from '../../api/workCycles.js'
import { getInitiative } from '../../api/initiatives.js'
export function WorkLayout() {
return (
@ -24,5 +28,34 @@ export function WorkLayout() {
}
export function WorkIndexRedirect() {
return <Navigate to="/work/today" replace />
const { initiativeId } = useProgramScope()
const [target, setTarget] = useState('/work/today')
useEffect(() => {
if (!initiativeId) {
setTarget('/work/today')
return undefined
}
let cancelled = false
Promise.all([getInitiative(initiativeId), getActiveWorkCycle(initiativeId)])
.then(([initiative, activeCycle]) => {
if (cancelled) return
if (
initiative?.archetype_key === 'initiative.product' &&
activeCycle?.id
) {
setTarget(`/work/sprint?initiative=${initiativeId}`)
} else {
setTarget(`/work/today?initiative=${initiativeId}`)
}
})
.catch(() => {
if (!cancelled) setTarget('/work/today')
})
return () => {
cancelled = true
}
}, [initiativeId])
return <Navigate to={target} replace />
}

View File

@ -0,0 +1,148 @@
import { useMemo } from 'react'
import { Link } from 'react-router-dom'
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx'
import { NextActionWidget } from '../../widgets/NextActionWidget.jsx'
import { LoadingState } from '../../components/LoadingState.jsx'
import { EmptyState } from '../../components/EmptyState.jsx'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
import { scopedPath } from '../../utils/routes.js'
function WorkSprintInner() {
const { initiativeId, hrefWithScope } = useProgramScope()
const ops = useInitiativeOperations()
const {
activeWorkCycle,
sprintActions,
projects,
roadmapItems,
capabilities,
hideDone,
setHideDone,
loading,
error,
formBusy,
showActionForm,
setShowActionForm,
editingAction,
setEditingAction,
actionContextById,
actors,
actorsLoading,
actorsError,
actorsUsedFallback,
reloadActors,
handleCreateAction,
handleUpdateAction,
handleQuickStatus,
handleCreateBlockerForAction,
initiative,
steeringSnapshot,
steeringSnapshotLoading,
} = ops
const defaultWorkCycleId = activeWorkCycle?.id || ''
const createAction = useMemo(
() => async (payload) => {
await handleCreateAction({
...payload,
work_cycle_id: payload.work_cycle_id || defaultWorkCycleId || undefined,
})
},
[handleCreateAction, defaultWorkCycleId],
)
if (loading) {
return <LoadingState message="Lade Sprint-Backlog …" />
}
const isProductArchetype = initiative?.archetype_key === 'initiative.product'
return (
<>
{error && <p className="error">{error}</p>}
{!activeWorkCycle && isProductArchetype && (
<section className="card">
<EmptyState
message="Kein aktiver Sprint — lege unter Plan → Sprint eine Zeitbox an und aktiviere sie."
/>
<p>
<Link to={hrefWithScope('/plan/sprint')} className="link-inline">
Zur Sprint-Planung
</Link>
</p>
</section>
)}
{activeWorkCycle && (
<>
<section className="card sprint-context-banner">
<h2>Sprint-Backlog</h2>
<p className="section-lead muted">
<strong>{activeWorkCycle.title}</strong>
{activeWorkCycle.goal_description && ` — ${activeWorkCycle.goal_description}`}
{' · '}
Product Backlog triagierst du unter{' '}
<Link to={scopedPath('/plan/inbox', { initiativeId })} className="link-inline">
Plan → Eingang
</Link>
.
</p>
</section>
<NextActionWidget
scope="initiative"
initiativeId={initiativeId}
items={steeringSnapshot?.next_actions}
loading={steeringSnapshotLoading}
embedded
title="Nächster Schritt im Sprint"
/>
<InitiativeActionsHub
initiativeId={initiativeId}
actions={sprintActions}
projects={projects}
roadmapItems={roadmapItems}
actionContextById={actionContextById}
hideDone={hideDone}
onHideDoneChange={setHideDone}
showForm={showActionForm}
onToggleForm={() => setShowActionForm((v) => !v)}
editingActionId={editingAction?.id}
onEditAction={setEditingAction}
onCancelEdit={() => setEditingAction(null)}
onCreateAction={createAction}
onUpdateAction={handleUpdateAction}
onQuickStatus={handleQuickStatus}
onCreateBlockerForAction={handleCreateBlockerForAction}
canManage={capabilities.has('kairo.action.manage')}
canManageBlocker={capabilities.has('kairo.blocker.manage')}
formBusy={formBusy}
actors={actors}
actorsLoading={actorsLoading}
actorsError={actorsError}
actorsUsedFallback={actorsUsedFallback}
onReloadActors={reloadActors}
workCycles={ops.workCycles}
activeWorkCycle={activeWorkCycle}
/>
</>
)}
</>
)
}
export function WorkSprintPage() {
return (
<RequireInitiativeScope lead="Sprint-Backlog ist vorhabenspezifisch — wähle ein Product-Vorhaben im Scope.">
<ScopedInitiativeProvider>
<WorkSprintInner />
</ScopedInitiativeProvider>
</RequireInitiativeScope>
)
}

View File

@ -11,6 +11,7 @@ export const PLAN_OUTLINE_NODES = [
{ key: 'structure', to: '/plan/structure', label: 'Struktur', requiresInitiative: true },
{ key: 'gates', to: '/plan/gates', label: 'Zielzustände', requiresInitiative: true },
{ key: 'inbox', to: '/plan/inbox', label: 'Eingang', requiresInitiative: true },
{ key: 'sprint', to: '/plan/sprint', label: 'Sprint', requiresInitiative: true },
{ key: 'work', to: '/plan/work', label: 'Arbeit', requiresInitiative: true },
]

View File

@ -13,6 +13,7 @@ describe('planOutlineNodes', () => {
'structure',
'gates',
'inbox',
'sprint',
'work',
])
})
@ -22,6 +23,7 @@ describe('planOutlineNodes', () => {
expect(resolvePlanOutlineActiveKey('/plan/structure')).toBe('structure')
expect(resolvePlanOutlineActiveKey('/plan/gates')).toBe('gates')
expect(resolvePlanOutlineActiveKey('/plan/inbox')).toBe('inbox')
expect(resolvePlanOutlineActiveKey('/plan/sprint')).toBe('sprint')
expect(resolvePlanOutlineActiveKey('/plan/work')).toBe('work')
expect(resolvePlanOutlineActiveKey('/plan/portfolio')).toBe(null)
expect(resolvePlanOutlineActiveKey('/projects/abc-123')).toBe('structure')

View File

@ -2,11 +2,13 @@ import { CockpitPage } from '../pages/modes/CockpitPage.jsx'
import { WorkLayout, WorkIndexRedirect } from '../pages/modes/WorkLayout.jsx'
import { WorkTodayPage } from '../pages/modes/WorkTodayPage.jsx'
import { WorkMinePage } from '../pages/modes/WorkMinePage.jsx'
import { WorkSprintPage } from '../pages/modes/WorkSprintPage.jsx'
import { PlanLayout, PlanIndexRedirect } from '../pages/modes/PlanLayout.jsx'
import { PlanStructurePage } from '../pages/modes/PlanStructurePage.jsx'
import { PlanGatesPage } from '../pages/modes/PlanGatesPage.jsx'
import { PlanInboxPage } from '../pages/modes/PlanInboxPage.jsx'
import { PlanWorkPage } from '../pages/modes/PlanWorkPage.jsx'
import { PlanSprintPage } from '../pages/modes/PlanSprintPage.jsx'
import { PlanProfilePage } from '../pages/modes/PlanProfilePage.jsx'
import { PlanPortfolioPage } from '../pages/modes/PlanPortfolioPage.jsx'
import { ControlLayout, ControlIndexRedirect } from '../pages/modes/ControlLayout.jsx'
@ -129,11 +131,13 @@ export function getViewByKey(key) {
export const MODE_ROUTE_COMPONENTS = {
workToday: WorkTodayPage,
workMine: WorkMinePage,
workSprint: WorkSprintPage,
planPortfolio: PlanPortfolioPage,
planProfile: PlanProfilePage,
planStructure: PlanStructurePage,
planGates: PlanGatesPage,
planInbox: PlanInboxPage,
planSprint: PlanSprintPage,
planWork: PlanWorkPage,
controlStatus: ControlStatusPage,
controlPlanIst: ControlPlanIstPage,