From 0b57e2ec7598829f2be1b4cc521bca94c10218cd Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 12 Jul 2026 16:19:55 +0200 Subject: [PATCH] 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 --- backend/routers/actors.py | 25 +++ backend/services/archetype_starter_kit.py | 2 + backend/services/maturity_stage_transition.py | 87 ++++++++++ backend/services/roadmap.py | 11 ++ .../next_action/maturity_progression.py | 39 +++-- .../next_action/recurring_control.py | 42 +---- .../next_action/recurring_helpers.py | 82 +++++++++ .../tests/test_ap20e_maturity_transition.py | 62 +++++++ backend/tests/test_ap22_actor_create.py | 18 ++ backend/version.py | 2 +- docs/product/Kairo_MVP_Execution_Plan_v0.2.md | 8 +- frontend/package.json | 2 +- frontend/src/api/actors.js | 7 + .../src/components/ArchetypeSteeringHints.jsx | 35 ++++ frontend/src/components/InitiativeForm.jsx | 27 ++- .../src/components/SteeringSnapshotPanel.jsx | 2 + .../src/constants/initiativeArchetypes.js | 8 +- frontend/src/pages/modes/TeamPage.jsx | 155 +++++++++++++----- 18 files changed, 507 insertions(+), 107 deletions(-) create mode 100644 backend/services/maturity_stage_transition.py create mode 100644 backend/steering/strategies/next_action/recurring_helpers.py create mode 100644 backend/tests/test_ap20e_maturity_transition.py create mode 100644 backend/tests/test_ap22_actor_create.py create mode 100644 frontend/src/components/ArchetypeSteeringHints.jsx diff --git a/backend/routers/actors.py b/backend/routers/actors.py index d0a6ee1..ae2e6da 100644 --- a/backend/routers/actors.py +++ b/backend/routers/actors.py @@ -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, diff --git a/backend/services/archetype_starter_kit.py b/backend/services/archetype_starter_kit.py index 316caec..e2964f4 100644 --- a/backend/services/archetype_starter_kit.py +++ b/backend/services/archetype_starter_kit.py @@ -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, ) diff --git a/backend/services/maturity_stage_transition.py b/backend/services/maturity_stage_transition.py new file mode 100644 index 0000000..7f0f0db --- /dev/null +++ b/backend/services/maturity_stage_transition.py @@ -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, + } diff --git a/backend/services/roadmap.py b/backend/services/roadmap.py index bd6f4a6..ddd8036 100644 --- a/backend/services/roadmap.py +++ b/backend/services/roadmap.py @@ -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 diff --git a/backend/steering/strategies/next_action/maturity_progression.py b/backend/steering/strategies/next_action/maturity_progression.py index 6cc9485..de7b7bc 100644 --- a/backend/steering/strategies/next_action/maturity_progression.py +++ b/backend/steering/strategies/next_action/maturity_progression.py @@ -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] diff --git a/backend/steering/strategies/next_action/recurring_control.py b/backend/steering/strategies/next_action/recurring_control.py index ee3822b..1b0eadc 100644 --- a/backend/steering/strategies/next_action/recurring_control.py +++ b/backend/steering/strategies/next_action/recurring_control.py @@ -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: diff --git a/backend/steering/strategies/next_action/recurring_helpers.py b/backend/steering/strategies/next_action/recurring_helpers.py new file mode 100644 index 0000000..b265cdc --- /dev/null +++ b/backend/steering/strategies/next_action/recurring_helpers.py @@ -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() diff --git a/backend/tests/test_ap20e_maturity_transition.py b/backend/tests/test_ap20e_maturity_transition.py new file mode 100644 index 0000000..7230747 --- /dev/null +++ b/backend/tests/test_ap20e_maturity_transition.py @@ -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" diff --git a/backend/tests/test_ap22_actor_create.py b/backend/tests/test_ap22_actor_create.py new file mode 100644 index 0000000..41fc00d --- /dev/null +++ b/backend/tests/test_ap22_actor_create.py @@ -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" diff --git a/backend/version.py b/backend/version.py index 8698266..0c74c91 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.20.0-ap2.2a" +APP_VERSION = "0.20.1-ap2.2bc" DB_SCHEMA_VERSION = "025" APP_NAME = "jinkendo-kairo" diff --git a/docs/product/Kairo_MVP_Execution_Plan_v0.2.md b/docs/product/Kairo_MVP_Execution_Plan_v0.2.md index 583b695..cbed10a 100644 --- a/docs/product/Kairo_MVP_Execution_Plan_v0.2.md +++ b/docs/product/Kairo_MVP_Execution_Plan_v0.2.md @@ -71,10 +71,10 @@ 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 +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 diff --git a/frontend/package.json b/frontend/package.json index 5055ae1..091a44c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "kairo-jinkendo-frontend", - "version": "0.20.0-ap2.2a", + "version": "0.20.1-ap2.2bc", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/actors.js b/frontend/src/api/actors.js index 7b18f1b..78d7e78 100644 --- a/frontend/src/api/actors.js +++ b/frontend/src/api/actors.js @@ -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 [] diff --git a/frontend/src/components/ArchetypeSteeringHints.jsx b/frontend/src/components/ArchetypeSteeringHints.jsx new file mode 100644 index 0000000..26c9f61 --- /dev/null +++ b/frontend/src/components/ArchetypeSteeringHints.jsx @@ -0,0 +1,35 @@ +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, committete Actions — optional Sprint-Zeitbox.', + link: { to: '/plan/inbox', label: 'Zum Eingang' }, + }, + '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 ( +

+ {hint.text}{' '} + + {hint.link.label} + +

+ ) +} diff --git a/frontend/src/components/InitiativeForm.jsx b/frontend/src/components/InitiativeForm.jsx index 910b967..7fd0794 100644 --- a/frontend/src/components/InitiativeForm.jsx +++ b/frontend/src/components/InitiativeForm.jsx @@ -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 (
@@ -146,11 +148,24 @@ export function InitiativeForm({ onChange={(e) => setArchetypeKey(e.target.value)} disabled={!archetypes} > - {(archetypes || []).map((item) => ( - - ))} + {stufeA.length > 0 && ( + + {stufeA.map((item) => ( + + ))} + + )} + {otherArchetypes.length > 0 && ( + + {otherArchetypes.map((item) => ( + + ))} + + )} {selectedArchetype?.description && ( {selectedArchetype.description} diff --git a/frontend/src/components/SteeringSnapshotPanel.jsx b/frontend/src/components/SteeringSnapshotPanel.jsx index 278abc7..b7675f1 100644 --- a/frontend/src/components/SteeringSnapshotPanel.jsx +++ b/frontend/src/components/SteeringSnapshotPanel.jsx @@ -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 && (

{steering_guidance}

)} + {displayLabel} diff --git a/frontend/src/constants/initiativeArchetypes.js b/frontend/src/constants/initiativeArchetypes.js index 8ff6912..d0a6329 100644 --- a/frontend/src/constants/initiativeArchetypes.js +++ b/frontend/src/constants/initiativeArchetypes.js @@ -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', diff --git a/frontend/src/pages/modes/TeamPage.jsx b/frontend/src/pages/modes/TeamPage.jsx index 2d98027..1ddbda0 100644 --- a/frontend/src/pages/modes/TeamPage.jsx +++ b/frontend/src/pages/modes/TeamPage.jsx @@ -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 (
- {rows === null && !error && } - {error && } + {rows === null && !error && } + {error && } {rows && ( -
-

Auslastung nach Actor

- {rows.length === 0 ? ( -

Keine Zuweisungen im Portfolio.

- ) : ( -
    - {rows.map((row) => ( -
  • -
    - {row.actor_name || row.actor_id} - - {' '} - · {row.open_actions ?? 0} offen - {(row.blocked_actions ?? 0) > 0 - ? ` · ${row.blocked_actions} blockiert` - : ''} - -
    - - Queue ansehen - -
  • - ))} -
- )} -

- AP1.9b erweitert Team um Filter nach Vorhaben und direkte Zuweisungslinks. -

-
+ <> +
+

Actor-Verzeichnis

+ {actors.length === 0 ? ( +

Keine Actors im Tenant.

+ ) : ( +
    + {actors.map((actor) => ( +
  • +
    + {actor.name} + + {' '} + · {ACTOR_TYPE_LABELS[actor.actor_type] || actor.actor_type} + +
    +
  • + ))} +
+ )} + {canManage && ( + + + + + )} +
+ +
+

Auslastung nach Actor

+ {rows.length === 0 ? ( +

Keine Zuweisungen im Portfolio.

+ ) : ( +
    + {rows.map((row) => ( +
  • +
    + {row.display_name || row.actor_name || row.actor_id} + + {' '} + · {row.open_actions ?? 0} offen + {(row.blocked_actions ?? 0) > 0 + ? ` · ${row.blocked_actions} blockiert` + : ''} + +
    + + Queue ansehen + +
  • + ))} +
+ )} +
+ )}
)