AP0.7: Tenant Hardening, Actor Directory und Data Layer Minimum
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 42s
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 12s
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 42s
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 12s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
36487d2869
commit
db06af0489
27
README.md
27
README.md
|
|
@ -147,7 +147,21 @@ Vorhaben und Maßnahmen (AP0.5 minimaler Slice):
|
||||||
| `/api/actions/{id}` | PATCH | `kairo.action.manage` | Maßnahme bearbeiten / Status |
|
| `/api/actions/{id}` | PATCH | `kairo.action.manage` | Maßnahme bearbeiten / Status |
|
||||||
| `/api/actions/{id}/assignments` | PUT | `kairo.action.manage` | Actors zuweisen (ersetzt Liste) |
|
| `/api/actions/{id}/assignments` | PUT | `kairo.action.manage` | Actors zuweisen (ersetzt Liste) |
|
||||||
| `/api/actions/{id}` | DELETE | `kairo.action.manage` | Maßnahme löschen |
|
| `/api/actions/{id}` | DELETE | `kairo.action.manage` | Maßnahme löschen |
|
||||||
| `/api/actions/me/open` | GET | `kairo.action.read` | Offene Maßnahmen des aktuellen Actors |
|
| `/api/actions/me/open` | GET | `kairo.action.read` | Offene Maßnahmen des aktuellen Actors (via Data Layer) |
|
||||||
|
|
||||||
|
Actor Directory und Workspace Data Layer (AP0.7):
|
||||||
|
|
||||||
|
| Endpoint | Methode | Capability | Beschreibung |
|
||||||
|
|----------|---------|------------|--------------|
|
||||||
|
| `/api/actors` | GET | `kairo.actor.read` | Actors des aktiven Tenants (Filter: `actor_type`, `q`, `include_inactive`) |
|
||||||
|
| `/api/actors/{id}` | GET | `kairo.actor.read` | Einzelner Actor (404 cross-tenant) |
|
||||||
|
| `/api/workspace/summary` | GET | `kairo.workspace.read` | Workspace-Kennzahlen |
|
||||||
|
| `/api/workspace/actions/open` | GET | `kairo.workspace.read` | Meine offenen Maßnahmen (open/in_progress) |
|
||||||
|
| `/api/workspace/actions/blocked` | GET | `kairo.workspace.read` | Meine blockierten Maßnahmen |
|
||||||
|
| `/api/workspace/initiatives/active` | GET | `kairo.workspace.read` | Aktive/pausierte Vorhaben (`?limit=`) |
|
||||||
|
| `/api/workspace/actors/workload` | GET | `kairo.workspace.read` | Actor-Workload (Basic) |
|
||||||
|
|
||||||
|
Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
|
||||||
|
|
||||||
**Status Vorhaben:** `active`, `paused`, `completed`, `archived`
|
**Status Vorhaben:** `active`, `paused`, `completed`, `archived`
|
||||||
|
|
||||||
|
|
@ -176,7 +190,7 @@ Nach Login leitet Kairo auf den **Workspace** weiter.
|
||||||
|
|
||||||
| Route | Beschreibung |
|
| Route | Beschreibung |
|
||||||
|-------|--------------|
|
|-------|--------------|
|
||||||
| `/workspace` | Karten: Kontext, offene Maßnahmen, blockierte Maßnahmen, aktive Vorhaben |
|
| `/workspace` | Karten: Kontext, Überblick, offene/blockierte Maßnahmen, aktive Vorhaben |
|
||||||
| `/initiatives` | Vorhabenliste mit offenen Maßnahmen-Zähler |
|
| `/initiatives` | Vorhabenliste mit offenen Maßnahmen-Zähler |
|
||||||
| `/initiatives/:id` | Vorhaben-Detail mit Maßnahmen CRUD, Status, Zuweisung |
|
| `/initiatives/:id` | Vorhaben-Detail mit Maßnahmen CRUD, Status, Zuweisung |
|
||||||
| `/my-actions` | Alle offenen Maßnahmen des aktuellen Actors |
|
| `/my-actions` | Alle offenen Maßnahmen des aktuellen Actors |
|
||||||
|
|
@ -203,10 +217,9 @@ cd frontend && npm install && npm run test && npm run build
|
||||||
4. Prüfen: Maßnahme verschwindet aus „Meine offenen Maßnahmen“
|
4. Prüfen: Maßnahme verschwindet aus „Meine offenen Maßnahmen“
|
||||||
5. Responsive: DevTools Viewports **1440px**, **1024px**, **390px** — keine horizontale Scrollbar, Bottom-Nav sichtbar unter 1024px
|
5. Responsive: DevTools Viewports **1440px**, **1024px**, **390px** — keine horizontale Scrollbar, Bottom-Nav sichtbar unter 1024px
|
||||||
6. PWA: Manifest unter `/manifest.webmanifest` erreichbar; „App installieren“ im Browser prüfbar
|
6. PWA: Manifest unter `/manifest.webmanifest` erreichbar; „App installieren“ im Browser prüfbar
|
||||||
|
7. AP0.7: ActorSelect lädt `/api/actors`; Maßnahme einem Agent zuweisen; Workspace-Überblick sichtbar
|
||||||
|
|
||||||
**Bekannte Lücke:** Kein `GET /api/actors` — Zuweisung nutzt aktuell den Human Actor aus TenantContext (AP0.7).
|
Abschlussberichte: `docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md`, `docs/sprints/Sprint0_AP0_6b_Completion_Report_v0.2.md`, `docs/sprints/Sprint0_AP0_7_Completion_Report_v0.1.md`
|
||||||
|
|
||||||
Abschlussberichte: `docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md`, `docs/sprints/Sprint0_AP0_6b_Completion_Report_v0.2.md`
|
|
||||||
|
|
||||||
### Registries (AP0.4)
|
### Registries (AP0.4)
|
||||||
|
|
||||||
|
|
@ -260,7 +273,9 @@ Registry-first: Capabilities werden in `backend/rights_registrations/` registrie
|
||||||
|------------|-------|------------------|
|
|------------|-------|------------------|
|
||||||
| `kairo.admin.access` | platform | Portal-Administration |
|
| `kairo.admin.access` | platform | Portal-Administration |
|
||||||
| `kairo.tenant.manage` | tenant | Tenant-Verwaltung |
|
| `kairo.tenant.manage` | tenant | Tenant-Verwaltung |
|
||||||
| `kairo.actor.manage` | tenant | Actor-Verwaltung |
|
| `kairo.actor.read` | tenant | Actors im Tenant lesen (Directory) |
|
||||||
|
| `kairo.actor.manage` | tenant | Actors verwalten |
|
||||||
|
| `kairo.workspace.read` | workspace | Workspace Read-Models / Data Layer |
|
||||||
| `kairo.initiative.read` | initiative | Vorhaben lesen |
|
| `kairo.initiative.read` | initiative | Vorhaben lesen |
|
||||||
| `kairo.initiative.manage` | initiative | Vorhaben verwalten |
|
| `kairo.initiative.manage` | initiative | Vorhaben verwalten |
|
||||||
| `kairo.action.read` | action | Maßnahmen lesen |
|
| `kairo.action.read` | action | Maßnahmen lesen |
|
||||||
|
|
|
||||||
1
backend/data_layer/__init__.py
Normal file
1
backend/data_layer/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Kairo Data Layer — read-oriented aggregation for workspace and dashboards."""
|
||||||
85
backend/data_layer/actions.py
Normal file
85
backend/data_layer/actions.py
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
"""Action read-models for workspace — tenant-scoped, actor-assignment based."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
from db import get_connection
|
||||||
|
from services.actions import OPEN_ACTION_STATUSES, _attach_assignments, _serialize_row
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
|
||||||
|
def _require_actor(ctx: TenantContext) -> str:
|
||||||
|
if not ctx.actor_id:
|
||||||
|
raise ValueError("Kein Actor im TenantContext")
|
||||||
|
return ctx.actor_id
|
||||||
|
|
||||||
|
|
||||||
|
def _list_assigned_actions(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
actor_id: str,
|
||||||
|
statuses: list[str],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT a.id, a.tenant_id, a.initiative_id, a.title, a.description,
|
||||||
|
a.status, a.priority, a.created_at, a.updated_at,
|
||||||
|
i.title AS initiative_title
|
||||||
|
FROM actions a
|
||||||
|
JOIN action_assignments aa
|
||||||
|
ON aa.action_id = a.id AND aa.tenant_id = a.tenant_id
|
||||||
|
JOIN initiatives i
|
||||||
|
ON i.id = a.initiative_id AND i.tenant_id = a.tenant_id
|
||||||
|
WHERE a.tenant_id = %s
|
||||||
|
AND aa.actor_id = %s
|
||||||
|
AND a.status = ANY(%s)
|
||||||
|
ORDER BY
|
||||||
|
CASE a.priority
|
||||||
|
WHEN 'high' THEN 0
|
||||||
|
WHEN 'normal' THEN 1
|
||||||
|
WHEN 'low' THEN 2
|
||||||
|
END,
|
||||||
|
a.updated_at DESC
|
||||||
|
""",
|
||||||
|
(tenant_id, actor_id, statuses),
|
||||||
|
)
|
||||||
|
actions = [_serialize_row(dict(row)) for row in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return _attach_assignments(actions, tenant_id=tenant_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_my_open_actions(ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
|
"""Open + in_progress actions assigned to current actor (excludes blocked)."""
|
||||||
|
actor_id = _require_actor(ctx)
|
||||||
|
return _list_assigned_actions(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
actor_id=actor_id,
|
||||||
|
statuses=["open", "in_progress"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_my_blocked_actions(ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
|
"""Blocked actions assigned to current actor."""
|
||||||
|
actor_id = _require_actor(ctx)
|
||||||
|
return _list_assigned_actions(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
actor_id=actor_id,
|
||||||
|
statuses=["blocked"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_my_open_actions(ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
|
"""All non-terminal open actions (open, in_progress, blocked) — legacy compat."""
|
||||||
|
actor_id = _require_actor(ctx)
|
||||||
|
return _list_assigned_actions(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
actor_id=actor_id,
|
||||||
|
statuses=list(OPEN_ACTION_STATUSES),
|
||||||
|
)
|
||||||
61
backend/data_layer/actors.py
Normal file
61
backend/data_layer/actors.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""Actor workload read-models — tenant-scoped aggregation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
from db import get_connection
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
|
||||||
|
def get_actor_workload(ctx: TenantContext) -> list[dict[str, Any]]:
|
||||||
|
"""Open/blocked/in_progress action counts per active actor in tenant."""
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
a.id AS actor_id,
|
||||||
|
a.name AS display_name,
|
||||||
|
a.actor_type,
|
||||||
|
COUNT(*) FILTER (WHERE act.status = 'open') AS open_actions,
|
||||||
|
COUNT(*) FILTER (WHERE act.status = 'blocked') AS blocked_actions,
|
||||||
|
COUNT(*) FILTER (WHERE act.status = 'in_progress') AS in_progress_actions
|
||||||
|
FROM actors a
|
||||||
|
LEFT JOIN action_assignments aa
|
||||||
|
ON aa.actor_id = a.id AND aa.tenant_id = a.tenant_id
|
||||||
|
LEFT JOIN actions act
|
||||||
|
ON act.id = aa.action_id
|
||||||
|
AND act.tenant_id = aa.tenant_id
|
||||||
|
AND act.status IN ('open', 'in_progress', 'blocked')
|
||||||
|
WHERE a.tenant_id = %s AND a.is_active = TRUE
|
||||||
|
GROUP BY a.id, a.name, a.actor_type
|
||||||
|
ORDER BY
|
||||||
|
CASE a.actor_type
|
||||||
|
WHEN 'human' THEN 0
|
||||||
|
WHEN 'working_group' THEN 1
|
||||||
|
WHEN 'agent' THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END,
|
||||||
|
a.name
|
||||||
|
""",
|
||||||
|
(ctx.tenant_id,),
|
||||||
|
)
|
||||||
|
result = []
|
||||||
|
for row in cur.fetchall():
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"actor_id": str(row["actor_id"]),
|
||||||
|
"display_name": row["display_name"],
|
||||||
|
"actor_type": row["actor_type"],
|
||||||
|
"open_actions": int(row["open_actions"] or 0),
|
||||||
|
"blocked_actions": int(row["blocked_actions"] or 0),
|
||||||
|
"in_progress_actions": int(row["in_progress_actions"] or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
37
backend/data_layer/initiatives.py
Normal file
37
backend/data_layer/initiatives.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""Initiative read-models for workspace."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
from db import get_connection
|
||||||
|
from services.initiatives import _serialize_row
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_initiatives(
|
||||||
|
ctx: TenantContext,
|
||||||
|
*,
|
||||||
|
limit: Optional[int] = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Active and paused initiatives in the current tenant."""
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
sql = """
|
||||||
|
SELECT id, tenant_id, title, goal, status, priority,
|
||||||
|
owner_actor_id, created_at, updated_at
|
||||||
|
FROM initiatives
|
||||||
|
WHERE tenant_id = %s AND status IN ('active', 'paused')
|
||||||
|
ORDER BY updated_at DESC, title
|
||||||
|
"""
|
||||||
|
params: list[Any] = [ctx.tenant_id]
|
||||||
|
if limit is not None:
|
||||||
|
sql += " LIMIT %s"
|
||||||
|
params.append(limit)
|
||||||
|
cur.execute(sql, params)
|
||||||
|
return [_serialize_row(dict(row)) for row in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
70
backend/data_layer/workspace.py
Normal file
70
backend/data_layer/workspace.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
"""Workspace summary read-model — tenant-scoped KPIs for current actor."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
||||||
|
from db import get_connection
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
|
||||||
|
def get_workspace_summary(ctx: TenantContext) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Summary for workspace dashboard.
|
||||||
|
|
||||||
|
Personal counts (current actor): open, blocked, recently done.
|
||||||
|
Tenant-wide: active initiatives count.
|
||||||
|
"""
|
||||||
|
if not ctx.actor_id:
|
||||||
|
return {
|
||||||
|
"open_actions_count": 0,
|
||||||
|
"blocked_actions_count": 0,
|
||||||
|
"active_initiatives_count": 0,
|
||||||
|
"done_actions_recent_count": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE a.status IN ('open', 'in_progress')
|
||||||
|
) AS open_actions_count,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE a.status = 'blocked'
|
||||||
|
) AS blocked_actions_count,
|
||||||
|
COUNT(*) FILTER (
|
||||||
|
WHERE a.status = 'done'
|
||||||
|
AND a.updated_at >= NOW() - INTERVAL '7 days'
|
||||||
|
) AS done_actions_recent_count
|
||||||
|
FROM actions a
|
||||||
|
JOIN action_assignments aa
|
||||||
|
ON aa.action_id = a.id AND aa.tenant_id = a.tenant_id
|
||||||
|
WHERE a.tenant_id = %s AND aa.actor_id = %s
|
||||||
|
""",
|
||||||
|
(ctx.tenant_id, ctx.actor_id),
|
||||||
|
)
|
||||||
|
row = dict(cur.fetchone())
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS active_initiatives_count
|
||||||
|
FROM initiatives
|
||||||
|
WHERE tenant_id = %s AND status IN ('active', 'paused')
|
||||||
|
""",
|
||||||
|
(ctx.tenant_id,),
|
||||||
|
)
|
||||||
|
initiatives_row = dict(cur.fetchone())
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"open_actions_count": int(row["open_actions_count"] or 0),
|
||||||
|
"blocked_actions_count": int(row["blocked_actions_count"] or 0),
|
||||||
|
"active_initiatives_count": int(initiatives_row["active_initiatives_count"] or 0),
|
||||||
|
"done_actions_recent_count": int(row["done_actions_recent_count"] or 0),
|
||||||
|
}
|
||||||
|
|
@ -53,7 +53,7 @@ app.add_middleware(
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
from routers import actions, auth, config, features, initiatives, me, prompts # noqa: E402
|
from routers import actions, actors, auth, config, features, initiatives, me, prompts, workspace # noqa: E402
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(me.router)
|
app.include_router(me.router)
|
||||||
|
|
@ -62,6 +62,8 @@ app.include_router(prompts.router)
|
||||||
app.include_router(config.router)
|
app.include_router(config.router)
|
||||||
app.include_router(initiatives.router)
|
app.include_router(initiatives.router)
|
||||||
app.include_router(actions.router)
|
app.include_router(actions.router)
|
||||||
|
app.include_router(actors.router)
|
||||||
|
app.include_router(workspace.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
"""Import all module registrations — side effect registers capabilities."""
|
"""Import all module registrations — side effect registers capabilities."""
|
||||||
|
|
||||||
from . import initiative_ops, platform, registry_ops, tenant_ops # noqa: F401
|
from . import initiative_ops, platform, registry_ops, tenant_ops, workspace_ops # noqa: F401
|
||||||
|
|
||||||
__all__ = ["initiative_ops", "platform", "registry_ops", "tenant_ops"]
|
__all__ = ["initiative_ops", "platform", "registry_ops", "tenant_ops", "workspace_ops"]
|
||||||
|
|
|
||||||
35
backend/rights_registrations/workspace_ops.py
Normal file
35
backend/rights_registrations/workspace_ops.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""Workspace read capabilities (AP0.7)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from rights_registry import CapabilityRegistration, register_capability
|
||||||
|
|
||||||
|
register_capability(
|
||||||
|
CapabilityRegistration(
|
||||||
|
key="kairo.actor.read",
|
||||||
|
module="tenant",
|
||||||
|
description="Actors im aktiven Tenant lesen (Directory)",
|
||||||
|
default_grants=(
|
||||||
|
("portal", "admin"),
|
||||||
|
("portal", "user"),
|
||||||
|
("tenant", "owner"),
|
||||||
|
("tenant", "admin"),
|
||||||
|
("tenant", "member"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
register_capability(
|
||||||
|
CapabilityRegistration(
|
||||||
|
key="kairo.workspace.read",
|
||||||
|
module="workspace",
|
||||||
|
description="Workspace-Übersichten und aggregierte Read-Models lesen",
|
||||||
|
default_grants=(
|
||||||
|
("portal", "admin"),
|
||||||
|
("portal", "user"),
|
||||||
|
("tenant", "owner"),
|
||||||
|
("tenant", "admin"),
|
||||||
|
("tenant", "member"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
"""Action (Maßnahme) API — AP0.5."""
|
"""Action (Maßnahme) API — AP0.5 / AP0.7."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from capabilities import require_capability
|
from capabilities import require_capability
|
||||||
|
from data_layer import actions as dl_actions
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from services import actions as action_service
|
from services import actions as action_service
|
||||||
|
|
@ -28,12 +29,10 @@ class ActionAssignmentsRequest(BaseModel):
|
||||||
def list_my_open_actions(
|
def list_my_open_actions(
|
||||||
ctx: TenantContext = Depends(require_capability("kairo.action.read")),
|
ctx: TenantContext = Depends(require_capability("kairo.action.read")),
|
||||||
):
|
):
|
||||||
if not ctx.actor_id:
|
try:
|
||||||
raise HTTPException(status_code=400, detail="Kein Actor im TenantContext")
|
return dl_actions.get_all_my_open_actions(ctx)
|
||||||
return action_service.list_open_actions_for_actor(
|
except ValueError as exc:
|
||||||
tenant_id=ctx.tenant_id,
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
actor_id=ctx.actor_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{action_id}")
|
@router.get("/{action_id}")
|
||||||
|
|
|
||||||
41
backend/routers/actors.py
Normal file
41
backend/routers/actors.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""Actor directory API — AP0.7."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from capabilities import require_capability
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from services import actors as actor_service
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/actors", tags=["actors"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_actors(
|
||||||
|
actor_type: Optional[Literal["human", "agent", "working_group", "external_system"]] = None,
|
||||||
|
include_inactive: bool = Query(default=False),
|
||||||
|
q: Optional[str] = Query(default=None, max_length=100),
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.actor.read")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return actor_service.list_actors(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
actor_type=actor_type,
|
||||||
|
include_inactive=include_inactive,
|
||||||
|
q=q,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{actor_id}")
|
||||||
|
def get_actor(
|
||||||
|
actor_id: str,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.actor.read")),
|
||||||
|
):
|
||||||
|
item = actor_service.get_actor(tenant_id=ctx.tenant_id, actor_id=actor_id)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Actor nicht gefunden")
|
||||||
|
return item
|
||||||
63
backend/routers/workspace.py
Normal file
63
backend/routers/workspace.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""Workspace read API — delegates to Data Layer (AP0.7)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from capabilities import require_capability
|
||||||
|
from data_layer import actions as dl_actions
|
||||||
|
from data_layer import actors as dl_actors
|
||||||
|
from data_layer import initiatives as dl_initiatives
|
||||||
|
from data_layer import workspace as dl_workspace
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/workspace", tags=["workspace"])
|
||||||
|
|
||||||
|
|
||||||
|
def _require_actor_ctx(ctx: TenantContext) -> TenantContext:
|
||||||
|
if not ctx.actor_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Kein Actor im TenantContext")
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
def workspace_summary(
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
|
||||||
|
):
|
||||||
|
return dl_workspace.get_workspace_summary(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/actions/open")
|
||||||
|
def workspace_open_actions(
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return dl_actions.get_my_open_actions(_require_actor_ctx(ctx))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/actions/blocked")
|
||||||
|
def workspace_blocked_actions(
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
return dl_actions.get_my_blocked_actions(_require_actor_ctx(ctx))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/initiatives/active")
|
||||||
|
def workspace_active_initiatives(
|
||||||
|
limit: Optional[int] = Query(default=None, ge=1, le=100),
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
|
||||||
|
):
|
||||||
|
return dl_initiatives.get_active_initiatives(ctx, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/actors/workload")
|
||||||
|
def workspace_actor_workload(
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
|
||||||
|
):
|
||||||
|
return dl_actors.get_actor_workload(ctx)
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Actor creation helpers — User and Actor remain separate concepts."""
|
"""Actor creation and directory helpers — User and Actor remain separate concepts."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -10,6 +10,20 @@ from db import get_connection
|
||||||
|
|
||||||
ActorType = Literal["human", "agent", "working_group", "external_system"]
|
ActorType = Literal["human", "agent", "working_group", "external_system"]
|
||||||
|
|
||||||
|
ACTOR_TYPES = frozenset({"human", "agent", "working_group", "external_system"})
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_actor(row: dict[str, Any], *, include_user_id: bool = False) -> dict[str, Any]:
|
||||||
|
result = {
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"name": row["name"],
|
||||||
|
"actor_type": row["actor_type"],
|
||||||
|
"is_active": bool(row["is_active"]),
|
||||||
|
}
|
||||||
|
if include_user_id and row.get("user_id"):
|
||||||
|
result["user_id"] = str(row["user_id"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def create_actor(
|
def create_actor(
|
||||||
*,
|
*,
|
||||||
|
|
@ -66,3 +80,70 @@ def get_human_actor(tenant_id: str, user_id: str) -> Optional[dict[str, Any]]:
|
||||||
return result
|
return result
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def list_actors(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
actor_type: Optional[str] = None,
|
||||||
|
include_inactive: bool = False,
|
||||||
|
q: Optional[str] = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if actor_type is not None and actor_type not in ACTOR_TYPES:
|
||||||
|
raise ValueError(f"Ungültiger actor_type: {actor_type}")
|
||||||
|
|
||||||
|
conditions = ["tenant_id = %s"]
|
||||||
|
params: list[Any] = [tenant_id]
|
||||||
|
|
||||||
|
if not include_inactive:
|
||||||
|
conditions.append("is_active = TRUE")
|
||||||
|
if actor_type:
|
||||||
|
conditions.append("actor_type = %s")
|
||||||
|
params.append(actor_type)
|
||||||
|
if q:
|
||||||
|
conditions.append("name ILIKE %s")
|
||||||
|
params.append(f"%{q.strip()}%")
|
||||||
|
|
||||||
|
where = " AND ".join(conditions)
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, actor_type, name, user_id, is_active
|
||||||
|
FROM actors
|
||||||
|
WHERE {where}
|
||||||
|
ORDER BY
|
||||||
|
CASE actor_type
|
||||||
|
WHEN 'human' THEN 0
|
||||||
|
WHEN 'working_group' THEN 1
|
||||||
|
WHEN 'agent' THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END,
|
||||||
|
name
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
return [_serialize_actor(dict(row)) for row in cur.fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_actor(*, tenant_id: str, actor_id: str) -> Optional[dict[str, Any]]:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, actor_type, name, user_id, is_active
|
||||||
|
FROM actors
|
||||||
|
WHERE id = %s AND tenant_id = %s
|
||||||
|
""",
|
||||||
|
(actor_id, tenant_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return _serialize_actor(dict(row))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
|
||||||
247
backend/tests/test_ap07.py
Normal file
247
backend/tests/test_ap07.py
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
"""AP0.7 — Actor directory, workspace data layer, tenant hardening tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from auth import AUTH_HEADER
|
||||||
|
from services.actors import create_actor
|
||||||
|
from tests.factories import provision_user_in_tenant
|
||||||
|
from tests.test_initiatives_actions import (
|
||||||
|
_auth,
|
||||||
|
_create_action,
|
||||||
|
_create_initiative,
|
||||||
|
_login,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_actors_tenant_scoped(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
agent = create_actor(tenant_id=user["tenant_id"], actor_type="agent", name="Bot Alpha")
|
||||||
|
|
||||||
|
res = client.get("/api/actors", headers=_auth(token))
|
||||||
|
assert res.status_code == 200
|
||||||
|
body = res.json()
|
||||||
|
ids = {a["id"] for a in body}
|
||||||
|
assert user["actor_id"] in ids
|
||||||
|
assert agent["id"] in ids
|
||||||
|
types = {a["actor_type"] for a in body}
|
||||||
|
assert "human" in types
|
||||||
|
assert "agent" in types
|
||||||
|
for actor in body:
|
||||||
|
assert "user_id" not in actor
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_actors_filter_by_type(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
|
token = _login(client, user)
|
||||||
|
create_actor(tenant_id=user["tenant_id"], actor_type="agent", name="Filter Agent")
|
||||||
|
|
||||||
|
res = client.get("/api/actors?actor_type=agent", headers=_auth(token))
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert all(a["actor_type"] == "agent" for a in res.json())
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_actors_excludes_inactive_by_default(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
|
token = _login(client, user)
|
||||||
|
inactive = create_actor(
|
||||||
|
tenant_id=user["tenant_id"], actor_type="external_system", name="Legacy"
|
||||||
|
)
|
||||||
|
conn = __import__("db").get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("UPDATE actors SET is_active = FALSE WHERE id = %s", (inactive["id"],))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
res = client.get("/api/actors", headers=_auth(token))
|
||||||
|
assert inactive["id"] not in {a["id"] for a in res.json()}
|
||||||
|
|
||||||
|
res_active = client.get("/api/actors?include_inactive=true", headers=_auth(token))
|
||||||
|
assert inactive["id"] in {a["id"] for a in res_active.json()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_actor_detail(client):
|
||||||
|
user = provision_user_in_tenant()
|
||||||
|
token = _login(client, user)
|
||||||
|
agent = create_actor(tenant_id=user["tenant_id"], actor_type="working_group", name="Team A")
|
||||||
|
|
||||||
|
res = client.get(f"/api/actors/{agent['id']}", headers=_auth(token))
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["name"] == "Team A"
|
||||||
|
assert res.json()["actor_type"] == "working_group"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_tenant_actor_directory_isolation(client):
|
||||||
|
user_a = provision_user_in_tenant()
|
||||||
|
user_b = provision_user_in_tenant()
|
||||||
|
token_a = _login(client, user_a)
|
||||||
|
token_b = _login(client, user_b)
|
||||||
|
agent_a = create_actor(tenant_id=user_a["tenant_id"], actor_type="agent", name="Tenant A Agent")
|
||||||
|
|
||||||
|
list_b = client.get("/api/actors", headers=_auth(token_b)).json()
|
||||||
|
assert agent_a["id"] not in {a["id"] for a in list_b}
|
||||||
|
assert user_a["actor_id"] not in {a["id"] for a in list_b}
|
||||||
|
|
||||||
|
detail = client.get(f"/api/actors/{agent_a['id']}", headers=_auth(token_b))
|
||||||
|
assert detail.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_member_has_actor_and_workspace_capabilities(client):
|
||||||
|
member = provision_user_in_tenant(tenant_role="member", portal_role="user")
|
||||||
|
token = _login(client, member)
|
||||||
|
ctx = client.get("/api/me/context", headers=_auth(token)).json()
|
||||||
|
assert "kairo.actor.read" in ctx["capabilities"]
|
||||||
|
assert "kairo.workspace.read" in ctx["capabilities"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_summary(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
|
||||||
|
open_action = _create_action(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
initiative_id,
|
||||||
|
assigned_actor_ids=[user["actor_id"]],
|
||||||
|
).json()
|
||||||
|
blocked = _create_action(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
initiative_id,
|
||||||
|
title="Blocked",
|
||||||
|
assigned_actor_ids=[user["actor_id"]],
|
||||||
|
).json()
|
||||||
|
client.patch(
|
||||||
|
f"/api/actions/{blocked['id']}",
|
||||||
|
json={"status": "blocked"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
done = _create_action(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
initiative_id,
|
||||||
|
title="Done",
|
||||||
|
assigned_actor_ids=[user["actor_id"]],
|
||||||
|
).json()
|
||||||
|
client.patch(
|
||||||
|
f"/api/actions/{done['id']}",
|
||||||
|
json={"status": "done"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
res = client.get("/api/workspace/summary", headers=_auth(token))
|
||||||
|
assert res.status_code == 200
|
||||||
|
summary = res.json()
|
||||||
|
assert summary["open_actions_count"] >= 1
|
||||||
|
assert summary["blocked_actions_count"] >= 1
|
||||||
|
assert summary["active_initiatives_count"] >= 1
|
||||||
|
assert summary["done_actions_recent_count"] >= 1
|
||||||
|
assert open_action["id"] # used
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_open_and_blocked_actions(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
|
||||||
|
open_id = _create_action(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
initiative_id,
|
||||||
|
title="Open",
|
||||||
|
assigned_actor_ids=[user["actor_id"]],
|
||||||
|
).json()["id"]
|
||||||
|
blocked_id = _create_action(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
initiative_id,
|
||||||
|
title="Blocked",
|
||||||
|
assigned_actor_ids=[user["actor_id"]],
|
||||||
|
).json()["id"]
|
||||||
|
client.patch(
|
||||||
|
f"/api/actions/{blocked_id}",
|
||||||
|
json={"status": "blocked"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
open_res = client.get("/api/workspace/actions/open", headers=_auth(token))
|
||||||
|
blocked_res = client.get("/api/workspace/actions/blocked", headers=_auth(token))
|
||||||
|
assert open_res.status_code == 200
|
||||||
|
assert blocked_res.status_code == 200
|
||||||
|
assert open_id in {a["id"] for a in open_res.json()}
|
||||||
|
assert blocked_id in {a["id"] for a in blocked_res.json()}
|
||||||
|
assert blocked_id not in {a["id"] for a in open_res.json()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_active_initiatives(client):
|
||||||
|
user = provision_user_in_tenant()
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token, title="Active One").json()["id"]
|
||||||
|
|
||||||
|
res = client.get("/api/workspace/initiatives/active?limit=5", headers=_auth(token))
|
||||||
|
assert res.status_code == 200
|
||||||
|
ids = {i["id"] for i in res.json()}
|
||||||
|
assert initiative_id in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_actor_workload(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
|
token = _login(client, user)
|
||||||
|
agent = create_actor(tenant_id=user["tenant_id"], actor_type="agent", name="Workload Bot")
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
_create_action(
|
||||||
|
client,
|
||||||
|
token,
|
||||||
|
initiative_id,
|
||||||
|
assigned_actor_ids=[agent["id"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
res = client.get("/api/workspace/actors/workload", headers=_auth(token))
|
||||||
|
assert res.status_code == 200
|
||||||
|
workload = {row["actor_id"]: row for row in res.json()}
|
||||||
|
assert agent["id"] in workload
|
||||||
|
assert workload[agent["id"]]["open_actions"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_tenant_workspace_summary_isolation(client):
|
||||||
|
user_a = provision_user_in_tenant()
|
||||||
|
user_b = provision_user_in_tenant()
|
||||||
|
token_a = _login(client, user_a)
|
||||||
|
token_b = _login(client, user_b)
|
||||||
|
initiative_id = _create_initiative(client, token_a).json()["id"]
|
||||||
|
_create_action(
|
||||||
|
client,
|
||||||
|
token_a,
|
||||||
|
initiative_id,
|
||||||
|
assigned_actor_ids=[user_a["actor_id"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
summary_b = client.get("/api/workspace/summary", headers=_auth(token_b)).json()
|
||||||
|
assert summary_b["open_actions_count"] == 0
|
||||||
|
|
||||||
|
open_b = client.get("/api/workspace/actions/open", headers=_auth(token_b)).json()
|
||||||
|
assert open_b == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_assign_action_to_tenant_actor(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
|
token = _login(client, user)
|
||||||
|
agent = create_actor(tenant_id=user["tenant_id"], actor_type="agent", name="Assignee")
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
action_id = _create_action(client, token, initiative_id).json()["id"]
|
||||||
|
|
||||||
|
res = client.put(
|
||||||
|
f"/api/actions/{action_id}/assignments",
|
||||||
|
json={"actor_ids": [agent["id"]]},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["assigned_actor_ids"] == [agent["id"]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unauthenticated_workspace_rejected(client):
|
||||||
|
assert client.get("/api/workspace/summary").status_code == 401
|
||||||
|
assert client.get("/api/actors").status_code == 401
|
||||||
|
|
@ -13,6 +13,7 @@ def test_registry_contains_initial_capabilities():
|
||||||
"kairo.admin.access",
|
"kairo.admin.access",
|
||||||
"kairo.tenant.manage",
|
"kairo.tenant.manage",
|
||||||
"kairo.actor.manage",
|
"kairo.actor.manage",
|
||||||
|
"kairo.actor.read",
|
||||||
"kairo.context.read",
|
"kairo.context.read",
|
||||||
"kairo.entitlements.read",
|
"kairo.entitlements.read",
|
||||||
"kairo.feature.registry.read",
|
"kairo.feature.registry.read",
|
||||||
|
|
@ -26,6 +27,7 @@ def test_registry_contains_initial_capabilities():
|
||||||
"kairo.initiative.manage",
|
"kairo.initiative.manage",
|
||||||
"kairo.action.read",
|
"kairo.action.read",
|
||||||
"kairo.action.manage",
|
"kairo.action.manage",
|
||||||
|
"kairo.workspace.read",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -37,7 +39,7 @@ def test_sync_is_idempotent():
|
||||||
try:
|
try:
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
cur.execute("SELECT COUNT(*) FROM capabilities")
|
cur.execute("SELECT COUNT(*) FROM capabilities")
|
||||||
assert cur.fetchone()[0] == 16
|
assert cur.fetchone()[0] == 18
|
||||||
cur.execute("SELECT COUNT(*) FROM role_capability_grants")
|
cur.execute("SELECT COUNT(*) FROM role_capability_grants")
|
||||||
assert cur.fetchone()[0] >= 5
|
assert cur.fetchone()[0] >= 5
|
||||||
finally:
|
finally:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
APP_VERSION = "0.6.0-ap0.6"
|
APP_VERSION = "0.7.0-ap0.7"
|
||||||
DB_SCHEMA_VERSION = "006"
|
DB_SCHEMA_VERSION = "006"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
62
docs/architecture/Kairo_Tenant_Invariants_v0.1.md
Normal file
62
docs/architecture/Kairo_Tenant_Invariants_v0.1.md
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Kairo Tenant-Invarianten v0.1
|
||||||
|
|
||||||
|
**Status:** verbindlich für Sprint 0 / AP0.7+
|
||||||
|
**Stand:** 2026-07-05
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zweck
|
||||||
|
|
||||||
|
Dieses Dokument legt Mandanten-Invarianten für Kairo fest, damit fachliche Module tenant-sicher bleiben und Cross-Tenant-Leaks vermieden werden.
|
||||||
|
|
||||||
|
Bei Abweichungen: Architecture Decision Proposal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Invarianten
|
||||||
|
|
||||||
|
| # | Invariante | Umsetzung in Kairo |
|
||||||
|
|---|------------|-------------------|
|
||||||
|
| 1 | Jede fachliche Tabelle hat `tenant_id`, sofern nicht global | `initiatives`, `actions`, `action_assignments`, `actors` — alle mit `tenant_id` |
|
||||||
|
| 2 | `tenant_id` kommt aus TenantContext, nicht aus Client-Body | Router nutzen `ctx.tenant_id` aus `require_tenant_context` / `require_capability` |
|
||||||
|
| 3 | Jede fachliche Query filtert nach `tenant_id` | Services und Data Layer: `WHERE tenant_id = %s` |
|
||||||
|
| 4 | Fremde Objekt-IDs → `404`, nicht `403` | Initiatives, Actions, Actors: Cross-Tenant-Zugriff liefert 404 |
|
||||||
|
| 5 | Actor-Zuweisungen nur im selben Tenant | `_actor_in_tenant()` in Services; Assignment-Tests |
|
||||||
|
| 6 | User ≠ Actor | Human Actor verknüpft via `actors.user_id`; Assignments an `actor_id` |
|
||||||
|
| 7 | Agenten/Arbeitsgruppen/externe Systeme sind tenant-scoped Actors | `actors.tenant_id` + `actor_type` |
|
||||||
|
| 8 | Audit Events enthalten mindestens `tenant_id` | `log_audit(..., tenant_id=...)` |
|
||||||
|
| 9 | Neue fachliche Tabellen: explizite Tenant-Entscheidung | Nummerierte Migration + Doku-Ergänzung |
|
||||||
|
| 10 | Router ohne eigene Mandantenlogik | Delegation an Services (Write) und Data Layer (Read-Aggregation) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Geprüfte Bereiche (AP0.7)
|
||||||
|
|
||||||
|
| Bereich | Ergebnis |
|
||||||
|
|---------|----------|
|
||||||
|
| `initiatives` CRUD | ✓ tenant_id aus Context |
|
||||||
|
| `actions` CRUD | ✓ tenant_id aus Context |
|
||||||
|
| `action_assignments` | ✓ tenant_id + Actor-Validierung |
|
||||||
|
| `actors` Directory | ✓ nur aktiver Tenant |
|
||||||
|
| `/api/actions/me/open` | ✓ via Data Layer, actor-scoped |
|
||||||
|
| `/api/workspace/*` | ✓ Data Layer, tenant-scoped |
|
||||||
|
| Cross-Tenant Tests | ✓ `test_initiatives_actions`, `test_ap07` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-Patterns (verboten)
|
||||||
|
|
||||||
|
- `tenant_id` aus Request-Body übernehmen ohne Membership-Prüfung
|
||||||
|
- Listen ohne `tenant_id`-Filter
|
||||||
|
- Cross-Tenant-Existenz via 403 verraten
|
||||||
|
- User-ID direkt in Assignments statt Actor-ID
|
||||||
|
- Aggregations-SQL in Routern
|
||||||
|
- Frontend-Widgets mit fachlicher KPI-Berechnung statt Data-Layer-Endpoints
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referenzen
|
||||||
|
|
||||||
|
- `docs/architecture/Kairo_Sprint0_Principle_Gate_v0.1.md` (G-01, G-02, G-03)
|
||||||
|
- Shinkan: Access Layer / tenant-sichere Queries
|
||||||
|
- AP0.7 Abschlussbericht: `docs/sprints/Sprint0_AP0_7_Completion_Report_v0.1.md`
|
||||||
342
docs/sprints/Sprint0_AP0_7_Completion_Report_v0.1.md
Normal file
342
docs/sprints/Sprint0_AP0_7_Completion_Report_v0.1.md
Normal file
|
|
@ -0,0 +1,342 @@
|
||||||
|
# AP0.7 – Abschlussbericht Tenant Hardening, Actor Directory & Data Layer Minimum
|
||||||
|
|
||||||
|
**Status:** abgeschlossen
|
||||||
|
**Stand:** 2026-07-05
|
||||||
|
**Branch:** `develop`
|
||||||
|
**Version:** Backend `0.7.0-ap0.7` · Frontend `0.7.0-ap0.7` · Schema `006` (unverändert)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Scope und Einordnung
|
||||||
|
|
||||||
|
AP0.7 ist ein **Basis-Härtungsauftrag** — keine neuen großen Fachfeatures.
|
||||||
|
|
||||||
|
| Teil | Ziel | Status |
|
||||||
|
|------|------|--------|
|
||||||
|
| A | Tenant Hardening | ✓ Invarianten dokumentiert, bestehende Endpoints geprüft, Tests ergänzt |
|
||||||
|
| B | Actor Directory | ✓ `GET /api/actors`, `GET /api/actors/{id}`, ActorSelect mit echter Liste |
|
||||||
|
| C | Data Layer Minimum | ✓ `backend/data_layer/`, Workspace-Endpoints, Widget-Integration |
|
||||||
|
| D | Audit-Härtung | ✓ Bewertung dokumentiert — `actor_id`-Spalte bewusst zurückgestellt |
|
||||||
|
|
||||||
|
**Keine neue Migration** — Read-Models nutzen bestehendes Schema `006`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Umgesetzte Dateien
|
||||||
|
|
||||||
|
### Backend (neu)
|
||||||
|
|
||||||
|
| Datei | Zweck |
|
||||||
|
|-------|--------|
|
||||||
|
| `backend/data_layer/__init__.py` | Data-Layer-Paket |
|
||||||
|
| `backend/data_layer/actions.py` | Open/blocked Actions (Read) |
|
||||||
|
| `backend/data_layer/initiatives.py` | Active Initiatives (Read) |
|
||||||
|
| `backend/data_layer/actors.py` | Actor Workload (Read) |
|
||||||
|
| `backend/data_layer/workspace.py` | Workspace Summary |
|
||||||
|
| `backend/routers/actors.py` | Actor Directory API |
|
||||||
|
| `backend/routers/workspace.py` | Workspace Read API |
|
||||||
|
| `backend/rights_registrations/workspace_ops.py` | `kairo.actor.read`, `kairo.workspace.read` |
|
||||||
|
| `backend/tests/test_ap07.py` | AP0.7 Tests (14 Tests) |
|
||||||
|
|
||||||
|
### Backend (geändert)
|
||||||
|
|
||||||
|
| Datei | Änderung |
|
||||||
|
|-------|----------|
|
||||||
|
| `backend/services/actors.py` | `list_actors`, `get_actor` |
|
||||||
|
| `backend/routers/actions.py` | `/me/open` delegiert an Data Layer |
|
||||||
|
| `backend/main.py` | Router actors + workspace |
|
||||||
|
| `backend/version.py` | `0.7.0-ap0.7` |
|
||||||
|
| `backend/tests/test_rights_registry.py` | 18 Capabilities |
|
||||||
|
|
||||||
|
### Frontend (neu/geändert)
|
||||||
|
|
||||||
|
| Datei | Zweck |
|
||||||
|
|-------|--------|
|
||||||
|
| `frontend/src/api/workspace.js` | Workspace API-Client |
|
||||||
|
| `frontend/src/hooks/useActors.js` | Actor Directory Hook + Fallback |
|
||||||
|
| `frontend/src/widgets/WorkspaceSummaryWidget.jsx` | Überblick-Karte |
|
||||||
|
| `frontend/src/api/actors.js` | `listActors`, Fallback beibehalten |
|
||||||
|
| Widgets, `ActorSelect`, `ActionForm`, `InitiativeDetailPage` | Data-Layer-Endpoints, echte Actors |
|
||||||
|
| `frontend/src/registry/widgetRegistry.js` | + `kairo.workspace_summary` |
|
||||||
|
|
||||||
|
### Doku
|
||||||
|
|
||||||
|
| Datei | Zweck |
|
||||||
|
|-------|--------|
|
||||||
|
| `docs/architecture/Kairo_Tenant_Invariants_v0.1.md` | 10 Tenant-Invarianten |
|
||||||
|
| `README.md` | AP0.7 API, Capabilities |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Tenant Hardening
|
||||||
|
|
||||||
|
**Ergebnis der Code-Prüfung:** Bestehende fachliche Services (AP0.5) waren bereits tenant-sicher aufgebaut.
|
||||||
|
|
||||||
|
| Prüfpunkt | Ergebnis |
|
||||||
|
|-----------|----------|
|
||||||
|
| `tenant_id` aus Context, nicht Body | ✓ alle Router |
|
||||||
|
| Queries mit `tenant_id`-Filter | ✓ Services + Data Layer |
|
||||||
|
| Cross-Tenant → 404 | ✓ Initiatives, Actions, Actors |
|
||||||
|
| Assignment tenant-sicher | ✓ `_actor_in_tenant()` |
|
||||||
|
| Keine Client-`tenant_id` | ✓ |
|
||||||
|
|
||||||
|
**Gefundene Mandantenrisiken:** Keine neuen Lücken in AP0.5-Code. AP0.7 schließt die fehlende Actor-Directory-Isolation und Workspace-Aggregations-Tests ab.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Tenant-Invarianten
|
||||||
|
|
||||||
|
Verbindlich dokumentiert in `docs/architecture/Kairo_Tenant_Invariants_v0.1.md` (10 Invarianten + Anti-Patterns).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Actor Directory
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Capability | Verhalten |
|
||||||
|
|----------|------------|-----------|
|
||||||
|
| `GET /api/actors` | `kairo.actor.read` | Aktive Actors des Tenants; optional `actor_type`, `q`, `include_inactive` |
|
||||||
|
| `GET /api/actors/{id}` | `kairo.actor.read` | Detail; Cross-Tenant → 404 |
|
||||||
|
|
||||||
|
### Response-Shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "...", "name": "...", "actor_type": "human|agent|working_group|external_system", "is_active": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
`user_id` wird **nicht** exponiert (Datenschutz).
|
||||||
|
|
||||||
|
### Default Grants `kairo.actor.read`
|
||||||
|
|
||||||
|
Portal Admin/User, Tenant Owner/Admin/Member — Begründung: Assignment-UI und Workspace-Workload für alle operativen Tenant-Mitglieder.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. ActorSelect / Assignment UX
|
||||||
|
|
||||||
|
- `useActors()` lädt `/api/actors`
|
||||||
|
- `ActorSelect` zeigt Name + Typ (deutsche Labels)
|
||||||
|
- Mehrfachauswahl für Assignments
|
||||||
|
- Fallback auf Human Actor aus Context bei API-Fehler
|
||||||
|
- Loading/Error/Empty States
|
||||||
|
- Maßnahmen können Agents/Working Groups aus dem Tenant zugewiesen werden
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Data Layer Struktur
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend/data_layer/
|
||||||
|
actions.py — get_my_open_actions, get_my_blocked_actions, get_all_my_open_actions
|
||||||
|
initiatives.py — get_active_initiatives
|
||||||
|
actors.py — get_actor_workload
|
||||||
|
workspace.py — get_workspace_summary
|
||||||
|
```
|
||||||
|
|
||||||
|
### Abgrenzung
|
||||||
|
|
||||||
|
| Schicht | Verantwortung |
|
||||||
|
|---------|---------------|
|
||||||
|
| Router | HTTP, Capability-Gates, Serialisierung |
|
||||||
|
| Data Layer | Read-Aggregation, tenant-scoped Queries, DTOs |
|
||||||
|
| Services | Write/CRUD, Domänenvalidierung, Audit |
|
||||||
|
|
||||||
|
`/api/actions/me/open` bleibt für Abwärtskompatibilität; delegiert intern an Data Layer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Workspace Summary
|
||||||
|
|
||||||
|
`GET /api/workspace/summary` → `kairo.workspace.read`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"open_actions_count": 0,
|
||||||
|
"blocked_actions_count": 0,
|
||||||
|
"active_initiatives_count": 0,
|
||||||
|
"done_actions_recent_count": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Personal (current actor): open, blocked, done (7 Tage via `updated_at`)
|
||||||
|
- Tenant-weit: `active_initiatives_count` (status active/paused)
|
||||||
|
|
||||||
|
Frontend: `WorkspaceSummaryWidget` im Workspace.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Actor Workload Basic
|
||||||
|
|
||||||
|
`GET /api/workspace/actors/workload` — pro aktivem Actor im Tenant:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"actor_id": "...",
|
||||||
|
"display_name": "...",
|
||||||
|
"actor_type": "human",
|
||||||
|
"open_actions": 3,
|
||||||
|
"blocked_actions": 1,
|
||||||
|
"in_progress_actions": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Noch **kein dediziertes UI-Widget** — Endpoint für spätere Dashboards; API + Tests vorhanden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Neue/geänderte API-Endpunkte
|
||||||
|
|
||||||
|
Siehe README AP0.7-Abschnitt. Keine Write-Endpoints hinzugefügt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Capability-Nutzung
|
||||||
|
|
||||||
|
| Capability | Neu? | Grants | Verwendung |
|
||||||
|
|------------|------|--------|------------|
|
||||||
|
| `kairo.actor.read` | ✓ | Member+ | Actor Directory |
|
||||||
|
| `kairo.workspace.read` | ✓ | Member+ | Workspace Data Layer |
|
||||||
|
| `kairo.actor.manage` | — | unverändert | Actor-Verwaltung (AP0.2) |
|
||||||
|
| `kairo.action.read` | — | unverändert | CRUD-Read, `/me/open` |
|
||||||
|
| `kairo.initiative.read` | — | unverändert | Initiatives |
|
||||||
|
|
||||||
|
**Entscheidung:** Kein separates `kairo.workspace.metrics.read` — ein Read-Key für alle Workspace-Read-Models.
|
||||||
|
|
||||||
|
Gesamt: **18 Capabilities** (vorher 16).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Audit-Härtung / actor_id-Bewertung
|
||||||
|
|
||||||
|
**Ist-Zustand:** `audit_log` hat `user_id`, `tenant_id`, `details` (JSONB). Kein `actor_id`-Spalte.
|
||||||
|
|
||||||
|
**Bewertung:**
|
||||||
|
|
||||||
|
| Option | Empfehlung |
|
||||||
|
|--------|------------|
|
||||||
|
| `actor_id` in `details` | ✓ bereits bei Assignments (`actor_ids`) |
|
||||||
|
| `actor_id` als Spalte | **Zurückgestellt** — Migration 007 + Backfill; sinnvoll in AP0.8 (Audit/Admin) |
|
||||||
|
| Risiko ohne Spalte | Gering für Sprint 0 — Actor aus Context + details ausreichend für Nachvollziehbarkeit |
|
||||||
|
|
||||||
|
Keine Migration in AP0.7 (Basis-Härtung ohne Schema-Change).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Frontend-Integration
|
||||||
|
|
||||||
|
| Widget/Komponente | Vor AP0.7 | Nach AP0.7 |
|
||||||
|
|-------------------|-----------|------------|
|
||||||
|
| MyOpenActionsWidget | `/api/actions/me/open` + Client-Filter | `/api/workspace/actions/open` |
|
||||||
|
| BlockedActionsWidget | `/me/open` + Filter | `/api/workspace/actions/blocked` |
|
||||||
|
| InitiativesWidget | `/api/initiatives` + Client-Filter | `/api/workspace/initiatives/active?limit=5` |
|
||||||
|
| WorkspaceSummaryWidget | — | `/api/workspace/summary` |
|
||||||
|
| ActorSelect | Context-Fallback only | `/api/actors` + Fallback |
|
||||||
|
|
||||||
|
**Keine fachliche KPI-Berechnung mehr in Widgets** (Filter/Slice entfernt).
|
||||||
|
|
||||||
|
AP0.6b Look & Feel unverändert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Tests und Verifikation
|
||||||
|
|
||||||
|
### Backend (`test_ap07.py`)
|
||||||
|
|
||||||
|
- Actor Directory: List, Filter, Inactive, Detail, Cross-Tenant
|
||||||
|
- Workspace: Summary, Open, Blocked, Initiatives, Workload, Cross-Tenant
|
||||||
|
- Capabilities: Member hat `kairo.actor.read`, `kairo.workspace.read`
|
||||||
|
- Assignment an Tenant-Actor
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- Vitest: 9/9 grün (Registry inkl. `workspace_summary`)
|
||||||
|
- Build: OK
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
pytest im Backend-Container (wie bisher) — erwartet grün mit +14 Tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Übernommene Muster aus Mitai
|
||||||
|
|
||||||
|
- Zentrale Data-Layer-Schicht für Read/Aggregation
|
||||||
|
- Widgets konsumieren vorbereitete Daten
|
||||||
|
- Trennung Rohdaten / Kennzahlen / UI
|
||||||
|
|
||||||
|
**Nicht übernommen:** Mitai Tracking/Gesundheits-KPIs, Tier/Billing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Übernommene Muster aus Shinkan
|
||||||
|
|
||||||
|
- Tenant-sichere Queries mit Context-`tenant_id`
|
||||||
|
- Capability Gates pro Endpoint
|
||||||
|
- Cross-Tenant → 404
|
||||||
|
- Access-Layer-Denken (Data Layer als Read-Access)
|
||||||
|
|
||||||
|
**Nicht übernommen:** Vereins-/Trainingslogik.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Bewusst nicht übernommene Muster
|
||||||
|
|
||||||
|
- Analytics-Plattform, Zeitreihen, Forecasting
|
||||||
|
- Actor-Admin-UI, Actor-Erstellung in GUI
|
||||||
|
- `audit_log.actor_id` Migration
|
||||||
|
- Backend-Widget-Registry
|
||||||
|
- Program-Director-Algorithmus
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Abweichungen von der Spezifikation
|
||||||
|
|
||||||
|
| Thema | Abweichung | Begründung |
|
||||||
|
|-------|------------|------------|
|
||||||
|
| Actor Workload UI | nur API, kein Widget | Endpoint für später; kein UI-Scope |
|
||||||
|
| Breakpoint Shell | unverändert 1024px | AP0.6b unverändert |
|
||||||
|
| `/api/actions/me/open` | beibehalten | Abwärtskompatibilität |
|
||||||
|
| `done_actions_recent_count` | 7-Tage-Fenster via `updated_at` | pragmatisch, dokumentiert |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. Offene Entscheidungen
|
||||||
|
|
||||||
|
1. **`audit_log.actor_id`** — Migration in AP0.8?
|
||||||
|
2. **Actor Workload Widget** — wann in Workspace anzeigen?
|
||||||
|
3. **Initiative bearbeiten in UI** — weiter offen aus AP0.6
|
||||||
|
4. **Foundation AP0.5 Audit/Admin-UI** — backlog
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. Empfehlung für AP0.8
|
||||||
|
|
||||||
|
Kleinster Nutzwert (Priorität):
|
||||||
|
|
||||||
|
1. **Audit/Admin minimal** — Audit-Log lesbar, optional `actor_id`-Spalte
|
||||||
|
2. **Initiative bearbeiten in UI** — PATCH existiert
|
||||||
|
3. **Actor Workload Widget** — kleine Karte im Workspace
|
||||||
|
4. **Kommentare pro Maßnahme** — Kollaboration ohne Domänen-Explosion
|
||||||
|
|
||||||
|
Danach Sprint-1-Fachscope gemäß Product Spec.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abnahme-Checkliste AP0.7
|
||||||
|
|
||||||
|
| Kriterium | Erfüllt |
|
||||||
|
|-----------|---------|
|
||||||
|
| Tenant-Invarianten dokumentiert | ✓ |
|
||||||
|
| Fachliche Endpoints tenant-geprüft | ✓ |
|
||||||
|
| Cross-Tenant-Tests | ✓ |
|
||||||
|
| `GET /api/actors` | ✓ |
|
||||||
|
| ActorSelect echte Liste | ✓ |
|
||||||
|
| Tenant-Actor-Zuweisung | ✓ |
|
||||||
|
| Data Layer Minimum | ✓ |
|
||||||
|
| Workspace Summary | ✓ |
|
||||||
|
| Blocked/Active zentral | ✓ |
|
||||||
|
| Actor Workload Basic | ✓ (API) |
|
||||||
|
| Frontend nutzt Data Layer | ✓ |
|
||||||
|
| Keine großen Fachfeatures | ✓ |
|
||||||
|
| AP0.6b UX erhalten | ✓ |
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "kairo-jinkendo-frontend",
|
"name": "kairo-jinkendo-frontend",
|
||||||
"version": "0.6.1-ap0.6b",
|
"version": "0.7.0-ap0.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,22 @@
|
||||||
/**
|
import { apiFetch } from './client.js'
|
||||||
* AP0.6: Kein GET /api/actors im Backend.
|
|
||||||
* Actor-Zuweisung nutzt TenantContext (aktueller Human Actor).
|
|
||||||
* Siehe Abschlussbericht AP0.6 §9.
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ actor_type?: string, include_inactive?: boolean, q?: string }} [params]
|
||||||
|
*/
|
||||||
|
export function listActors(params = {}) {
|
||||||
|
const search = new URLSearchParams()
|
||||||
|
if (params.actor_type) search.set('actor_type', params.actor_type)
|
||||||
|
if (params.include_inactive) search.set('include_inactive', 'true')
|
||||||
|
if (params.q) search.set('q', params.q)
|
||||||
|
const qs = search.toString()
|
||||||
|
return apiFetch(`/api/actors${qs ? `?${qs}` : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActor(id) {
|
||||||
|
return apiFetch(`/api/actors/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fallback wenn Actor Directory nicht erreichbar (AP0.6-Kompatibilität). */
|
||||||
export function actorsFromContext(context) {
|
export function actorsFromContext(context) {
|
||||||
if (!context?.actor?.id) return []
|
if (!context?.actor?.id) return []
|
||||||
return [
|
return [
|
||||||
|
|
|
||||||
21
frontend/src/api/workspace.js
Normal file
21
frontend/src/api/workspace.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { apiFetch } from './client.js'
|
||||||
|
|
||||||
|
export function getWorkspaceSummary() {
|
||||||
|
return apiFetch('/api/workspace/summary')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listWorkspaceOpenActions() {
|
||||||
|
return apiFetch('/api/workspace/actions/open')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listWorkspaceBlockedActions() {
|
||||||
|
return apiFetch('/api/workspace/actions/blocked')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listWorkspaceActiveInitiatives(limit = 5) {
|
||||||
|
return apiFetch(`/api/workspace/initiatives/active?limit=${limit}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActorWorkload() {
|
||||||
|
return apiFetch('/api/workspace/actors/workload')
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,10 @@ import { ActorSelect } from './ActorSelect.jsx'
|
||||||
export function ActionForm({
|
export function ActionForm({
|
||||||
initial = {},
|
initial = {},
|
||||||
actors = [],
|
actors = [],
|
||||||
|
actorsLoading = false,
|
||||||
|
actorsError = null,
|
||||||
|
actorsUsedFallback = false,
|
||||||
|
onReloadActors,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
busy = false,
|
busy = false,
|
||||||
|
|
@ -55,7 +59,14 @@ export function ActionForm({
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<ActorSelect actors={actors} defaultSelected={defaultActors} />
|
<ActorSelect
|
||||||
|
actors={actors}
|
||||||
|
defaultSelected={defaultActors}
|
||||||
|
loading={actorsLoading}
|
||||||
|
error={actorsError}
|
||||||
|
usedFallback={actorsUsedFallback}
|
||||||
|
onRetry={onReloadActors}
|
||||||
|
/>
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
{busy ? 'Speichern …' : submitLabel}
|
{busy ? 'Speichern …' : submitLabel}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,36 @@
|
||||||
export function ActorSelect({ actors = [], defaultSelected = [], label = 'Zugewiesene Actors' }) {
|
const ACTOR_TYPE_LABELS = {
|
||||||
|
human: 'Mensch',
|
||||||
|
agent: 'Agent',
|
||||||
|
working_group: 'Arbeitsgruppe',
|
||||||
|
external_system: 'Externes System',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActorSelect({
|
||||||
|
actors = [],
|
||||||
|
defaultSelected = [],
|
||||||
|
label = 'Zugewiesene Actors',
|
||||||
|
loading = false,
|
||||||
|
error = null,
|
||||||
|
usedFallback = false,
|
||||||
|
onRetry,
|
||||||
|
}) {
|
||||||
|
if (loading) {
|
||||||
|
return <p className="muted actor-select-hint">Actors werden geladen …</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !actors.length) {
|
||||||
|
return (
|
||||||
|
<div className="actor-select actor-select--error">
|
||||||
|
<p className="error">{error}</p>
|
||||||
|
{onRetry && (
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={onRetry}>
|
||||||
|
Erneut laden
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!actors.length) {
|
if (!actors.length) {
|
||||||
return (
|
return (
|
||||||
<p className="muted actor-select-hint">
|
<p className="muted actor-select-hint">
|
||||||
|
|
@ -18,12 +50,17 @@ export function ActorSelect({ actors = [], defaultSelected = [], label = 'Zugewi
|
||||||
defaultChecked={defaultSelected.includes(actor.id)}
|
defaultChecked={defaultSelected.includes(actor.id)}
|
||||||
/>
|
/>
|
||||||
{actor.name}
|
{actor.name}
|
||||||
{actor.actor_type && <span className="muted"> ({actor.actor_type})</span>}
|
{actor.actor_type && (
|
||||||
|
<span className="muted">
|
||||||
|
{' '}
|
||||||
|
({ACTOR_TYPE_LABELS[actor.actor_type] || actor.actor_type})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
{actors.length === 1 && (
|
{usedFallback && (
|
||||||
<p className="muted actor-select-hint">
|
<p className="muted actor-select-hint">
|
||||||
AP0.6: Nur der aktuelle Actor — Tenant-Actor-Liste folgt in AP0.7.
|
Actor Directory nicht erreichbar — Fallback auf aktuellen Actor.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
|
||||||
37
frontend/src/hooks/useActors.js
Normal file
37
frontend/src/hooks/useActors.js
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { listActors, actorsFromContext } from '../api/actors.js'
|
||||||
|
import { useSession } from '../context/SessionContext.jsx'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt Tenant-Actor-Directory; Fallback auf Human Actor aus Context.
|
||||||
|
*/
|
||||||
|
export function useActors() {
|
||||||
|
const { context } = useSession()
|
||||||
|
const [actors, setActors] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [usedFallback, setUsedFallback] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setUsedFallback(false)
|
||||||
|
try {
|
||||||
|
const data = await listActors()
|
||||||
|
setActors(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
const fallback = actorsFromContext(context)
|
||||||
|
setActors(fallback)
|
||||||
|
setUsedFallback(fallback.length > 0)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [context])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
return { actors, loading, error, usedFallback, reload: load }
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
createInitiativeAction,
|
createInitiativeAction,
|
||||||
} from '../api/initiatives.js'
|
} from '../api/initiatives.js'
|
||||||
import { updateAction, setActionAssignments } from '../api/actions.js'
|
import { updateAction, setActionAssignments } from '../api/actions.js'
|
||||||
import { actorsFromContext } from '../api/actors.js'
|
|
||||||
import { ACTION_STATUS_LABELS } from '../constants/status.js'
|
import { ACTION_STATUS_LABELS } from '../constants/status.js'
|
||||||
import { StatusBadge } from '../components/StatusBadge.jsx'
|
import { StatusBadge } from '../components/StatusBadge.jsx'
|
||||||
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
||||||
|
|
@ -15,13 +14,20 @@ import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
import { ErrorState } from '../components/ErrorState.jsx'
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
import { useCapabilities } from '../hooks/useCapabilities.js'
|
import { useCapabilities } from '../hooks/useCapabilities.js'
|
||||||
|
import { useActors } from '../hooks/useActors.js'
|
||||||
import { useSession } from '../context/SessionContext.jsx'
|
import { useSession } from '../context/SessionContext.jsx'
|
||||||
|
|
||||||
export function InitiativeDetailPage() {
|
export function InitiativeDetailPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const { context } = useSession()
|
const { context } = useSession()
|
||||||
const { hasCapability } = useCapabilities()
|
const { hasCapability } = useCapabilities()
|
||||||
const actors = actorsFromContext(context)
|
const {
|
||||||
|
actors,
|
||||||
|
loading: actorsLoading,
|
||||||
|
error: actorsError,
|
||||||
|
usedFallback: actorsUsedFallback,
|
||||||
|
reload: reloadActors,
|
||||||
|
} = useActors()
|
||||||
|
|
||||||
const [initiative, setInitiative] = useState(null)
|
const [initiative, setInitiative] = useState(null)
|
||||||
const [actions, setActions] = useState([])
|
const [actions, setActions] = useState([])
|
||||||
|
|
@ -178,6 +184,10 @@ export function InitiativeDetailPage() {
|
||||||
<div className="inline-form-block">
|
<div className="inline-form-block">
|
||||||
<ActionForm
|
<ActionForm
|
||||||
actors={actors}
|
actors={actors}
|
||||||
|
actorsLoading={actorsLoading}
|
||||||
|
actorsError={actorsError}
|
||||||
|
actorsUsedFallback={actorsUsedFallback}
|
||||||
|
onReloadActors={reloadActors}
|
||||||
onSubmit={handleCreateAction}
|
onSubmit={handleCreateAction}
|
||||||
onCancel={() => setShowActionForm(false)}
|
onCancel={() => setShowActionForm(false)}
|
||||||
busy={formBusy}
|
busy={formBusy}
|
||||||
|
|
@ -197,6 +207,10 @@ export function InitiativeDetailPage() {
|
||||||
<ActionForm
|
<ActionForm
|
||||||
initial={action}
|
initial={action}
|
||||||
actors={actors}
|
actors={actors}
|
||||||
|
actorsLoading={actorsLoading}
|
||||||
|
actorsError={actorsError}
|
||||||
|
actorsUsedFallback={actorsUsedFallback}
|
||||||
|
onReloadActors={reloadActors}
|
||||||
onSubmit={(payload) => handleUpdateAction(action.id, payload)}
|
onSubmit={(payload) => handleUpdateAction(action.id, payload)}
|
||||||
onCancel={() => setEditingAction(null)}
|
onCancel={() => setEditingAction(null)}
|
||||||
busy={formBusy}
|
busy={formBusy}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ describe('widgetRegistry', () => {
|
||||||
expect(keys).toContain('kairo.active_initiatives')
|
expect(keys).toContain('kairo.active_initiatives')
|
||||||
expect(keys).toContain('kairo.blocked_actions')
|
expect(keys).toContain('kairo.blocked_actions')
|
||||||
expect(keys).toContain('kairo.tenant_context')
|
expect(keys).toContain('kairo.tenant_context')
|
||||||
|
expect(keys).toContain('kairo.workspace_summary')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('filters widgets by capability', () => {
|
it('filters widgets by capability', () => {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { MyOpenActionsWidget } from '../widgets/MyOpenActionsWidget.jsx'
|
||||||
import { InitiativesWidget } from '../widgets/InitiativesWidget.jsx'
|
import { InitiativesWidget } from '../widgets/InitiativesWidget.jsx'
|
||||||
import { BlockedActionsWidget } from '../widgets/BlockedActionsWidget.jsx'
|
import { BlockedActionsWidget } from '../widgets/BlockedActionsWidget.jsx'
|
||||||
import { TenantContextWidget } from '../widgets/TenantContextWidget.jsx'
|
import { TenantContextWidget } from '../widgets/TenantContextWidget.jsx'
|
||||||
|
import { WorkspaceSummaryWidget } from '../widgets/WorkspaceSummaryWidget.jsx'
|
||||||
|
|
||||||
/** @typedef {import('react').ComponentType<any>} WidgetComponent */
|
/** @typedef {import('react').ComponentType<any>} WidgetComponent */
|
||||||
|
|
||||||
|
|
@ -26,6 +27,15 @@ export const WIDGETS = [
|
||||||
defaultOrder: 0,
|
defaultOrder: 0,
|
||||||
component: TenantContextWidget,
|
component: TenantContextWidget,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'kairo.workspace_summary',
|
||||||
|
title: 'Überblick',
|
||||||
|
description: 'Workspace-Kennzahlen aus Data Layer',
|
||||||
|
area: 'workspace',
|
||||||
|
requiredCapability: 'kairo.workspace.read',
|
||||||
|
defaultOrder: 5,
|
||||||
|
component: WorkspaceSummaryWidget,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'kairo.my_open_actions',
|
key: 'kairo.my_open_actions',
|
||||||
title: 'Meine offenen Maßnahmen',
|
title: 'Meine offenen Maßnahmen',
|
||||||
|
|
|
||||||
|
|
@ -24,3 +24,36 @@
|
||||||
.mt-sm {
|
.mt-sm {
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.75rem 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.summary-metrics {
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-metric {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--jk-surface-raised);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--jk-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-metric dt {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--jk-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-metric dd {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 1.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--jk-text);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { listMyOpenActions } from '../api/actions.js'
|
import { listWorkspaceBlockedActions } from '../api/workspace.js'
|
||||||
import { ActionList } from '../components/ActionList.jsx'
|
import { ActionList } from '../components/ActionList.jsx'
|
||||||
import { EmptyState } from '../components/EmptyState.jsx'
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
import { ErrorState } from '../components/ErrorState.jsx'
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
|
|
@ -16,8 +16,8 @@ export function BlockedActionsWidget() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const data = await listMyOpenActions()
|
const data = await listWorkspaceBlockedActions()
|
||||||
setActions(data.filter((a) => a.status === 'blocked'))
|
setActions(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { listInitiatives } from '../api/initiatives.js'
|
import { listWorkspaceActiveInitiatives } from '../api/workspace.js'
|
||||||
import { StatusBadge } from '../components/StatusBadge.jsx'
|
import { StatusBadge } from '../components/StatusBadge.jsx'
|
||||||
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
||||||
import { EmptyState } from '../components/EmptyState.jsx'
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
|
|
@ -17,10 +17,8 @@ export function InitiativesWidget() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const data = await listInitiatives()
|
const data = await listWorkspaceActiveInitiatives(5)
|
||||||
setInitiatives(
|
setInitiatives(data)
|
||||||
data.filter((i) => i.status === 'active' || i.status === 'paused').slice(0, 5),
|
|
||||||
)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { listMyOpenActions } from '../api/actions.js'
|
import { listWorkspaceOpenActions } from '../api/workspace.js'
|
||||||
import { ActionList } from '../components/ActionList.jsx'
|
import { ActionList } from '../components/ActionList.jsx'
|
||||||
import { EmptyState } from '../components/EmptyState.jsx'
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
import { ErrorState } from '../components/ErrorState.jsx'
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
|
|
@ -16,8 +16,8 @@ export function MyOpenActionsWidget() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const data = await listMyOpenActions()
|
const data = await listWorkspaceOpenActions()
|
||||||
setActions(data.filter((a) => a.status !== 'blocked'))
|
setActions(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
63
frontend/src/widgets/WorkspaceSummaryWidget.jsx
Normal file
63
frontend/src/widgets/WorkspaceSummaryWidget.jsx
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { getWorkspaceSummary } from '../api/workspace.js'
|
||||||
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
|
import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||||
|
|
||||||
|
export function WorkspaceSummaryWidget() {
|
||||||
|
const [summary, setSummary] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await getWorkspaceSummary()
|
||||||
|
setSummary(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WidgetCard
|
||||||
|
title="Überblick"
|
||||||
|
subtitle="Kennzahlen für deinen Workspace"
|
||||||
|
actions={
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={load} disabled={loading}>
|
||||||
|
Aktualisieren
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading && <LoadingState />}
|
||||||
|
{!loading && error && <ErrorState message={error} onRetry={load} />}
|
||||||
|
{!loading && !error && summary && (
|
||||||
|
<dl className="summary-metrics">
|
||||||
|
<div className="summary-metric">
|
||||||
|
<dt>Offene Maßnahmen</dt>
|
||||||
|
<dd>{summary.open_actions_count}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="summary-metric">
|
||||||
|
<dt>Blockiert</dt>
|
||||||
|
<dd>{summary.blocked_actions_count}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="summary-metric">
|
||||||
|
<dt>Aktive Vorhaben</dt>
|
||||||
|
<dd>{summary.active_initiatives_count}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="summary-metric">
|
||||||
|
<dt>Erledigt (7 Tage)</dt>
|
||||||
|
<dd>{summary.done_actions_recent_count}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</WidgetCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user