AP0.10b: Steering UI Minimum — Next Actions, Heute, Snapshot v2
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 1m13s
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 15s
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 1m13s
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 15s
Workspace beantwortet die Leitfrage mit prominenten Next-Action- und Heute-Widgets sowie erweitertem Steuerungszustand pro Vorhaben. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6ea663b4c4
commit
451d62ed46
12
README.md
12
README.md
|
|
@ -242,6 +242,18 @@ Operating Model Integration (AP0.10, Schema weiter `008`):
|
|||
|
||||
Version nach AP0.10: **`0.10.0-ap0.10`**. Brücke zum Steering Core: `docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md`
|
||||
|
||||
Operating Model Integration UI (AP0.10b, Schema weiter `008`):
|
||||
|
||||
| Endpoint | Methode | Capability | Beschreibung |
|
||||
|----------|---------|------------|--------------|
|
||||
| `/api/workspace/actions/today` | GET | `kairo.workspace.read` | Meine Maßnahmen heute (überfällig zuerst) |
|
||||
|
||||
**Workspace-Widgets:** Nächste sinnvolle Schritte, Heute, Aufmerksamkeit (erweitert)
|
||||
|
||||
**Steuerungszustand v2:** Meilenstein-Horizont, Next Actions pro Vorhaben, Phase-Klartext
|
||||
|
||||
Version nach AP0.10b: **`0.10.1-ap0.10b`**
|
||||
|
||||
Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
|
||||
`docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
|
||||
|
||||
|
|
|
|||
|
|
@ -22,15 +22,37 @@ def _list_assigned_actions(
|
|||
tenant_id: str,
|
||||
actor_id: str,
|
||||
statuses: list[str],
|
||||
order_today: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
conn = get_connection()
|
||||
order_clause = """
|
||||
ORDER BY
|
||||
CASE WHEN a.due_at IS NOT NULL AND a.due_at < NOW() THEN 0 ELSE 1 END,
|
||||
CASE a.status WHEN 'blocked' THEN 0 WHEN 'review_required' THEN 1 ELSE 2 END,
|
||||
CASE a.priority
|
||||
WHEN 'high' THEN 0
|
||||
WHEN 'normal' THEN 1
|
||||
WHEN 'low' THEN 2
|
||||
END,
|
||||
a.due_at ASC NULLS LAST,
|
||||
a.updated_at DESC
|
||||
""" if order_today else """
|
||||
ORDER BY
|
||||
CASE a.priority
|
||||
WHEN 'high' THEN 0
|
||||
WHEN 'normal' THEN 1
|
||||
WHEN 'low' THEN 2
|
||||
END,
|
||||
a.updated_at DESC
|
||||
"""
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
f"""
|
||||
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
|
||||
a.status, a.priority, a.due_at, a.created_at, a.updated_at,
|
||||
i.title AS initiative_title,
|
||||
(a.due_at IS NOT NULL AND a.due_at < NOW()) AS is_overdue
|
||||
FROM actions a
|
||||
JOIN action_assignments aa
|
||||
ON aa.action_id = a.id AND aa.tenant_id = a.tenant_id
|
||||
|
|
@ -39,17 +61,13 @@ def _list_assigned_actions(
|
|||
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
|
||||
{order_clause}
|
||||
""",
|
||||
(tenant_id, actor_id, statuses),
|
||||
)
|
||||
actions = [_serialize_row(dict(row)) for row in cur.fetchall()]
|
||||
for action in actions:
|
||||
action["is_overdue"] = bool(action.pop("is_overdue", False))
|
||||
finally:
|
||||
conn.close()
|
||||
return _attach_assignments(actions, tenant_id=tenant_id)
|
||||
|
|
@ -83,3 +101,19 @@ def get_all_my_open_actions(ctx: TenantContext) -> list[dict[str, Any]]:
|
|||
actor_id=actor_id,
|
||||
statuses=list(OPEN_ACTION_STATUSES),
|
||||
)
|
||||
|
||||
|
||||
def get_my_today_actions(ctx: TenantContext, *, limit: int = 15) -> list[dict[str, Any]]:
|
||||
"""Maßnahmen für heute — überfällig zuerst, dann blockiert/review, dann Priorität."""
|
||||
if limit < 1:
|
||||
limit = 1
|
||||
if limit > 50:
|
||||
limit = 50
|
||||
actor_id = _require_actor(ctx)
|
||||
actions = _list_assigned_actions(
|
||||
tenant_id=ctx.tenant_id,
|
||||
actor_id=actor_id,
|
||||
statuses=["open", "ready", "in_progress", "blocked", "review_required"],
|
||||
order_today=True,
|
||||
)
|
||||
return actions[:limit]
|
||||
|
|
|
|||
|
|
@ -567,3 +567,140 @@ def get_next_action_candidates(
|
|||
return candidates[:limit]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_next_action_candidates_for_initiative(
|
||||
ctx: TenantContext, *, initiative_id: str, limit: int = 5
|
||||
) -> list[dict[str, Any]]:
|
||||
"""NextActionCandidates für ein Vorhaben — tenant-scoped."""
|
||||
if limit < 1:
|
||||
limit = 1
|
||||
if limit > 20:
|
||||
limit = 20
|
||||
|
||||
conn = get_connection()
|
||||
candidates: list[dict[str, Any]] = []
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
'resolve_blocker' AS kind,
|
||||
b.title AS title,
|
||||
'Blocker klären oder Status aktualisieren' AS summary,
|
||||
b.initiative_id,
|
||||
b.action_id,
|
||||
NULL::uuid AS backlog_item_id,
|
||||
'blocker_open' AS reason_code,
|
||||
'Blocker bearbeiten' AS recommended_action
|
||||
FROM blockers b
|
||||
WHERE b.tenant_id = %s AND b.initiative_id = %s
|
||||
AND b.status IN ('open', 'in_progress')
|
||||
ORDER BY b.updated_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(ctx.tenant_id, initiative_id, limit),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
item = dict(row)
|
||||
item["initiative_id"] = str(item["initiative_id"])
|
||||
if item.get("action_id"):
|
||||
item["action_id"] = str(item["action_id"])
|
||||
candidates.append(item)
|
||||
|
||||
remaining = limit - len(candidates)
|
||||
if remaining > 0:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
'assign_action' AS kind,
|
||||
a.title AS title,
|
||||
'Actor zuweisen' AS summary,
|
||||
a.initiative_id,
|
||||
a.id AS action_id,
|
||||
NULL::uuid AS backlog_item_id,
|
||||
'action_unassigned' AS reason_code,
|
||||
'Maßnahme zuweisen' AS recommended_action
|
||||
FROM actions a
|
||||
WHERE a.tenant_id = %s AND a.initiative_id = %s
|
||||
AND a.status IN ('open', 'ready', 'in_progress')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM action_assignments aa
|
||||
WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
|
||||
)
|
||||
ORDER BY a.updated_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(ctx.tenant_id, initiative_id, remaining),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
item = dict(row)
|
||||
item["initiative_id"] = str(item["initiative_id"])
|
||||
item["action_id"] = str(item["action_id"])
|
||||
candidates.append(item)
|
||||
|
||||
remaining = limit - len(candidates)
|
||||
if remaining > 0:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
'convert_backlog' AS kind,
|
||||
bi.title AS title,
|
||||
'Freigegebenes Backlog-Item in Maßnahme umwandeln' AS summary,
|
||||
bi.initiative_id,
|
||||
NULL::uuid AS action_id,
|
||||
bi.id AS backlog_item_id,
|
||||
'backlog_accepted_not_converted' AS reason_code,
|
||||
'In Maßnahme umwandeln' AS recommended_action
|
||||
FROM backlog_items bi
|
||||
WHERE bi.tenant_id = %s AND bi.initiative_id = %s
|
||||
AND bi.status = 'accepted'
|
||||
AND bi.converted_action_id IS NULL
|
||||
ORDER BY bi.updated_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(ctx.tenant_id, initiative_id, remaining),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
item = dict(row)
|
||||
item["initiative_id"] = str(item["initiative_id"])
|
||||
item["backlog_item_id"] = str(item["backlog_item_id"])
|
||||
candidates.append(item)
|
||||
|
||||
remaining = limit - len(candidates)
|
||||
if remaining > 0:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM actions a
|
||||
WHERE a.initiative_id = %s AND a.tenant_id = %s
|
||||
AND a.status IN ('open', 'ready', 'in_progress', 'blocked', 'review_required')
|
||||
LIMIT 1
|
||||
""",
|
||||
(initiative_id, ctx.tenant_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT title FROM initiatives
|
||||
WHERE id = %s AND tenant_id = %s
|
||||
""",
|
||||
(initiative_id, ctx.tenant_id),
|
||||
)
|
||||
init_row = cur.fetchone()
|
||||
if init_row:
|
||||
candidates.append(
|
||||
{
|
||||
"kind": "create_action",
|
||||
"title": init_row["title"],
|
||||
"summary": "Nächste Maßnahme für Vorhaben anlegen",
|
||||
"initiative_id": initiative_id,
|
||||
"action_id": None,
|
||||
"backlog_item_id": None,
|
||||
"reason_code": "initiative_no_open_action",
|
||||
"recommended_action": "Maßnahme anlegen",
|
||||
}
|
||||
)
|
||||
|
||||
return candidates[:limit]
|
||||
finally:
|
||||
conn.close()
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from typing import Any, Literal, Optional
|
|||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from db import get_connection
|
||||
from data_layer.attention import get_next_action_candidates_for_initiative
|
||||
from services.initiatives import get_initiative
|
||||
from tenant_context import TenantContext
|
||||
|
||||
|
|
@ -296,12 +297,27 @@ def get_initiative_steering_snapshot(
|
|||
planned_reviews_due=planned_reviews_due,
|
||||
)
|
||||
|
||||
upcoming_milestones = sorted(
|
||||
[
|
||||
m
|
||||
for m in milestones
|
||||
if m["status"] in ("planned", "active", "at_risk")
|
||||
],
|
||||
key=lambda m: (m["target_date"] is None, m["target_date"] or ""),
|
||||
)[:5]
|
||||
|
||||
next_actions = get_next_action_candidates_for_initiative(
|
||||
ctx, initiative_id=initiative_id, limit=5
|
||||
)
|
||||
|
||||
return {
|
||||
"initiative_id": initiative_id,
|
||||
"initiative_title": initiative["title"],
|
||||
"initiative_status": initiative["status"],
|
||||
"operating_phase": phase,
|
||||
"phase_signals": phase_signals,
|
||||
"upcoming_milestones": upcoming_milestones,
|
||||
"next_actions": next_actions,
|
||||
"counts": {
|
||||
"actions_open": open_actions,
|
||||
"actions_blocked": blocked_actions,
|
||||
|
|
|
|||
|
|
@ -49,6 +49,17 @@ def workspace_blocked_actions(
|
|||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/actions/today")
|
||||
def workspace_today_actions(
|
||||
limit: Optional[int] = Query(default=15, ge=1, le=50),
|
||||
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
|
||||
):
|
||||
try:
|
||||
return dl_actions.get_my_today_actions(_require_actor_ctx(ctx), limit=limit or 15)
|
||||
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),
|
||||
|
|
|
|||
73
backend/tests/test_ap10b_steering_ui.py
Normal file
73
backend/tests/test_ap10b_steering_ui.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"""AP0.10b — Steering UI Minimum tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from tests.factories import provision_user_in_tenant
|
||||
from tests.test_initiatives_actions import (
|
||||
_auth,
|
||||
_create_action,
|
||||
_create_initiative,
|
||||
_login,
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_next_actions(client):
|
||||
user = provision_user_in_tenant(tenant_role="member")
|
||||
token = _login(client, user)
|
||||
initiative_id = _create_initiative(client, token).json()["id"]
|
||||
client.post(
|
||||
f"/api/initiatives/{initiative_id}/blockers",
|
||||
json={"title": "Blocker für NextAction"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
|
||||
res = client.get("/api/workspace/next-actions?limit=5", headers=_auth(token))
|
||||
assert res.status_code == 200
|
||||
items = res.json()
|
||||
assert len(items) >= 1
|
||||
assert items[0]["recommended_action"]
|
||||
assert items[0]["initiative_id"]
|
||||
|
||||
|
||||
def test_workspace_today_actions(client):
|
||||
user = provision_user_in_tenant(tenant_role="member")
|
||||
token = _login(client, user)
|
||||
initiative_id = _create_initiative(client, token).json()["id"]
|
||||
action = _create_action(
|
||||
client, token, initiative_id, assigned_actor_ids=[user["actor_id"]]
|
||||
).json()
|
||||
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
||||
client.patch(
|
||||
f"/api/actions/{action['id']}",
|
||||
json={"due_at": past},
|
||||
headers=_auth(token),
|
||||
)
|
||||
|
||||
res = client.get("/api/workspace/actions/today", headers=_auth(token))
|
||||
assert res.status_code == 200
|
||||
items = res.json()
|
||||
assert len(items) >= 1
|
||||
assert items[0]["is_overdue"] is True
|
||||
|
||||
|
||||
def test_steering_snapshot_v2_fields(client):
|
||||
user = provision_user_in_tenant(tenant_role="member")
|
||||
token = _login(client, user)
|
||||
initiative_id = _create_initiative(client, token).json()["id"]
|
||||
client.post(
|
||||
f"/api/initiatives/{initiative_id}/milestones",
|
||||
json={"title": "M1", "status": "planned", "target_date": "2026-12-31"},
|
||||
headers=_auth(token),
|
||||
)
|
||||
|
||||
snap = client.get(
|
||||
f"/api/initiatives/{initiative_id}/steering-snapshot",
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert snap.status_code == 200
|
||||
data = snap.json()
|
||||
assert "upcoming_milestones" in data
|
||||
assert "next_actions" in data
|
||||
assert len(data["upcoming_milestones"]) >= 1
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
APP_VERSION = "0.10.0-ap0.10"
|
||||
APP_VERSION = "0.10.1-ap0.10b"
|
||||
DB_SCHEMA_VERSION = "008"
|
||||
APP_NAME = "jinkendo-kairo"
|
||||
|
|
|
|||
189
docs/product/Kairo_MVP_Usability_Recovery_Plan_v0.1.md
Normal file
189
docs/product/Kairo_MVP_Usability_Recovery_Plan_v0.1.md
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
# Kairo — MVP Usability Recovery Plan
|
||||
|
||||
**Status:** Planungsdokument (Product Direction)
|
||||
**Stand:** 2026-07-05
|
||||
**Auslöser:** AP0.8–0.10 liefern Entitäten, aber **keinen täglich nutzbaren Steuerungswert** — schlechter als To-do-/PM-Tools für den Alltag.
|
||||
|
||||
---
|
||||
|
||||
## 1. Diagnose
|
||||
|
||||
| Was Nutzer erwarten | Was Kairo heute liefert |
|
||||
|---------------------|-------------------------|
|
||||
| „Was mache ich jetzt?“ | Parallele Listen, keine Priorität in der UI |
|
||||
| „Wo steht das Vorhaben?“ | Heuristik-Badge ohne Lifecycle-Modell |
|
||||
| „Was kommt als Nächstes?“ | NextAction-API existiert, **kein Widget** |
|
||||
| „Was blockiert?“ | Blocker-Liste, kaum mit Maßnahme verbunden |
|
||||
| Meilensteine / Fristen | CRUD-Sektion, kein Horizont |
|
||||
| Weniger als Todoist/Linear | **Ja** — mehr Klicks, weniger Antworten |
|
||||
|
||||
Das Product Reset (`Kairo_Product_Definition_and_MVP_Reset_v0.1.md` §4) warnt genau davor:
|
||||
*Initiative → Action allein ist weniger als To-do-Listen und verfehlt den Program-Director-Anspruch.*
|
||||
|
||||
**Technisch:** Foundation + OM-Inventar ✓
|
||||
**Produkt:** Steuerungsantwort auf die Leitfrage ✗
|
||||
|
||||
---
|
||||
|
||||
## 2. MVP-Nutzbarkeits-Bar (Definition of Done)
|
||||
|
||||
Kairo ist **minimal täglich nutzbar**, wenn ein Nutzer ohne Erklärung:
|
||||
|
||||
1. **In ≤30 Sekunden** seine nächste sinnvolle Maßnahme findet (Workspace).
|
||||
2. **Pro Vorhaben** sieht: Phase (grober Zustand), nächster Meilenstein, größtes Hindernis, empfohlene Aktion.
|
||||
3. **Backlog vs. Maßnahme** unterscheiden und Backlog in Maßnahme überführen kann.
|
||||
4. **Blocker** erkennt, die echte Arbeit stoppen (nicht nur als Liste).
|
||||
5. Das **ohne** Prompt/KI/MCP/Method Designer schafft.
|
||||
|
||||
Das ist **nicht** Feature-Parität mit Todoist — sondern **Steuerungsmehrwert** gegenüber reinen Listen:
|
||||
|
||||
> Nicht nur *was* existiert, sondern *was jetzt wirkt*.
|
||||
|
||||
---
|
||||
|
||||
## 3. Strategische Korrektur der Roadmap
|
||||
|
||||
Die bisherige Reihenfolge:
|
||||
|
||||
```text
|
||||
AP0.8 Entitäten → AP0.9 Entitäten → AP0.10 Validation → Sprint 1 Steering Core
|
||||
```
|
||||
|
||||
**Problem:** Validation auf CRUD-Listen liefert nur „fehlt alles“ — ohne vorherige **Steering Surface**.
|
||||
|
||||
**Korrigierte Reihenfolge:**
|
||||
|
||||
```text
|
||||
AP0.10b Steering UI Minimum ← zuerst (Nutzen sichtbar)
|
||||
AP0.10c Initiative Flow Minimum
|
||||
AP0.10d MVP Validation (5 Vorhaben)
|
||||
AP1.0 Steering Core Foundation (nur mit Validation-Ergebnis)
|
||||
AP1.1 Method Registry minimal
|
||||
```
|
||||
|
||||
**Regel bis AP1.0:** Keine neuen OM-Tabellen. Nur verbinden, anzeigen, orchestrieren, validieren.
|
||||
|
||||
---
|
||||
|
||||
## 4. Empfohlene Pakete
|
||||
|
||||
### AP0.10b — Steering UI Minimum (Priorität 1)
|
||||
|
||||
**Ziel:** Leitfrage im Workspace beantworten.
|
||||
|
||||
| # | Lieferung | Aufwand | Nutzen |
|
||||
|---|-----------|---------|--------|
|
||||
| 1 | **NextActionWidget** — `/api/workspace/next-actions`, Top 5, Link zu Vorhaben/Maßnahme | S | ★★★ |
|
||||
| 2 | **Workspace „Heute“** — meine offenen Maßnahmen sortiert: überfällig → blockiert → high priority | S | ★★★ |
|
||||
| 3 | **Steuerungszustand erweitern** — nächster Meilenstein (`target_date`), Top-Next-Action *für dieses Vorhaben*, Klartext statt `work_in_progress` | M | ★★★ |
|
||||
| 4 | Attention-Widget: Kind + empfohlene Aktion lesbar | S | ★★ |
|
||||
|
||||
**Nicht-Scope:** Redesign, neue Entitäten, KI-Ranking.
|
||||
|
||||
---
|
||||
|
||||
### AP0.10c — Initiative Flow Minimum (Priorität 2)
|
||||
|
||||
**Ziel:** Vorhaben-Detail beantwortet „Wo stehe ich?“ — nicht „Hier sind 8 Listen“.
|
||||
|
||||
| # | Lieferung | Aufwand | Nutzen |
|
||||
|---|-----------|---------|--------|
|
||||
| 1 | **Maßnahmen-zentrierte Ansicht** — Blocker/Evidence/Reviews unter der Maßnahme, nicht nur global | M | ★★★ |
|
||||
| 2 | **Sektionen einklappen** — Backlog/Evidence/Decision/Recurring unter „Erweitert“ | S | ★★ |
|
||||
| 3 | **Meilenstein-Horizont** — „Als Nächstes: …“ (planned/active, sortiert nach `target_date`) | S | ★★ |
|
||||
| 4 | **Flows sichtbar machen** — Blocker→blockiert, Review→review_required→done (Hinweis in UI) | S | ★★ |
|
||||
| 5 | Snapshot-API in UI: `next_actions[]`, `upcoming_milestones[]` Felder | M | ★★★ |
|
||||
|
||||
---
|
||||
|
||||
### AP0.10d — MVP Validation (Priorität 3)
|
||||
|
||||
**Ziel:** Mit echten Vorhaben prüfen — dokumentiert, nicht Bauchgefühl.
|
||||
|
||||
Testvorhaben (Roadmap): Kairo, Gewaltschutzkurs, Karate, Familienorganisation, Server/Mindnet.
|
||||
|
||||
**Checkliste pro Vorhaben (15 Min):**
|
||||
|
||||
- [ ] Nächste Aktion in ≤30s gefunden?
|
||||
- [ ] Blocker früh sichtbar?
|
||||
- [ ] Meilenstein-Horizont hilfreich?
|
||||
- [ ] Backlog→Maßnahme sinnvoll?
|
||||
- [ ] Besser als Todo-Tool für *dieses* Vorhaben? Wenn nein: was fehlt?
|
||||
|
||||
**Output:** `Sprint0_AP0_10_Validation_Report_v0.1.md` + Go/No-Go für AP1.0.
|
||||
|
||||
---
|
||||
|
||||
### AP1.0 — Steering Core Foundation (Priorität 4, nach Validation)
|
||||
|
||||
**Ziel:** Heuristik durch echte Steuerungsstruktur ersetzen — schmal starten.
|
||||
|
||||
| Baustein | Minimal-Inhalt |
|
||||
|----------|----------------|
|
||||
| `SteeringContext` | `initiative_id`, `method_key` (default `generic_program`), `lifecycle_step` |
|
||||
| Lifecycle | 5 Schritte: `intake`, `planning`, `execution`, `review`, `closure` |
|
||||
| `operating_phase` | Liest aus `lifecycle_step`, nicht nur Zähler |
|
||||
| Hooks (stub) | `on_blocker_open`, `on_next_action_requested` — Logging only |
|
||||
| Migration | `009_steering_context.sql` |
|
||||
|
||||
**Nicht in AP1.0:** Method Designer, Workflow Runtime, Dependencies, Sprints.
|
||||
|
||||
---
|
||||
|
||||
### AP1.1 — Method Registry minimal (Priorität 5)
|
||||
|
||||
- Eine eingebaute Methode: **`generic_program`**
|
||||
- Signal Rules + NextAction Strategy für diese Methode
|
||||
- Später: `software_delivery`, `content_development`, …
|
||||
|
||||
---
|
||||
|
||||
## 5. Was bewusst warten muss
|
||||
|
||||
| Thema | Warum später |
|
||||
|-------|--------------|
|
||||
| Project-Entität | Erst wenn Initiative-Steuerung trägt |
|
||||
| Abhängigkeiten / Gantt | Steering Core + Validation |
|
||||
| Sprints / Work Cycles | Method Registry |
|
||||
| KI-Priorisierung | Regelbasiert muss zuerst gut genug sein |
|
||||
| UI-Redesign | Erst wenn Informationsarchitektur steht |
|
||||
| Weitere OM-Tabellen | Stop — Inventar reicht |
|
||||
|
||||
---
|
||||
|
||||
## 6. Erfolgskriterien (Go für Sprint 1)
|
||||
|
||||
Sprint 1 (Steering Core) startet nur wenn:
|
||||
|
||||
1. **≥3 von 5** Testvorhaben: „Hilft bei täglicher Steuerung“ = ja (schwach ja reicht nicht).
|
||||
2. **Leitfrage** auf Workspace in User-Test beantwortbar ohne Schulung.
|
||||
3. **NextActionWidget** und erweiterter Steuerungszustand deployed und genutzt.
|
||||
4. Validation-Report dokumentiert verbleibende Lücken — keine stillen Annahmen.
|
||||
|
||||
---
|
||||
|
||||
## 7. Empfohlene unmittelbare Reihenfolge (nächste 2–3 Umsetzungssprints)
|
||||
|
||||
```text
|
||||
Sprint A (AP0.10b) NextActionWidget + Workspace Heute + Steuerungszustand v2
|
||||
Sprint B (AP0.10c) Maßnahmen-Hub + Meilenstein-Horizont + UI entrauschen
|
||||
Sprint C (AP0.10d) Validation + Report + AP1.0 Auftrag schärfen
|
||||
Sprint D (AP1.0) SteeringContext + Lifecycle stub
|
||||
```
|
||||
|
||||
**Sofort starten mit Sprint A** — größter Hebel, kleinster Scope, API existiert bereits.
|
||||
|
||||
---
|
||||
|
||||
## 8. Einordnung: Kairo vs. To-do-Tool
|
||||
|
||||
Kairo muss **nicht** mehr Features als Todoist haben.
|
||||
Es muss **eine Frage besser beantworten**:
|
||||
|
||||
> *Welcher nächste Schritt bringt dieses Vorhaben jetzt am wirksamsten voran — und warum?*
|
||||
|
||||
Solange diese Frage in der UI nicht zentral beantwortet wird, ist jeder OM-Ausbau wertlos.
|
||||
|
||||
---
|
||||
|
||||
*v0.1 — Product Direction, ergänzt Corrected MVP Roadmap AP0.10*
|
||||
31
docs/sprints/Sprint0_AP0_10b_Assignment_v0.1.md
Normal file
31
docs/sprints/Sprint0_AP0_10b_Assignment_v0.1.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# AP0.10b — Steering UI Minimum
|
||||
## Implementierungsauftrag v0.1
|
||||
|
||||
**Status:** umgesetzt
|
||||
**Stand:** 2026-07-05
|
||||
**Version:** `0.10.1-ap0.10b` · Schema `008`
|
||||
**Basis:** `Kairo_MVP_Usability_Recovery_Plan_v0.1.md`
|
||||
|
||||
---
|
||||
|
||||
## Ziel
|
||||
|
||||
Leitfrage im Workspace beantworten — **ohne** neue Tabellen, **ohne** Steering Core.
|
||||
|
||||
## Geliefert
|
||||
|
||||
| # | Lieferung |
|
||||
|---|-----------|
|
||||
| 1 | **NextActionWidget** — Top 5, Link zum Vorhaben |
|
||||
| 2 | **WorkspaceTodayWidget** — überfällig → blockiert → Priorität |
|
||||
| 3 | **Steuerungszustand v2** — Phase-Klartext, Meilenstein-Horizont, Next Actions pro Vorhaben |
|
||||
| 4 | **Attention** — Kind-Labels lesbar |
|
||||
| 5 | API `/api/workspace/actions/today`, Snapshot-Felder `upcoming_milestones`, `next_actions` |
|
||||
|
||||
## Abnahme
|
||||
|
||||
Workspace zeigt oben Next Actions + Heute; Vorhaben-Detail zeigt erweiterten Steuerungszustand; pytest `test_ap10b_steering_ui.py`.
|
||||
|
||||
---
|
||||
|
||||
*Nächster Schritt: AP0.10c Initiative Flow Minimum*
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "kairo-jinkendo-frontend",
|
||||
"version": "0.10.0-ap0.10",
|
||||
"version": "0.10.1-ap0.10b",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ export function listWorkspaceBlockedActions() {
|
|||
return apiFetch('/api/workspace/actions/blocked')
|
||||
}
|
||||
|
||||
export function listWorkspaceTodayActions(limit = 15) {
|
||||
return apiFetch(`/api/workspace/actions/today?limit=${limit}`)
|
||||
}
|
||||
|
||||
export function listWorkspaceActiveInitiatives(limit = 5) {
|
||||
return apiFetch(`/api/workspace/initiatives/active?limit=${limit}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,20 @@
|
|||
import { PHASE_LABELS } from '../constants/operating.js'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
PHASE_LABELS,
|
||||
PHASE_DESCRIPTIONS,
|
||||
SIGNAL_LABELS,
|
||||
NEXT_ACTION_KIND_LABELS,
|
||||
} from '../constants/operating.js'
|
||||
import { MILESTONE_STATUS_LABELS } from '../constants/status.js'
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return '—'
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString('de-DE')
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
export function SteeringSnapshotPanel({ snapshot, loading, error }) {
|
||||
if (loading) {
|
||||
|
|
@ -22,8 +38,15 @@ export function SteeringSnapshotPanel({ snapshot, loading, error }) {
|
|||
)
|
||||
}
|
||||
|
||||
const { operating_phase, phase_signals, counts } = snapshot
|
||||
const {
|
||||
operating_phase,
|
||||
phase_signals,
|
||||
counts,
|
||||
upcoming_milestones = [],
|
||||
next_actions = [],
|
||||
} = snapshot
|
||||
const phaseLabel = PHASE_LABELS[operating_phase] || operating_phase
|
||||
const phaseDesc = PHASE_DESCRIPTIONS[operating_phase] || ''
|
||||
|
||||
return (
|
||||
<section className="card steering-snapshot">
|
||||
|
|
@ -31,11 +54,54 @@ export function SteeringSnapshotPanel({ snapshot, loading, error }) {
|
|||
<h2>Steuerungszustand</h2>
|
||||
<span className="badge status-badge status-active">{phaseLabel}</span>
|
||||
</div>
|
||||
|
||||
{phaseDesc && <p className="steering-phase-desc">{phaseDesc}</p>}
|
||||
|
||||
{phase_signals?.length > 0 && (
|
||||
<p className="muted snapshot-signals">
|
||||
Signale: {phase_signals.join(' · ')}
|
||||
{phase_signals.map((s) => SIGNAL_LABELS[s] || s).join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{next_actions.length > 0 && (
|
||||
<div className="steering-next-actions">
|
||||
<h3>Nächste Schritte für dieses Vorhaben</h3>
|
||||
<ol className="item-list compact-list">
|
||||
{next_actions.map((item, i) => (
|
||||
<li key={`${item.kind}-${i}`} className="list-item card-list-item compact">
|
||||
<div className="list-item-main">
|
||||
<span className="next-action-rank">{i + 1}</span>
|
||||
<strong>{item.title}</strong>
|
||||
<span className="muted list-item-sub">
|
||||
{NEXT_ACTION_KIND_LABELS[item.kind] || item.kind}
|
||||
{item.recommended_action ? ` — ${item.recommended_action}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{upcoming_milestones.length > 0 && (
|
||||
<div className="steering-milestones">
|
||||
<h3>Meilenstein-Horizont</h3>
|
||||
<ul className="item-list compact-list">
|
||||
{upcoming_milestones.map((ms) => (
|
||||
<li key={ms.id} className="list-item card-list-item compact">
|
||||
<div className="list-item-main">
|
||||
<strong>{ms.title}</strong>
|
||||
<span className="muted list-item-sub">
|
||||
{MILESTONE_STATUS_LABELS[ms.status] || ms.status}
|
||||
{ms.target_date ? ` · Ziel: ${formatDate(ms.target_date)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<dl className="snapshot-counts">
|
||||
<div>
|
||||
<dt>Offene Maßnahmen</dt>
|
||||
|
|
@ -62,8 +128,9 @@ export function SteeringSnapshotPanel({ snapshot, loading, error }) {
|
|||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<p className="muted snapshot-hint">
|
||||
Kanonischer Operating Cycle — noch heuristisch, später methodengeführt über Steering Core.
|
||||
Heuristischer Steuerungszustand — später methodengeführt über Steering Core.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -21,3 +21,48 @@ export const PHASE_LABELS = {
|
|||
adapt: 'Anpassung',
|
||||
closure: 'Abschluss',
|
||||
}
|
||||
|
||||
export const PHASE_DESCRIPTIONS = {
|
||||
capture: 'Ideen und Eingänge erfassen — noch wenig strukturierte Arbeit.',
|
||||
triage: 'Backlog wartet auf Commit — noch keine offene nächste Maßnahme.',
|
||||
structure: 'Vorhaben braucht Struktur — keine offene Maßnahme, Backlog leer.',
|
||||
commit: 'Maßnahmen committen und zuweisen.',
|
||||
execute: 'Aktive Umsetzung — offene Maßnahmen oder Blocker bearbeiten.',
|
||||
verify: 'Nachweise und Ergebnisse belegen.',
|
||||
review: 'Reviews oder Maßnahmen mit Review-Status klären.',
|
||||
adapt: 'Anpassen — Backlog, Meilensteine oder Prioritäten prüfen.',
|
||||
closure: 'Vorhaben abgeschlossen oder archiviert.',
|
||||
}
|
||||
|
||||
export const SIGNAL_LABELS = {
|
||||
initiative_terminal: 'Vorhaben abgeschlossen',
|
||||
backlog_awaiting_commit: 'Backlog wartet auf Maßnahme',
|
||||
no_open_work: 'Keine offene Arbeit',
|
||||
actions_blocked: 'Maßnahmen blockiert',
|
||||
open_blockers: 'Offene Blocker',
|
||||
actions_need_review: 'Maßnahmen brauchen Review',
|
||||
reviews_due: 'Reviews fällig',
|
||||
work_in_progress: 'Arbeit in Umsetzung',
|
||||
}
|
||||
|
||||
export const NEXT_ACTION_KIND_LABELS = {
|
||||
resolve_blocker: 'Blocker klären',
|
||||
assign_action: 'Maßnahme zuweisen',
|
||||
convert_backlog: 'Backlog umwandeln',
|
||||
create_action: 'Maßnahme anlegen',
|
||||
review_milestone: 'Meilenstein prüfen',
|
||||
}
|
||||
|
||||
export const ATTENTION_KIND_LABELS = {
|
||||
blocked_action: 'Blockierte Maßnahme',
|
||||
open_blocker: 'Offener Blocker',
|
||||
high_priority_action: 'High Priority',
|
||||
unassigned_action: 'Ohne Zuweisung',
|
||||
initiative_without_next_action: 'Keine nächste Maßnahme',
|
||||
stale_initiative: 'Inaktives Vorhaben',
|
||||
milestone_at_risk: 'Meilenstein gefährdet',
|
||||
overdue_action: 'Überfällig',
|
||||
review_due: 'Review fällig',
|
||||
recurring_due: 'Wiederkehrend fällig',
|
||||
action_review_required: 'Review ausstehend',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export function WorkspacePage() {
|
|||
<header className="page-header">
|
||||
<div>
|
||||
<h1>Workspace</h1>
|
||||
<p className="page-lead">Dein Überblick über Vorhaben und Maßnahmen.</p>
|
||||
<p className="page-lead">Was ist dein nächster sinnvoller Schritt?</p>
|
||||
</div>
|
||||
{hasCapability('kairo.initiative.manage') && (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ describe('widgetRegistry', () => {
|
|||
expect(keys).toContain('kairo.blocked_actions')
|
||||
expect(keys).toContain('kairo.tenant_context')
|
||||
expect(keys).toContain('kairo.workspace_summary')
|
||||
expect(keys).toContain('kairo.next_actions')
|
||||
expect(keys).toContain('kairo.workspace_today')
|
||||
expect(keys).toContain('kairo.attention')
|
||||
})
|
||||
|
||||
|
|
@ -20,7 +22,7 @@ describe('widgetRegistry', () => {
|
|||
'kairo.initiative.read',
|
||||
'kairo.workspace.read',
|
||||
])
|
||||
expect(all.length).toBe(6)
|
||||
expect(all.length).toBe(8)
|
||||
|
||||
const readOnly = getWidgetsForArea('workspace', ['kairo.initiative.read'])
|
||||
expect(readOnly.some((w) => w.key === 'kairo.my_open_actions')).toBe(false)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { BlockedActionsWidget } from '../widgets/BlockedActionsWidget.jsx'
|
|||
import { TenantContextWidget } from '../widgets/TenantContextWidget.jsx'
|
||||
import { WorkspaceSummaryWidget } from '../widgets/WorkspaceSummaryWidget.jsx'
|
||||
import { AttentionWidget } from '../widgets/AttentionWidget.jsx'
|
||||
import { NextActionWidget } from '../widgets/NextActionWidget.jsx'
|
||||
import { WorkspaceTodayWidget } from '../widgets/WorkspaceTodayWidget.jsx'
|
||||
|
||||
/** @typedef {import('react').ComponentType<any>} WidgetComponent */
|
||||
|
||||
|
|
@ -28,6 +30,24 @@ export const WIDGETS = [
|
|||
defaultOrder: 0,
|
||||
component: TenantContextWidget,
|
||||
},
|
||||
{
|
||||
key: 'kairo.next_actions',
|
||||
title: 'Nächste Schritte',
|
||||
description: 'Regelbasierte NextAction-Empfehlungen',
|
||||
area: 'workspace',
|
||||
requiredCapability: 'kairo.workspace.read',
|
||||
defaultOrder: 2,
|
||||
component: NextActionWidget,
|
||||
},
|
||||
{
|
||||
key: 'kairo.workspace_today',
|
||||
title: 'Heute',
|
||||
description: 'Meine Maßnahmen — überfällig zuerst',
|
||||
area: 'workspace',
|
||||
requiredCapability: 'kairo.workspace.read',
|
||||
defaultOrder: 4,
|
||||
component: WorkspaceTodayWidget,
|
||||
},
|
||||
{
|
||||
key: 'kairo.workspace_summary',
|
||||
title: 'Überblick',
|
||||
|
|
@ -52,7 +72,7 @@ export const WIDGETS = [
|
|||
description: 'Offene Maßnahmen, die mir zugewiesen sind',
|
||||
area: 'workspace',
|
||||
requiredCapability: 'kairo.action.read',
|
||||
defaultOrder: 10,
|
||||
defaultOrder: 25,
|
||||
component: MyOpenActionsWidget,
|
||||
},
|
||||
{
|
||||
|
|
@ -61,7 +81,7 @@ export const WIDGETS = [
|
|||
description: 'Mir zugewiesene blockierte Maßnahmen',
|
||||
area: 'workspace',
|
||||
requiredCapability: 'kairo.action.read',
|
||||
defaultOrder: 20,
|
||||
defaultOrder: 35,
|
||||
component: BlockedActionsWidget,
|
||||
},
|
||||
{
|
||||
|
|
@ -70,7 +90,7 @@ export const WIDGETS = [
|
|||
description: 'Aktive und pausierte Vorhaben im Tenant',
|
||||
area: 'workspace',
|
||||
requiredCapability: 'kairo.initiative.read',
|
||||
defaultOrder: 30,
|
||||
defaultOrder: 40,
|
||||
component: InitiativesWidget,
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -629,6 +629,55 @@
|
|||
margin: 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.steering-phase-desc {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--jk-text);
|
||||
}
|
||||
|
||||
.steering-next-actions,
|
||||
.steering-milestones {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.steering-next-actions h3,
|
||||
.steering-milestones h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.compact-list .list-item.compact {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.next-action-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.next-action-rank {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
border-radius: 999px;
|
||||
background: var(--jk-surface-raised, #f3f4f6);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
|
||||
.next-action-kind {
|
||||
font-size: 0.75rem;
|
||||
margin-right: 0.35rem;
|
||||
}
|
||||
|
||||
.recommended-action {
|
||||
color: var(--jk-accent, #2563eb);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-links {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { LoadingState } from '../components/LoadingState.jsx'
|
|||
import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||
import { EmptyState } from '../components/EmptyState.jsx'
|
||||
|
||||
import { ATTENTION_KIND_LABELS } from '../constants/operating.js'
|
||||
|
||||
const SEVERITY_LABELS = {
|
||||
critical: 'Kritisch',
|
||||
warning: 'Warnung',
|
||||
|
|
@ -69,6 +71,9 @@ export function AttentionWidget() {
|
|||
<span className={`attention-severity attention-severity--${item.severity}`}>
|
||||
{SEVERITY_LABELS[item.severity] || item.severity}
|
||||
</span>
|
||||
<span className="muted list-item-sub attention-kind">
|
||||
{ATTENTION_KIND_LABELS[item.kind] || item.kind}
|
||||
</span>
|
||||
<strong>{item.title}</strong>
|
||||
{item.summary && <p className="list-item-desc muted">{item.summary}</p>}
|
||||
</div>
|
||||
|
|
|
|||
78
frontend/src/widgets/NextActionWidget.jsx
Normal file
78
frontend/src/widgets/NextActionWidget.jsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getNextActionCandidates } from '../api/attention.js'
|
||||
import { NEXT_ACTION_KIND_LABELS } from '../constants/operating.js'
|
||||
import { EmptyState } from '../components/EmptyState.jsx'
|
||||
import { ErrorState } from '../components/ErrorState.jsx'
|
||||
import { LoadingState } from '../components/LoadingState.jsx'
|
||||
import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||
|
||||
export function NextActionWidget() {
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await getNextActionCandidates(5)
|
||||
setItems(Array.isArray(data) ? data : [])
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<WidgetCard
|
||||
title="Nächste sinnvolle Schritte"
|
||||
subtitle="Was bringt deine Vorhaben jetzt am wirksamsten voran?"
|
||||
className="widget-card--span-full"
|
||||
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 && items.length === 0 && (
|
||||
<EmptyState message="Keine Empfehlungen — entweder alles erledigt oder noch nichts zu steuern." />
|
||||
)}
|
||||
{!loading && !error && items.length > 0 && (
|
||||
<ol className="item-list next-action-list">
|
||||
{items.map((item, index) => (
|
||||
<li key={`${item.kind}-${item.initiative_id}-${item.action_id || item.backlog_item_id || index}`} className="list-item card-list-item">
|
||||
<div className="list-item-main">
|
||||
<span className="next-action-rank">{index + 1}</span>
|
||||
<span className="badge status-badge status-active next-action-kind">
|
||||
{NEXT_ACTION_KIND_LABELS[item.kind] || item.kind}
|
||||
</span>
|
||||
<strong>{item.title}</strong>
|
||||
{item.summary && <p className="list-item-desc muted">{item.summary}</p>}
|
||||
{item.recommended_action && (
|
||||
<p className="list-item-sub recommended-action">
|
||||
→ {item.recommended_action}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="list-item-meta">
|
||||
{item.initiative_id && (
|
||||
<Link to={`/initiatives/${item.initiative_id}`} className="btn btn-primary btn-sm">
|
||||
Zum Vorhaben
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</WidgetCard>
|
||||
)
|
||||
}
|
||||
98
frontend/src/widgets/WorkspaceTodayWidget.jsx
Normal file
98
frontend/src/widgets/WorkspaceTodayWidget.jsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { listWorkspaceTodayActions } from '../api/workspace.js'
|
||||
import { StatusBadge } from '../components/StatusBadge.jsx'
|
||||
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
||||
import { EmptyState } from '../components/EmptyState.jsx'
|
||||
import { ErrorState } from '../components/ErrorState.jsx'
|
||||
import { LoadingState } from '../components/LoadingState.jsx'
|
||||
import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||
|
||||
function formatDue(iso) {
|
||||
if (!iso) return null
|
||||
try {
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
export function WorkspaceTodayWidget() {
|
||||
const [actions, setActions] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await listWorkspaceTodayActions(10)
|
||||
setActions(Array.isArray(data) ? data : [])
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<WidgetCard
|
||||
title="Heute"
|
||||
subtitle="Deine Maßnahmen — überfällig und dringend zuerst"
|
||||
actions={
|
||||
<button type="button" className="btn btn-ghost" onClick={load} disabled={loading}>
|
||||
Aktualisieren
|
||||
</button>
|
||||
}
|
||||
footer={
|
||||
actions.length > 0 ? (
|
||||
<Link to="/my-actions" className="link-inline">
|
||||
Alle meine Maßnahmen →
|
||||
</Link>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{loading && <LoadingState />}
|
||||
{!loading && error && <ErrorState message={error} onRetry={load} />}
|
||||
{!loading && !error && actions.length === 0 && (
|
||||
<EmptyState message="Keine Maßnahmen für heute — prüfe „Nächste sinnvolle Schritte“ oben." />
|
||||
)}
|
||||
{!loading && !error && actions.length > 0 && (
|
||||
<ul className="item-list">
|
||||
{actions.map((action) => (
|
||||
<li key={action.id} className="list-item card-list-item">
|
||||
<div className="list-item-main">
|
||||
{action.is_overdue && (
|
||||
<span className="badge status-badge status-blocked">Überfällig</span>
|
||||
)}
|
||||
<strong>{action.title}</strong>
|
||||
{action.initiative_title && (
|
||||
<span className="muted list-item-sub">{action.initiative_title}</span>
|
||||
)}
|
||||
{action.due_at && (
|
||||
<span className="muted list-item-sub">Fällig: {formatDue(action.due_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="list-item-meta">
|
||||
<StatusBadge status={action.status} />
|
||||
<PriorityBadge priority={action.priority} />
|
||||
<Link to={`/initiatives/${action.initiative_id}`} className="btn btn-secondary btn-sm">
|
||||
Öffnen
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</WidgetCard>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user