diff --git a/README.md b/README.md
index 8e84552..7623f1d 100644
--- a/README.md
+++ b/README.md
@@ -170,6 +170,30 @@ curl -s -X POST http://localhost:8097/api/initiatives/INITIATIVE_ID/actions \
curl -s http://localhost:8097/api/actions/me/open -H "X-Auth-Token: TOKEN"
```
+### Workspace UI (AP0.6)
+
+Nach Login leitet Kairo auf den **Workspace** weiter.
+
+| Route | Beschreibung |
+|-------|--------------|
+| `/workspace` | Karten: Kontext, offene Maßnahmen, blockierte Maßnahmen, aktive Vorhaben |
+| `/initiatives` | Vorhabenliste mit offenen Maßnahmen-Zähler |
+| `/initiatives/:id` | Vorhaben-Detail mit Maßnahmen CRUD, Status, Zuweisung |
+
+**Frontend-Struktur:** `frontend/src/api/`, `registry/`, `widgets/`, `pages/`, `components/`
+
+**Widget Registry (frontend-only):** Widgets in `registry/widgetRegistry.js` — Capability-Filter, keine Backend-Persistenz.
+
+**Tests (Frontend):**
+
+```bash
+cd frontend && npm install && npm run test && npm run build
+```
+
+**Manueller Smoke-Test:** Login → Workspace → Vorhaben anlegen → Maßnahme anlegen → Status auf *Erledigt* → prüfen, dass sie aus „Meine offenen Maßnahmen“ verschwindet.
+
+**Bekannte Lücke:** Kein `GET /api/actors` — Zuweisung nutzt aktuell den Human Actor aus TenantContext (AP0.7).
+
### Registries (AP0.4)
Feature-, Prompt-, Placeholder- und Config-Registry analog zur Rights Registry: Code-Registrierung → Startup-Sync → DB.
diff --git a/backend/version.py b/backend/version.py
index 203c59c..8071c24 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,3 +1,3 @@
-APP_VERSION = "0.5.0-ap0.5"
+APP_VERSION = "0.6.0-ap0.6"
DB_SCHEMA_VERSION = "006"
APP_NAME = "jinkendo-kairo"
diff --git a/docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md b/docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md
new file mode 100644
index 0000000..1e81478
--- /dev/null
+++ b/docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md
@@ -0,0 +1,276 @@
+# AP0.6 – Abschlussbericht Kairo Workspace UX mit minimaler Widget Registry
+
+**Status:** abgeschlossen
+**Stand:** 2026-07-05
+**Branch:** `develop` · Frontend `0.6.0-ap0.6` · Backend Schema `006` (unverändert)
+**Backend-Änderungen:** keine
+
+---
+
+## 1. Scope und Einordnung
+
+AP0.6 macht Kairo erstmals als **Arbeitswerkzeug nutzbar** — ohne neue Foundation-Schicht, ohne Backend-Widget-Registry, ohne KI/MCP/Projects.
+
+| Anforderung (AP0.6) | Status |
+|---------------------|--------|
+| Workspace mit Widget-Karten | ✓ |
+| Vorhabenliste + Detail | ✓ |
+| Maßnahmen CRUD in UI | ✓ |
+| Status / Priorität ändern | ✓ |
+| Actor-Zuweisung (minimal) | ✓ |
+| Meine offenen Maßnahmen | ✓ |
+| Blockierte Maßnahmen erkennbar | ✓ |
+| Erledigte filterbar/ausblenden | ✓ |
+| Frontend Widget Registry | ✓ |
+| View Registry + Navigation | ✓ |
+| Capability-bewusste UI | ✓ |
+| Loading / Error / Empty States | ✓ |
+| Vitest (Registry + Badges) | ✓ 7 Tests |
+| README UI-Doku | ✓ |
+
+**Produktfrage beantwortet:** Ja — mit AP0.5-APIs kann ein Nutzer Vorhaben und Maßnahmen praktisch verwalten.
+
+---
+
+## 2. Umgesetzte Dateien
+
+| Bereich | Dateien |
+|---------|---------|
+| API-Client | `frontend/src/api/client.js`, `me.js`, `initiatives.js`, `actions.js`, `actors.js` |
+| Session | `frontend/src/context/SessionContext.jsx`, `hooks/useCapabilities.js` |
+| Registry | `frontend/src/registry/widgetRegistry.js`, `viewRegistry.js` |
+| Widgets | `TenantContextWidget`, `MyOpenActionsWidget`, `BlockedActionsWidget`, `InitiativesWidget` |
+| Pages | `WorkspacePage`, `InitiativesPage`, `InitiativeDetailPage`, `AuthPanel` |
+| Components | Badges, Forms, Lists, Empty/Loading/Error |
+| Layout | `frontend/src/layout/AppLayout.jsx` |
+| Styles | `frontend/src/styles/workspace.css` |
+| App | `App.jsx`, `main.jsx` (React Router) |
+| Tests | `registry.test.js`, `badges.test.jsx` |
+| Doku | `README.md`, dieser Bericht |
+
+---
+
+## 3. Frontend-Struktur
+
+```text
+frontend/src/
+ api/ # HTTP-Schicht
+ context/ # SessionProvider
+ hooks/ # useCapabilities
+ registry/ # Widget + View Registry
+ widgets/ # Workspace-Karten
+ pages/ # Routen
+ components/ # Wiederverwendbare UI-Bausteine
+ layout/ # App-Shell + Navigation
+ constants/ # Status/Priority Labels
+ styles/ # workspace.css
+```
+
+Keine monolithische God Page — `App.jsx` nur Routing + Session.
+
+---
+
+## 4. Widget Registry
+
+**Datei:** `registry/widgetRegistry.js`
+
+| key | title | capability | order |
+|-----|-------|------------|-------|
+| `kairo.tenant_context` | Kontext | — | 0 |
+| `kairo.my_open_actions` | Meine offenen Maßnahmen | `kairo.action.read` | 10 |
+| `kairo.blocked_actions` | Blockierte Maßnahmen | `kairo.action.read` | 20 |
+| `kairo.active_initiatives` | Aktive Vorhaben | `kairo.initiative.read` | 30 |
+
+`getWidgetsForArea('workspace', capabilities)` filtert und sortiert.
+
+**Frontend-only** — keine DB, kein Drag & Drop, keine User-Layouts.
+
+---
+
+## 5. View Registry / Navigation
+
+**Datei:** `registry/viewRegistry.js`
+
+| key | path | Nav | capability |
+|-----|------|-----|------------|
+| `kairo.workspace` | `/workspace` | ✓ | `kairo.action.read` |
+| `kairo.initiatives` | `/initiatives` | ✓ | `kairo.initiative.read` |
+| `kairo.initiative_detail` | `/initiatives/:id` | — | `kairo.initiative.read` |
+
+`AppLayout` rendert NavLinks aus `getNavViews(capabilities)`.
+
+---
+
+## 6. Workspace UX
+
+Route `/workspace`:
+
+- Tenant-/Actor-Kontext-Karte
+- Offene Maßnahmen (ohne `blocked` — die haben eigene Karte)
+- Blockierte Maßnahmen (gefiltert aus `/api/actions/me/open`)
+- Aktive/pausierte Vorhaben (Top 5)
+- Quick-Action „Vorhaben anlegen“ (mit `kairo.initiative.manage`)
+
+---
+
+## 7. Vorhaben-UX
+
+**Liste** `/initiatives`: aktive + pausierte Vorhaben, Status/Priority-Badges, offene Maßnahmen-Zähler (parallele API-Calls pro Vorhaben).
+
+**Detail** `/initiatives/:id`: Titel, Goal, Badges, Maßnahmenliste, Filter „Erledigte ausblenden“.
+
+---
+
+## 8. Maßnahmen-UX
+
+- Anlegen via `ActionForm` im Detail
+- Inline Status-Dropdown (Quick-Change)
+- Vollständiges Bearbeiten (Titel, Beschreibung, Status, Priority, Assignment)
+- Erledigte (`done`/`discarded`) standardmäßig ausgeblendet
+- Offene Liste über Backend `/api/actions/me/open` — erledigte erscheinen dort nicht
+
+---
+
+## 9. Actor-Zuweisung
+
+**Lücke:** Kein `GET /api/actors` im Backend.
+
+**AP0.6-Lösung:** `api/actors.js` → `actorsFromContext()` liefert nur den aktuellen Human Actor aus `/api/me/context`.
+
+`ActorSelect` zeigt Checkboxen für verfügbare Actors; Hinweistext auf AP0.7.
+
+Assignments via `PUT /api/actions/{id}/assignments` — Actor-IDs, nicht User.
+
+---
+
+## 10. Capability-bewusste UI
+
+Quelle: `/api/me/entitlements` + `/api/me/context` → `useCapabilities()`.
+
+| Capability | UI-Effekt |
+|------------|-----------|
+| `kairo.initiative.manage` | Button „Vorhaben anlegen“ |
+| `kairo.initiative.read` | Vorhaben-Widgets/Views |
+| `kairo.action.manage` | Maßnahme anlegen/bearbeiten, Status, Assignment |
+| `kairo.action.read` | Workspace, offene Maßnahmen |
+
+Backend bleibt autoritativ — Frontend nur UX-Gates.
+
+---
+
+## 11. Loading / Error / Empty States
+
+Jedes Widget und jede Page nutzt `LoadingState`, `ErrorState` (mit Retry), `EmptyState` mit kontextbezogenen Texten.
+
+---
+
+## 12. Responsive Verhalten
+
+`workspace.css` — `@media (max-width: 640px)`:
+
+- Header stapelt vertikal
+- Widget-Grid einspaltig
+- Buttons full-width (Form-Ausnahmen)
+
+---
+
+## 13. Tests und Smoke-Tests
+
+### Automatisiert (Vitest)
+
+```bash
+cd frontend && npm run test
+```
+
+7 Tests: Registry-Inhalt, Capability-Filter, StatusBadge, PriorityBadge.
+
+### Manueller Smoke-Test
+
+1. Login (Dev: `lars@stommer.com`)
+2. `/workspace` — Karten laden
+3. Vorhaben anlegen
+4. Detail → Maßnahme anlegen (Self-Assignment)
+5. Status → In Arbeit → Erledigt
+6. Workspace: Maßnahme nicht mehr unter „Offen“
+7. Blockiert setzen → erscheint in „Blockierte Maßnahmen“
+
+Kein Playwright im Repo — dokumentierter manueller Smoke-Test.
+
+---
+
+## 14. Übernommene Muster aus Mitai
+
+| Muster | Umsetzung |
+|--------|-----------|
+| Kartenbasierte Oberfläche | `widget-card`, `widget-grid` |
+| Widget-Registry-Idee | `widgetRegistry.js` (frontend-only, minimal) |
+| Status-Badges | `StatusBadge`, `PriorityBadge` |
+| Leere Zustände | `EmptyState` |
+| Loading/Error | eigene Komponenten |
+
+**Nicht übernommen:** volles Widget Dashboard, Layout-Persistenz, Drag & Drop, Backend-Registry.
+
+---
+
+## 15. Übernommene Muster aus Shinkan
+
+| Muster | Umsetzung |
+|--------|-----------|
+| Schlankes KPI/Listen-Layout | kompakte Widget-Karten |
+| Tenant-Kontext sichtbar | `TenantContextWidget`, App-Header |
+| Capability-bewusste Nav | `getNavViews()` |
+| Robuste Frontend-Struktur | api/ + pages/ + components/ |
+
+---
+
+## 16. Bewusst nicht übernommene Muster
+
+- Mitai/Shinkan-Domänenlogik
+- Backend-Widget-Registry
+- Admin-Konsole, Prompt/Feature/Config UI
+- Actor-Admin-API
+- Playwright E2E (noch nicht im Stack)
+- Designsystem / UI-Library
+
+---
+
+## 17. Backend-Änderungen
+
+**Keine.** AP0.5-APIs reichen aus.
+
+---
+
+## 18. Abweichungen von der Spezifikation
+
+| Thema | Abweichung | Begründung |
+|-------|------------|------------|
+| Actor-Liste | nur Context-Actor | kein GET /api/actors |
+| Owner Actor wählen | nicht in UI | keine Actor-List-API |
+| Open-Count Vorhaben | N+1 API-Calls | akzeptabel für MVP |
+| Playwright Smoke | manuell dokumentiert | nicht im CI-Stack |
+
+---
+
+## 19. Offene UX-/Produktentscheidungen
+
+1. **`GET /api/actors`** — read-only für Assignment-UI (AP0.7)?
+2. **Initiative bearbeiten in UI** — PATCH existiert, Form fehlt noch
+3. **Workspace-Refresh** — Widgets laden eigenständig; kein globales Event-Bus
+4. **Foundation AP0.5 (Audit/Admin)** — weiterhin AP0.6/0.7 backlog
+
+---
+
+## 20. Empfehlung für AP0.7
+
+**Kleinster Nutzwert:** `GET /api/actors` (tenant-scoped, read, `kairo.action.read`) + ActorSelect mit echten Tenant-Actors.
+
+Alternativ: **Kommentare pro Maßnahme** (aus AP0.5-Empfehlung) — erhöht Kollaboration ohne Backend-Explosion.
+
+---
+
+## Referenzen
+
+- AP0.5 Backend: `docs/sprints/Sprint0_AP0_5_Completion_Report_v0.2.md`
+- ADP Scope: `docs/architecture/ADP_AP0_5_Sprint0_Scope_Shift_v0.1.md`
+- Mitai: `docs/reference/design-principles/mitai/DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md`
+- Shinkan: `docs/reference/design-principles/shinkan/NAVIGATION_IA_DESIGN_PRINCIPLES.md`
diff --git a/frontend/package.json b/frontend/package.json
index e99b667..c4c5db6 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,19 +1,25 @@
{
"name": "kairo-jinkendo-frontend",
- "version": "0.1.0-ap0.1",
+ "version": "0.6.0-ap0.6",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 3097",
"build": "vite build",
- "preview": "vite preview"
+ "preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest"
},
"dependencies": {
"react": "^18.3.1",
- "react-dom": "^18.3.1"
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.22.0"
},
"devDependencies": {
+ "@testing-library/react": "^14.2.1",
"@vitejs/plugin-react": "^4.2.1",
- "vite": "^5.1.4"
+ "jsdom": "^24.0.0",
+ "vite": "^5.1.4",
+ "vitest": "^1.3.1"
}
}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 586b2d9..d95c354 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,389 +1,39 @@
-import { useCallback, useEffect, useState } from 'react'
+import { Navigate, Route, Routes } from 'react-router-dom'
+import { SessionProvider, useSession } from './context/SessionContext.jsx'
+import { AppLayout } from './layout/AppLayout.jsx'
+import { AuthPanel } from './pages/AuthPanel.jsx'
+import { VIEWS } from './registry/viewRegistry.js'
+import './app.css'
+import './styles/workspace.css'
-const TOKEN_KEY = 'kairo_auth_token'
+function AppRoutes() {
+ const { isAuthenticated, loading } = useSession()
-function apiHeaders(token) {
- const headers = { 'Content-Type': 'application/json' }
- if (token) headers['X-Auth-Token'] = token
- return headers
-}
-
-function parseError(body, fallback) {
- if (!body) return fallback
- if (typeof body.detail === 'string') return body.detail
- if (Array.isArray(body.detail)) {
- return body.detail.map((d) => d.msg || JSON.stringify(d)).join(', ')
+ if (loading) {
+ return null
}
- return fallback
+
+ if (!isAuthenticated) {
+ return
+ }
+
+ return (
+
+ }>
+ {VIEWS.map((view) => (
+ } />
+ ))}
+ } />
+ } />
+
+
+ )
}
export default function App() {
- const [health, setHealth] = useState(null)
- const [healthError, setHealthError] = useState(null)
- const [setup, setSetup] = useState(null)
- const [setupLoading, setSetupLoading] = useState(true)
- const [setupError, setSetupError] = useState(null)
- const [authMode, setAuthMode] = useState('login')
- const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) || '')
- const [email, setEmail] = useState('')
- const [password, setPassword] = useState('')
- const [displayName, setDisplayName] = useState('')
- const [organizationName, setOrganizationName] = useState('')
- const [passwordConfirm, setPasswordConfirm] = useState('')
- const [authError, setAuthError] = useState(null)
- const [authBusy, setAuthBusy] = useState(false)
- const [me, setMe] = useState(null)
- const [context, setContext] = useState(null)
-
- useEffect(() => {
- fetch('/api/health')
- .then((res) => {
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
- return res.json()
- })
- .then(setHealth)
- .catch((err) => setHealthError(err.message))
-
- async function loadSetupStatus() {
- setSetupLoading(true)
- setSetupError(null)
- try {
- const res = await fetch('/api/auth/setup-status')
- const body = await res.json().catch(() => ({}))
- if (!res.ok) {
- throw new Error(
- res.status === 404
- ? 'Endpoint /api/auth/setup-status nicht gefunden — Backend neu deployen?'
- : parseError(body, `Setup-Status fehlgeschlagen (HTTP ${res.status})`),
- )
- }
- if (typeof body.registration_open !== 'boolean') {
- throw new Error('Ungültige Antwort vom Setup-Status — Backend-Version prüfen')
- }
- setSetup(body)
- if (body.registration_open) setAuthMode('register')
- } catch (err) {
- setSetup(null)
- setSetupError(err.message)
- } finally {
- setSetupLoading(false)
- }
- }
-
- loadSetupStatus()
- }, [])
-
- const loadSession = useCallback(async (activeToken) => {
- if (!activeToken) {
- setMe(null)
- setContext(null)
- return
- }
- const meRes = await fetch('/api/me', { headers: apiHeaders(activeToken) })
- if (!meRes.ok) {
- localStorage.removeItem(TOKEN_KEY)
- setToken('')
- setMe(null)
- setContext(null)
- throw new Error(meRes.status === 401 ? 'Session abgelaufen' : `HTTP ${meRes.status}`)
- }
- setMe(await meRes.json())
- const ctxRes = await fetch('/api/me/context', { headers: apiHeaders(activeToken) })
- if (ctxRes.ok) setContext(await ctxRes.json())
- }, [])
-
- useEffect(() => {
- if (!token) return
- loadSession(token).catch((err) => setAuthError(err.message))
- }, [token, loadSession])
-
- function applyLogin(data) {
- localStorage.setItem(TOKEN_KEY, data.token)
- setToken(data.token)
- setPassword('')
- setPasswordConfirm('')
- setSetup({ registration_open: false, has_users: true, user_count: 1 })
- setAuthMode('login')
- }
-
- async function handleLogin(event) {
- event.preventDefault()
- setAuthBusy(true)
- setAuthError(null)
- try {
- const res = await fetch('/api/auth/login', {
- method: 'POST',
- headers: apiHeaders(),
- body: JSON.stringify({ email, password }),
- })
- const body = await res.json().catch(() => ({}))
- if (!res.ok) throw new Error(parseError(body, `Login fehlgeschlagen (${res.status})`))
- applyLogin(body)
- } catch (err) {
- setAuthError(err.message)
- } finally {
- setAuthBusy(false)
- }
- }
-
- async function handleRegister(event) {
- event.preventDefault()
- if (password !== passwordConfirm) {
- setAuthError('Passwörter stimmen nicht überein')
- return
- }
- setAuthBusy(true)
- setAuthError(null)
- try {
- const res = await fetch('/api/auth/register', {
- method: 'POST',
- headers: apiHeaders(),
- body: JSON.stringify({
- email,
- password,
- display_name: displayName,
- organization_name: organizationName || undefined,
- }),
- })
- const body = await res.json().catch(() => ({}))
- if (!res.ok) throw new Error(parseError(body, `Registrierung fehlgeschlagen (${res.status})`))
- applyLogin(body)
- } catch (err) {
- setAuthError(err.message)
- } finally {
- setAuthBusy(false)
- }
- }
-
- async function handleLogout() {
- setAuthBusy(true)
- setAuthError(null)
- try {
- if (token) {
- await fetch('/api/auth/logout', {
- method: 'POST',
- headers: apiHeaders(token),
- })
- }
- } catch {
- /* lokal entfernen */
- } finally {
- localStorage.removeItem(TOKEN_KEY)
- setToken('')
- setMe(null)
- setContext(null)
- setAuthBusy(false)
- }
- }
-
- const registrationOpen = setup?.registration_open === true
- const registrationClosedBecauseUsersExist = setup?.has_users === true && !registrationOpen
-
return (
-
-
-
-
- {token ? 'Session' : registrationOpen ? 'Ersteinrichtung' : 'Anmeldung'}
-
- {!token && registrationOpen && (
-
- Noch kein Systemadministrator. Der erste Account wird automatisch{' '}
- Portal-Admin und erhält einen Default-Tenant.
-
- )}
-
- {!token && setupLoading && (
- Prüfe Ersteinrichtung …
- )}
-
- {!token && setupError && (
- {setupError}
- )}
-
- {!token && registrationClosedBecauseUsersExist && (
-
- Es existiert bereits mindestens ein Benutzer ({setup.user_count ?? '≥1'}).
- Registrierung ist nur für die Ersteinrichtung vorgesehen — bitte anmelden.
- Falls der Admin per KAIRO_BOOTSTRAP_* in der .env angelegt
- wurde, nutzen Sie diese Zugangsdaten.
-
- )}
-
- {!token && !setupLoading && !setupError && (
- <>
-
- { setAuthMode('login'); setAuthError(null) }}
- >
- Anmelden
-
- { setAuthMode('register'); setAuthError(null) }}
- >
- Registrieren
-
-
-
- {authMode === 'login' && (
-
- )}
-
- {authMode === 'register' && registrationOpen && (
-
- )}
-
- {authMode === 'register' && !registrationOpen && (
-
- Registrierung ist geschlossen. Bitte melden Sie sich an oder wenden Sie sich an
- den Administrator.
-
- )}
- >
- )}
-
- {token && me && (
-
-
- Eingeloggt als {me.display_name} ({me.email})
-
-
- {me.portal_role === 'admin' ? 'Portal-Systemadmin' : `Portal-Rolle: ${me.portal_role}`}
- {context?.tenant?.name && ` · ${context.tenant.name}`}
-
-
- Abmelden
-
-
- )}
-
- {authError && {authError}
}
-
-
-
- Ersteinrichtung
- {setupLoading && Lade …
}
- {setupError && {setupError}
}
- {setup && {JSON.stringify(setup, null, 2)} }
- {!setupLoading && !setup && !setupError && (
- Kein Setup-Status verfügbar.
- )}
-
-
-
- API Health
- {healthError && Fehler: {healthError}
}
- {!healthError && !health && Lade …
}
- {health && {JSON.stringify(health, null, 2)} }
-
-
- {token && me && (
- <>
-
- /api/me
- {JSON.stringify(me, null, 2)}
-
- {context && (
-
- /api/me/context
- {JSON.stringify(context, null, 2)}
-
- )}
- >
- )}
-
+
+
+
)
}
diff --git a/frontend/src/api/actions.js b/frontend/src/api/actions.js
new file mode 100644
index 0000000..fba1f9d
--- /dev/null
+++ b/frontend/src/api/actions.js
@@ -0,0 +1,32 @@
+import { apiFetch } from './client.js'
+
+export function getAction(id) {
+ return apiFetch(`/api/actions/${id}`)
+}
+
+export function updateAction(id, payload) {
+ return apiFetch(`/api/actions/${id}`, {
+ method: 'PATCH',
+ body: JSON.stringify(payload),
+ })
+}
+
+export function setActionAssignments(id, actorIds) {
+ return apiFetch(`/api/actions/${id}/assignments`, {
+ method: 'PUT',
+ body: JSON.stringify({ actor_ids: actorIds }),
+ })
+}
+
+export function deleteAction(id) {
+ return apiFetch(`/api/actions/${id}`, { method: 'DELETE' })
+}
+
+export function listMyOpenActions() {
+ return apiFetch('/api/actions/me/open')
+}
+
+/** Offene Maßnahmen = open, in_progress, blocked (Backend-Filter). */
+export function countOpenActions(actions) {
+ return actions.filter((a) => ['open', 'in_progress', 'blocked'].includes(a.status)).length
+}
diff --git a/frontend/src/api/actors.js b/frontend/src/api/actors.js
new file mode 100644
index 0000000..723a196
--- /dev/null
+++ b/frontend/src/api/actors.js
@@ -0,0 +1,16 @@
+/**
+ * AP0.6: Kein GET /api/actors im Backend.
+ * Actor-Zuweisung nutzt TenantContext (aktueller Human Actor).
+ * Siehe Abschlussbericht AP0.6 §9.
+ */
+
+export function actorsFromContext(context) {
+ if (!context?.actor?.id) return []
+ return [
+ {
+ id: context.actor.id,
+ name: context.display_name || 'Aktueller Actor',
+ actor_type: context.actor.type || 'human',
+ },
+ ]
+}
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
new file mode 100644
index 0000000..09a4e8d
--- /dev/null
+++ b/frontend/src/api/client.js
@@ -0,0 +1,44 @@
+const TOKEN_KEY = 'kairo_auth_token'
+
+export { TOKEN_KEY }
+
+export function getToken() {
+ return localStorage.getItem(TOKEN_KEY) || ''
+}
+
+export function setToken(token) {
+ if (token) localStorage.setItem(TOKEN_KEY, token)
+ else localStorage.removeItem(TOKEN_KEY)
+}
+
+export function apiHeaders(token = getToken()) {
+ const headers = { 'Content-Type': 'application/json' }
+ if (token) headers['X-Auth-Token'] = token
+ return headers
+}
+
+export function parseError(body, fallback) {
+ if (!body) return fallback
+ if (typeof body.detail === 'string') return body.detail
+ if (Array.isArray(body.detail)) {
+ return body.detail.map((d) => d.msg || JSON.stringify(d)).join(', ')
+ }
+ return fallback
+}
+
+export async function apiFetch(path, options = {}) {
+ const token = options.token ?? getToken()
+ const res = await fetch(path, {
+ ...options,
+ headers: { ...apiHeaders(token), ...options.headers },
+ })
+ const body = await res.json().catch(() => null)
+ if (!res.ok) {
+ const message = parseError(body, `Anfrage fehlgeschlagen (${res.status})`)
+ const err = new Error(message)
+ err.status = res.status
+ err.body = body
+ throw err
+ }
+ return body
+}
diff --git a/frontend/src/api/initiatives.js b/frontend/src/api/initiatives.js
new file mode 100644
index 0000000..f5e79fb
--- /dev/null
+++ b/frontend/src/api/initiatives.js
@@ -0,0 +1,38 @@
+import { apiFetch } from './client.js'
+
+export function listInitiatives() {
+ return apiFetch('/api/initiatives')
+}
+
+export function getInitiative(id) {
+ return apiFetch(`/api/initiatives/${id}`)
+}
+
+export function createInitiative(payload) {
+ return apiFetch('/api/initiatives', {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ })
+}
+
+export function updateInitiative(id, payload) {
+ return apiFetch(`/api/initiatives/${id}`, {
+ method: 'PATCH',
+ body: JSON.stringify(payload),
+ })
+}
+
+export function deleteInitiative(id) {
+ return apiFetch(`/api/initiatives/${id}`, { method: 'DELETE' })
+}
+
+export function listInitiativeActions(initiativeId) {
+ return apiFetch(`/api/initiatives/${initiativeId}/actions`)
+}
+
+export function createInitiativeAction(initiativeId, payload) {
+ return apiFetch(`/api/initiatives/${initiativeId}/actions`, {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ })
+}
diff --git a/frontend/src/api/me.js b/frontend/src/api/me.js
new file mode 100644
index 0000000..da0f4de
--- /dev/null
+++ b/frontend/src/api/me.js
@@ -0,0 +1,37 @@
+import { apiFetch } from './client.js'
+
+export function fetchMe() {
+ return apiFetch('/api/me')
+}
+
+export function fetchContext() {
+ return apiFetch('/api/me/context')
+}
+
+export function fetchEntitlements() {
+ return apiFetch('/api/me/entitlements')
+}
+
+export function login(email, password) {
+ return apiFetch('/api/auth/login', {
+ method: 'POST',
+ body: JSON.stringify({ email, password }),
+ token: null,
+ })
+}
+
+export function register(payload) {
+ return apiFetch('/api/auth/register', {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ token: null,
+ })
+}
+
+export function logout() {
+ return apiFetch('/api/auth/logout', { method: 'POST' })
+}
+
+export function fetchSetupStatus() {
+ return apiFetch('/api/auth/setup-status', { token: null })
+}
diff --git a/frontend/src/components/ActionForm.jsx b/frontend/src/components/ActionForm.jsx
new file mode 100644
index 0000000..dd927ad
--- /dev/null
+++ b/frontend/src/components/ActionForm.jsx
@@ -0,0 +1,71 @@
+import { ACTION_STATUSES, PRIORITIES } from '../constants/status.js'
+import { ACTION_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js'
+import { ActorSelect } from './ActorSelect.jsx'
+
+export function ActionForm({
+ initial = {},
+ actors = [],
+ onSubmit,
+ onCancel,
+ busy = false,
+ submitLabel = 'Speichern',
+}) {
+ async function handleSubmit(e) {
+ e.preventDefault()
+ const form = e.target
+ const selected = actors.filter((a) => form[`actor_${a.id}`]?.checked).map((a) => a.id)
+ await onSubmit({
+ title: form.title.value.trim(),
+ description: form.description.value,
+ status: form.status.value,
+ priority: form.priority.value,
+ assigned_actor_ids: selected,
+ })
+ }
+
+ const defaultActors = initial.assigned_actor_ids || []
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/ActionList.jsx b/frontend/src/components/ActionList.jsx
new file mode 100644
index 0000000..807459a
--- /dev/null
+++ b/frontend/src/components/ActionList.jsx
@@ -0,0 +1,42 @@
+import { Link } from 'react-router-dom'
+import { StatusBadge } from '../components/StatusBadge.jsx'
+import { PriorityBadge } from '../components/PriorityBadge.jsx'
+
+export function ActionListItem({ action, showInitiative = false, manageLink }) {
+ return (
+
+
+
{action.title}
+ {showInitiative && action.initiative_title && (
+
{action.initiative_title}
+ )}
+ {action.description &&
{action.description}
}
+
+
+
+
+ {manageLink && (
+
+ Bearbeiten
+
+ )}
+
+
+ )
+}
+
+export function ActionList({ actions, empty, showInitiative, getManageLink }) {
+ if (!actions.length) return empty
+ return (
+
+ {actions.map((action) => (
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/components/ActorSelect.jsx b/frontend/src/components/ActorSelect.jsx
new file mode 100644
index 0000000..3409609
--- /dev/null
+++ b/frontend/src/components/ActorSelect.jsx
@@ -0,0 +1,31 @@
+export function ActorSelect({ actors = [], defaultSelected = [], label = 'Zugewiesene Actors' }) {
+ if (!actors.length) {
+ return (
+
+ Kein Actor verfügbar — bitte TenantContext prüfen.
+
+ )
+ }
+
+ return (
+
+ {label}
+ {actors.map((actor) => (
+
+
+ {actor.name}
+ {actor.actor_type && ({actor.actor_type}) }
+
+ ))}
+ {actors.length === 1 && (
+
+ AP0.6: Nur der aktuelle Actor — Tenant-Actor-Liste folgt in AP0.7.
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/EmptyState.jsx b/frontend/src/components/EmptyState.jsx
new file mode 100644
index 0000000..5a0bf71
--- /dev/null
+++ b/frontend/src/components/EmptyState.jsx
@@ -0,0 +1,9 @@
+export function EmptyState({ title, message, action }) {
+ return (
+
+ {title &&
{title}
}
+ {message &&
{message}
}
+ {action}
+
+ )
+}
diff --git a/frontend/src/components/ErrorState.jsx b/frontend/src/components/ErrorState.jsx
new file mode 100644
index 0000000..0d72ce1
--- /dev/null
+++ b/frontend/src/components/ErrorState.jsx
@@ -0,0 +1,12 @@
+export function ErrorState({ message, onRetry }) {
+ return (
+
+
{message || 'Ein Fehler ist aufgetreten.'}
+ {onRetry && (
+
+ Erneut versuchen
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/InitiativeForm.jsx b/frontend/src/components/InitiativeForm.jsx
new file mode 100644
index 0000000..c64e280
--- /dev/null
+++ b/frontend/src/components/InitiativeForm.jsx
@@ -0,0 +1,64 @@
+import { INITIATIVE_STATUSES, PRIORITIES } from '../constants/status.js'
+import { INITIATIVE_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js'
+
+export function InitiativeForm({
+ initial = {},
+ onSubmit,
+ onCancel,
+ busy = false,
+ submitLabel = 'Speichern',
+}) {
+ async function handleSubmit(e) {
+ e.preventDefault()
+ const form = e.target
+ await onSubmit({
+ title: form.title.value.trim(),
+ goal: form.goal.value,
+ status: form.status.value,
+ priority: form.priority.value,
+ })
+ }
+
+ return (
+
+
+ Titel
+
+
+
+ Ziel
+
+
+
+ Status
+
+ {INITIATIVE_STATUSES.map((s) => (
+
+ {INITIATIVE_STATUS_LABELS[s]}
+
+ ))}
+
+
+
+ Priorität
+
+ {PRIORITIES.map((p) => (
+
+ {PRIORITY_LABELS[p]}
+
+ ))}
+
+
+
+
+ {busy ? 'Speichern …' : submitLabel}
+
+ {onCancel && (
+
+ Abbrechen
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/components/LoadingState.jsx b/frontend/src/components/LoadingState.jsx
new file mode 100644
index 0000000..6d65d06
--- /dev/null
+++ b/frontend/src/components/LoadingState.jsx
@@ -0,0 +1,3 @@
+export function LoadingState({ message = 'Lade …' }) {
+ return {message}
+}
diff --git a/frontend/src/components/PriorityBadge.jsx b/frontend/src/components/PriorityBadge.jsx
new file mode 100644
index 0000000..a0ba277
--- /dev/null
+++ b/frontend/src/components/PriorityBadge.jsx
@@ -0,0 +1,13 @@
+import { PRIORITY_LABELS } from '../constants/status.js'
+
+const VARIANTS = {
+ low: 'priority-low',
+ normal: 'priority-normal',
+ high: 'priority-high',
+}
+
+export function PriorityBadge({ priority }) {
+ const label = PRIORITY_LABELS[priority] || priority
+ const variant = VARIANTS[priority] || 'priority-normal'
+ return {label}
+}
diff --git a/frontend/src/components/StatusBadge.jsx b/frontend/src/components/StatusBadge.jsx
new file mode 100644
index 0000000..d73e013
--- /dev/null
+++ b/frontend/src/components/StatusBadge.jsx
@@ -0,0 +1,20 @@
+import { INITIATIVE_STATUS_LABELS, ACTION_STATUS_LABELS } from '../constants/status.js'
+
+const VARIANTS = {
+ active: 'status-active',
+ paused: 'status-paused',
+ completed: 'status-completed',
+ archived: 'status-archived',
+ open: 'status-open',
+ in_progress: 'status-progress',
+ blocked: 'status-blocked',
+ done: 'status-done',
+ discarded: 'status-discarded',
+}
+
+export function StatusBadge({ kind = 'action', status }) {
+ const labels = kind === 'initiative' ? INITIATIVE_STATUS_LABELS : ACTION_STATUS_LABELS
+ const label = labels[status] || status
+ const variant = VARIANTS[status] || 'status-default'
+ return {label}
+}
diff --git a/frontend/src/components/badges.test.jsx b/frontend/src/components/badges.test.jsx
new file mode 100644
index 0000000..20ac659
--- /dev/null
+++ b/frontend/src/components/badges.test.jsx
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import { StatusBadge } from '../components/StatusBadge.jsx'
+import { PriorityBadge } from '../components/PriorityBadge.jsx'
+
+describe('StatusBadge', () => {
+ it('renders known action status', () => {
+ render( )
+ expect(screen.getByText('Blockiert')).toBeTruthy()
+ })
+
+ it('renders known initiative status', () => {
+ render( )
+ expect(screen.getByText('Aktiv')).toBeTruthy()
+ })
+})
+
+describe('PriorityBadge', () => {
+ it('renders known priority', () => {
+ render( )
+ expect(screen.getByText('Hoch')).toBeTruthy()
+ })
+})
diff --git a/frontend/src/constants/status.js b/frontend/src/constants/status.js
new file mode 100644
index 0000000..65e6ded
--- /dev/null
+++ b/frontend/src/constants/status.js
@@ -0,0 +1,25 @@
+export const INITIATIVE_STATUSES = ['active', 'paused', 'completed', 'archived']
+export const ACTION_STATUSES = ['open', 'in_progress', 'blocked', 'done', 'discarded']
+export const PRIORITIES = ['low', 'normal', 'high']
+export const OPEN_ACTION_STATUSES = ['open', 'in_progress', 'blocked']
+
+export const INITIATIVE_STATUS_LABELS = {
+ active: 'Aktiv',
+ paused: 'Pausiert',
+ completed: 'Abgeschlossen',
+ archived: 'Archiviert',
+}
+
+export const ACTION_STATUS_LABELS = {
+ open: 'Offen',
+ in_progress: 'In Arbeit',
+ blocked: 'Blockiert',
+ done: 'Erledigt',
+ discarded: 'Verworfen',
+}
+
+export const PRIORITY_LABELS = {
+ low: 'Niedrig',
+ normal: 'Normal',
+ high: 'Hoch',
+}
diff --git a/frontend/src/context/SessionContext.jsx b/frontend/src/context/SessionContext.jsx
new file mode 100644
index 0000000..519a923
--- /dev/null
+++ b/frontend/src/context/SessionContext.jsx
@@ -0,0 +1,100 @@
+import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
+import { getToken, setToken } from '../api/client.js'
+import * as meApi from '../api/me.js'
+
+const SessionContext = createContext(null)
+
+export function SessionProvider({ children }) {
+ const [token, setTokenState] = useState(() => getToken())
+ const [me, setMe] = useState(null)
+ const [context, setContext] = useState(null)
+ const [entitlements, setEntitlements] = useState(null)
+ const [loading, setLoading] = useState(!!getToken())
+ const [error, setError] = useState(null)
+
+ const refresh = useCallback(async (activeToken = token) => {
+ if (!activeToken) {
+ setMe(null)
+ setContext(null)
+ setEntitlements(null)
+ setLoading(false)
+ return
+ }
+ setLoading(true)
+ setError(null)
+ try {
+ const [meData, ctxData, entData] = await Promise.all([
+ meApi.fetchMe(),
+ meApi.fetchContext(),
+ meApi.fetchEntitlements(),
+ ])
+ setMe(meData)
+ setContext(ctxData)
+ setEntitlements(entData)
+ } catch (err) {
+ if (err.status === 401) {
+ setToken(null)
+ setTokenState('')
+ setMe(null)
+ setContext(null)
+ setEntitlements(null)
+ }
+ setError(err.message)
+ throw err
+ } finally {
+ setLoading(false)
+ }
+ }, [token])
+
+ useEffect(() => {
+ if (token) refresh(token).catch(() => {})
+ else {
+ setLoading(false)
+ setMe(null)
+ setContext(null)
+ setEntitlements(null)
+ }
+ }, [token]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ const applyLogin = useCallback((data) => {
+ setToken(data.token)
+ setTokenState(data.token)
+ }, [])
+
+ const signOut = useCallback(async () => {
+ try {
+ if (token) await meApi.logout()
+ } catch {
+ /* lokal entfernen */
+ }
+ setToken(null)
+ setTokenState('')
+ setMe(null)
+ setContext(null)
+ setEntitlements(null)
+ }, [token])
+
+ const value = useMemo(
+ () => ({
+ token,
+ me,
+ context,
+ entitlements,
+ loading,
+ error,
+ refresh,
+ applyLogin,
+ signOut,
+ isAuthenticated: !!token && !!me,
+ }),
+ [token, me, context, entitlements, loading, error, refresh, applyLogin, signOut],
+ )
+
+ return {children}
+}
+
+export function useSession() {
+ const ctx = useContext(SessionContext)
+ if (!ctx) throw new Error('useSession requires SessionProvider')
+ return ctx
+}
diff --git a/frontend/src/hooks/useCapabilities.js b/frontend/src/hooks/useCapabilities.js
new file mode 100644
index 0000000..d3de2d0
--- /dev/null
+++ b/frontend/src/hooks/useCapabilities.js
@@ -0,0 +1,20 @@
+import { useMemo } from 'react'
+import { useSession } from '../context/SessionContext.jsx'
+
+export function useCapabilities() {
+ const { context, entitlements } = useSession()
+
+ const capabilities = useMemo(() => {
+ const fromContext = context?.capabilities || []
+ const fromEntitlements = entitlements?.capabilities || []
+ const merged = new Set([...fromContext, ...fromEntitlements])
+ return merged
+ }, [context, entitlements])
+
+ const hasCapability = (key) => {
+ if (!key) return true
+ return capabilities.has(key)
+ }
+
+ return { capabilities, hasCapability }
+}
diff --git a/frontend/src/layout/AppLayout.jsx b/frontend/src/layout/AppLayout.jsx
new file mode 100644
index 0000000..623fd2c
--- /dev/null
+++ b/frontend/src/layout/AppLayout.jsx
@@ -0,0 +1,50 @@
+import { NavLink, Outlet } from 'react-router-dom'
+import { useSession } from '../context/SessionContext.jsx'
+import { useCapabilities } from '../hooks/useCapabilities.js'
+import { getNavViews } from '../registry/viewRegistry.js'
+import { LoadingState } from '../components/LoadingState.jsx'
+
+export function AppLayout() {
+ const { me, context, signOut, loading } = useSession()
+ const { capabilities } = useCapabilities()
+ const navViews = getNavViews(capabilities)
+
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index 7392451..def85e8 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -1,10 +1,13 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
+import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import './app.css'
createRoot(document.getElementById('root')).render(
-
+
+
+
,
)
diff --git a/frontend/src/pages/AuthPanel.jsx b/frontend/src/pages/AuthPanel.jsx
new file mode 100644
index 0000000..e21887b
--- /dev/null
+++ b/frontend/src/pages/AuthPanel.jsx
@@ -0,0 +1,151 @@
+import { useState, useEffect } from 'react'
+import { fetchSetupStatus, login, register } from '../api/me.js'
+import { useSession } from '../context/SessionContext.jsx'
+
+export function AuthPanel() {
+ const { applyLogin } = useSession()
+ const [setup, setSetup] = useState(null)
+ const [setupLoading, setSetupLoading] = useState(true)
+ const [setupError, setSetupError] = useState(null)
+ const [authMode, setAuthMode] = useState('login')
+ const [email, setEmail] = useState('')
+ const [password, setPassword] = useState('')
+ const [displayName, setDisplayName] = useState('')
+ const [organizationName, setOrganizationName] = useState('')
+ const [passwordConfirm, setPasswordConfirm] = useState('')
+ const [authError, setAuthError] = useState(null)
+ const [authBusy, setAuthBusy] = useState(false)
+
+ useEffect(() => {
+ fetchSetupStatus()
+ .then((body) => {
+ setSetup(body)
+ if (body.registration_open) setAuthMode('register')
+ })
+ .catch((err) => setSetupError(err.message))
+ .finally(() => setSetupLoading(false))
+ }, [])
+
+ async function handleLogin(e) {
+ e.preventDefault()
+ setAuthBusy(true)
+ setAuthError(null)
+ try {
+ const body = await login(email, password)
+ applyLogin(body)
+ } catch (err) {
+ setAuthError(err.message)
+ } finally {
+ setAuthBusy(false)
+ }
+ }
+
+ async function handleRegister(e) {
+ e.preventDefault()
+ if (password !== passwordConfirm) {
+ setAuthError('Passwörter stimmen nicht überein')
+ return
+ }
+ setAuthBusy(true)
+ setAuthError(null)
+ try {
+ const body = await register({
+ email,
+ password,
+ display_name: displayName,
+ organization_name: organizationName || undefined,
+ })
+ applyLogin(body)
+ } catch (err) {
+ setAuthError(err.message)
+ } finally {
+ setAuthBusy(false)
+ }
+ }
+
+ const registrationOpen = setup?.registration_open === true
+
+ return (
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/InitiativeDetailPage.jsx b/frontend/src/pages/InitiativeDetailPage.jsx
new file mode 100644
index 0000000..9f49146
--- /dev/null
+++ b/frontend/src/pages/InitiativeDetailPage.jsx
@@ -0,0 +1,233 @@
+import { useCallback, useEffect, useState } from 'react'
+import { Link, useParams } from 'react-router-dom'
+import {
+ getInitiative,
+ listInitiativeActions,
+ createInitiativeAction,
+} from '../api/initiatives.js'
+import { updateAction, setActionAssignments } from '../api/actions.js'
+import { ACTION_STATUS_LABELS } from '../constants/status.js'
+import { StatusBadge } from '../components/StatusBadge.jsx'
+import { PriorityBadge } from '../components/PriorityBadge.jsx'
+import { ActionForm } from '../components/ActionForm.jsx'
+import { EmptyState } from '../components/EmptyState.jsx'
+import { ErrorState } from '../components/ErrorState.jsx'
+import { LoadingState } from '../components/LoadingState.jsx'
+import { useCapabilities } from '../hooks/useCapabilities.js'
+import { useSession } from '../context/SessionContext.jsx'
+
+export function InitiativeDetailPage() {
+ const { id } = useParams()
+ const { context } = useSession()
+ const { hasCapability } = useCapabilities()
+ const actors = actorsFromContext(context)
+
+ const [initiative, setInitiative] = useState(null)
+ const [actions, setActions] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [showActionForm, setShowActionForm] = useState(false)
+ const [editingAction, setEditingAction] = useState(null)
+ const [formBusy, setFormBusy] = useState(false)
+ const [hideDone, setHideDone] = useState(true)
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const [initData, actionData] = await Promise.all([
+ getInitiative(id),
+ listInitiativeActions(id),
+ ])
+ setInitiative(initData)
+ setActions(actionData)
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }, [id])
+
+ useEffect(() => {
+ load()
+ }, [load])
+
+ const visibleActions = hideDone
+ ? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
+ : actions
+
+ async function handleCreateAction(payload) {
+ setFormBusy(true)
+ try {
+ const assigned =
+ payload.assigned_actor_ids?.length > 0
+ ? payload.assigned_actor_ids
+ : context?.actor?.id
+ ? [context.actor.id]
+ : []
+ await createInitiativeAction(id, { ...payload, assigned_actor_ids: assigned })
+ setShowActionForm(false)
+ await load()
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setFormBusy(false)
+ }
+ }
+
+ async function handleUpdateAction(actionId, payload) {
+ setFormBusy(true)
+ try {
+ await updateAction(actionId, {
+ title: payload.title,
+ description: payload.description,
+ status: payload.status,
+ priority: payload.priority,
+ })
+ if (hasCapability('kairo.action.manage')) {
+ await setActionAssignments(actionId, payload.assigned_actor_ids || [])
+ }
+ setEditingAction(null)
+ await load()
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setFormBusy(false)
+ }
+ }
+
+ async function handleQuickStatus(action, status) {
+ if (!hasCapability('kairo.action.manage')) return
+ try {
+ await updateAction(action.id, { status })
+ await load()
+ } catch (err) {
+ setError(err.message)
+ }
+ }
+
+ if (loading) return
+ if (error && !initiative) return
+ if (!initiative) return
+
+ return (
+
+
+ Vorhaben
+ /
+ {initiative.title}
+
+
+
+
+
{initiative.title}
+ {initiative.goal &&
{initiative.goal}
}
+
+
+
+
+ {error &&
{error}
}
+
+
+
+
Maßnahmen
+
+
+ setHideDone(e.target.checked)}
+ />
+ Erledigte ausblenden
+
+ {hasCapability('kairo.action.manage') && (
+ { setShowActionForm((v) => !v); setEditingAction(null) }}>
+ {showActionForm ? 'Abbrechen' : 'Maßnahme anlegen'}
+
+ )}
+
+
+
+ {showActionForm && (
+
+
setShowActionForm(false)}
+ busy={formBusy}
+ submitLabel="Maßnahme anlegen"
+ />
+
+ )}
+
+ {visibleActions.length === 0 && (
+
+ )}
+
+
+ {visibleActions.map((action) => (
+
+ {editingAction === action.id ? (
+ handleUpdateAction(action.id, payload)}
+ onCancel={() => setEditingAction(null)}
+ busy={formBusy}
+ submitLabel="Speichern"
+ />
+ ) : (
+ <>
+
+
{action.title}
+ {action.description && (
+
{action.description}
+ )}
+ {action.assigned_actor_ids?.length > 0 && (
+
+ Zugewiesen: {action.assigned_actor_ids.length} Actor(s)
+
+ )}
+
+
+
+
+ {hasCapability('kairo.action.manage') && (
+ <>
+
handleQuickStatus(action, e.target.value)}
+ aria-label="Status ändern"
+ >
+ {['open', 'in_progress', 'blocked', 'done', 'discarded'].map((s) => (
+
+ {ACTION_STATUS_LABELS[s]}
+
+ ))}
+
+
{
+ setEditingAction(action.id)
+ setShowActionForm(false)
+ }}
+ >
+ Bearbeiten
+
+ >
+ )}
+
+ >
+ )}
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/InitiativesPage.jsx b/frontend/src/pages/InitiativesPage.jsx
new file mode 100644
index 0000000..8cf6ed3
--- /dev/null
+++ b/frontend/src/pages/InitiativesPage.jsx
@@ -0,0 +1,123 @@
+import { useCallback, useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { listInitiatives, listInitiativeActions } from '../api/initiatives.js'
+import { countOpenActions } from '../api/actions.js'
+import { StatusBadge } from '../components/StatusBadge.jsx'
+import { PriorityBadge } from '../components/PriorityBadge.jsx'
+import { InitiativeForm } from '../components/InitiativeForm.jsx'
+import { EmptyState } from '../components/EmptyState.jsx'
+import { ErrorState } from '../components/ErrorState.jsx'
+import { LoadingState } from '../components/LoadingState.jsx'
+import { useCapabilities } from '../hooks/useCapabilities.js'
+import { useSession } from '../context/SessionContext.jsx'
+import { createInitiative } from '../api/initiatives.js'
+
+export function InitiativesPage() {
+ const { context } = useSession()
+ const { hasCapability } = useCapabilities()
+ const [initiatives, setInitiatives] = useState([])
+ const [openCounts, setOpenCounts] = useState({})
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [showForm, setShowForm] = useState(false)
+ const [formBusy, setFormBusy] = useState(false)
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const data = await listInitiatives()
+ const active = data.filter((i) => i.status === 'active' || i.status === 'paused')
+ setInitiatives(active)
+
+ const counts = {}
+ await Promise.all(
+ active.map(async (initiative) => {
+ try {
+ const actions = await listInitiativeActions(initiative.id)
+ counts[initiative.id] = countOpenActions(actions)
+ } catch {
+ counts[initiative.id] = null
+ }
+ }),
+ )
+ setOpenCounts(counts)
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ load()
+ }, [load])
+
+ async function handleCreate(payload) {
+ setFormBusy(true)
+ try {
+ await createInitiative({ ...payload, owner_actor_id: context?.actor?.id })
+ setShowForm(false)
+ await load()
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setFormBusy(false)
+ }
+ }
+
+ return (
+
+
+
+ {showForm && (
+
+ Neues Vorhaben
+ setShowForm(false)}
+ busy={formBusy}
+ submitLabel="Vorhaben anlegen"
+ />
+
+ )}
+
+ {loading &&
}
+ {!loading && error &&
}
+ {!loading && !error && initiatives.length === 0 && (
+
+ )}
+ {!loading && !error && initiatives.length > 0 && (
+
+ {initiatives.map((item) => (
+
+
+
+
{item.title}
+
+ {item.goal &&
{item.goal}
}
+
+
+
+
+ {openCounts[item.id] != null && (
+
{openCounts[item.id]} offen
+ )}
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/WorkspacePage.jsx b/frontend/src/pages/WorkspacePage.jsx
new file mode 100644
index 0000000..ae37533
--- /dev/null
+++ b/frontend/src/pages/WorkspacePage.jsx
@@ -0,0 +1,77 @@
+import { Link } from 'react-router-dom'
+import { useNavigate } from 'react-router-dom'
+import { useCapabilities } from '../hooks/useCapabilities.js'
+import { getWidgetsForArea } from '../registry/widgetRegistry.js'
+import { useSession } from '../context/SessionContext.jsx'
+import { InitiativeForm } from '../components/InitiativeForm.jsx'
+import { createInitiative } from '../api/initiatives.js'
+import { useState } from 'react'
+
+export function WorkspacePage() {
+ const navigate = useNavigate()
+ const { context } = useSession()
+ const { capabilities, hasCapability } = useCapabilities()
+ const widgets = getWidgetsForArea('workspace', capabilities)
+ const [showForm, setShowForm] = useState(false)
+ const [formBusy, setFormBusy] = useState(false)
+ const [formError, setFormError] = useState(null)
+
+ async function handleCreateInitiative(payload) {
+ setFormBusy(true)
+ setFormError(null)
+ try {
+ await createInitiative({
+ ...payload,
+ owner_actor_id: context?.actor?.id,
+ })
+ setShowForm(false)
+ navigate('/initiatives')
+ } catch (err) {
+ setFormError(err.message)
+ } finally {
+ setFormBusy(false)
+ }
+ }
+
+ return (
+
+
+
+ {showForm && (
+
+ Neues Vorhaben
+ {formError && {formError}
}
+ setShowForm(false)}
+ busy={formBusy}
+ submitLabel="Vorhaben anlegen"
+ />
+
+ )}
+
+
+ {widgets.map((widget) => {
+ const Component = widget.component
+ return
+ })}
+
+
+ {hasCapability('kairo.initiative.read') && (
+
+ Zur Vorhabenliste →
+
+ )}
+
+ )
+}
diff --git a/frontend/src/registry/registry.test.js b/frontend/src/registry/registry.test.js
new file mode 100644
index 0000000..408d990
--- /dev/null
+++ b/frontend/src/registry/registry.test.js
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest'
+import { WIDGETS, getWidgetsForArea } from '../registry/widgetRegistry.js'
+import { VIEWS, getNavViews } from '../registry/viewRegistry.js'
+
+describe('widgetRegistry', () => {
+ it('contains expected AP0.6 widgets', () => {
+ const keys = WIDGETS.map((w) => w.key)
+ expect(keys).toContain('kairo.my_open_actions')
+ expect(keys).toContain('kairo.active_initiatives')
+ expect(keys).toContain('kairo.blocked_actions')
+ expect(keys).toContain('kairo.tenant_context')
+ })
+
+ it('filters widgets by capability', () => {
+ const all = getWidgetsForArea('workspace', [
+ 'kairo.action.read',
+ 'kairo.initiative.read',
+ ])
+ expect(all.length).toBe(4)
+
+ const readOnly = getWidgetsForArea('workspace', ['kairo.initiative.read'])
+ expect(readOnly.some((w) => w.key === 'kairo.my_open_actions')).toBe(false)
+ expect(readOnly.some((w) => w.key === 'kairo.active_initiatives')).toBe(true)
+ })
+})
+
+describe('viewRegistry', () => {
+ it('contains workspace and initiatives views', () => {
+ const keys = VIEWS.map((v) => v.key)
+ expect(keys).toContain('kairo.workspace')
+ expect(keys).toContain('kairo.initiatives')
+ })
+
+ it('filters nav views by capability', () => {
+ const nav = getNavViews(['kairo.initiative.read'])
+ expect(nav.some((v) => v.key === 'kairo.workspace')).toBe(false)
+ expect(nav.some((v) => v.key === 'kairo.initiatives')).toBe(true)
+ })
+})
diff --git a/frontend/src/registry/viewRegistry.js b/frontend/src/registry/viewRegistry.js
new file mode 100644
index 0000000..80f347d
--- /dev/null
+++ b/frontend/src/registry/viewRegistry.js
@@ -0,0 +1,51 @@
+import { WorkspacePage } from '../pages/WorkspacePage.jsx'
+import { InitiativesPage } from '../pages/InitiativesPage.jsx'
+import { InitiativeDetailPage } from '../pages/InitiativeDetailPage.jsx'
+
+/**
+ * @typedef {Object} ViewDefinition
+ * @property {string} key
+ * @property {string} path
+ * @property {string} title
+ * @property {string} [requiredCapability]
+ * @property {import('react').ComponentType} component
+ * @property {boolean} [showInNav]
+ */
+
+/** @type {ViewDefinition[]} */
+export const VIEWS = [
+ {
+ key: 'kairo.workspace',
+ path: '/workspace',
+ title: 'Workspace',
+ requiredCapability: 'kairo.action.read',
+ component: WorkspacePage,
+ showInNav: true,
+ },
+ {
+ key: 'kairo.initiatives',
+ path: '/initiatives',
+ title: 'Vorhaben',
+ requiredCapability: 'kairo.initiative.read',
+ component: InitiativesPage,
+ showInNav: true,
+ },
+ {
+ key: 'kairo.initiative_detail',
+ path: '/initiatives/:id',
+ title: 'Vorhaben-Detail',
+ requiredCapability: 'kairo.initiative.read',
+ component: InitiativeDetailPage,
+ showInNav: false,
+ },
+]
+
+export function getNavViews(capabilities) {
+ const capSet = capabilities instanceof Set ? capabilities : new Set(capabilities)
+ return VIEWS.filter((v) => v.showInNav !== false)
+ .filter((v) => !v.requiredCapability || capSet.has(v.requiredCapability))
+}
+
+export function getViewByKey(key) {
+ return VIEWS.find((v) => v.key === key)
+}
diff --git a/frontend/src/registry/widgetRegistry.js b/frontend/src/registry/widgetRegistry.js
new file mode 100644
index 0000000..372d12b
--- /dev/null
+++ b/frontend/src/registry/widgetRegistry.js
@@ -0,0 +1,71 @@
+import { MyOpenActionsWidget } from '../widgets/MyOpenActionsWidget.jsx'
+import { InitiativesWidget } from '../widgets/InitiativesWidget.jsx'
+import { BlockedActionsWidget } from '../widgets/BlockedActionsWidget.jsx'
+import { TenantContextWidget } from '../widgets/TenantContextWidget.jsx'
+
+/** @typedef {import('react').ComponentType} WidgetComponent */
+
+/**
+ * @typedef {Object} WidgetDefinition
+ * @property {string} key
+ * @property {string} title
+ * @property {string} [description]
+ * @property {string} area
+ * @property {string} [requiredCapability]
+ * @property {number} defaultOrder
+ * @property {WidgetComponent} component
+ */
+
+/** @type {WidgetDefinition[]} */
+export const WIDGETS = [
+ {
+ key: 'kairo.tenant_context',
+ title: 'Kontext',
+ description: 'Aktiver Tenant und Actor',
+ area: 'workspace',
+ defaultOrder: 0,
+ component: TenantContextWidget,
+ },
+ {
+ key: 'kairo.my_open_actions',
+ title: 'Meine offenen Maßnahmen',
+ description: 'Offene Maßnahmen, die mir zugewiesen sind',
+ area: 'workspace',
+ requiredCapability: 'kairo.action.read',
+ defaultOrder: 10,
+ component: MyOpenActionsWidget,
+ },
+ {
+ key: 'kairo.blocked_actions',
+ title: 'Blockierte Maßnahmen',
+ description: 'Mir zugewiesene blockierte Maßnahmen',
+ area: 'workspace',
+ requiredCapability: 'kairo.action.read',
+ defaultOrder: 20,
+ component: BlockedActionsWidget,
+ },
+ {
+ key: 'kairo.active_initiatives',
+ title: 'Aktive Vorhaben',
+ description: 'Aktive und pausierte Vorhaben im Tenant',
+ area: 'workspace',
+ requiredCapability: 'kairo.initiative.read',
+ defaultOrder: 30,
+ component: InitiativesWidget,
+ },
+]
+
+/**
+ * @param {string} area
+ * @param {Set|string[]} capabilities
+ */
+export function getWidgetsForArea(area, capabilities) {
+ const capSet = capabilities instanceof Set ? capabilities : new Set(capabilities)
+ return WIDGETS.filter((w) => w.area === area)
+ .filter((w) => !w.requiredCapability || capSet.has(w.requiredCapability))
+ .sort((a, b) => a.defaultOrder - b.defaultOrder)
+}
+
+export function getWidgetByKey(key) {
+ return WIDGETS.find((w) => w.key === key)
+}
diff --git a/frontend/src/styles/workspace.css b/frontend/src/styles/workspace.css
new file mode 100644
index 0000000..a2e04ff
--- /dev/null
+++ b/frontend/src/styles/workspace.css
@@ -0,0 +1,400 @@
+.app-layout {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.app-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 1rem;
+ padding: 0.75rem 1.25rem;
+ background: #fff;
+ border-bottom: 1px solid #ddd;
+}
+
+.app-header-brand strong {
+ color: #1a4d8f;
+}
+
+.app-nav {
+ display: flex;
+ gap: 0.5rem;
+ flex: 1;
+}
+
+.nav-link {
+ padding: 0.4rem 0.75rem;
+ border-radius: 6px;
+ text-decoration: none;
+ color: #333;
+ font-size: 0.95rem;
+}
+
+.nav-link.active {
+ background: #eef4fc;
+ color: #1a4d8f;
+ font-weight: 600;
+}
+
+.app-header-user {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.user-label {
+ font-size: 0.9rem;
+ color: #555;
+}
+
+.workspace-shell {
+ max-width: 960px;
+}
+
+.auth-shell {
+ max-width: 720px;
+}
+
+.page-header {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+ margin-bottom: 1rem;
+}
+
+.page-header h1 {
+ margin: 0 0 0.25rem;
+}
+
+.widget-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 1rem;
+}
+
+.widget-card {
+ background: #fff;
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ padding: 1rem;
+}
+
+.widget-card-alert {
+ border-color: #e8c4c4;
+ background: #fffafa;
+}
+
+.widget-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.75rem;
+}
+
+.widget-header h3 {
+ margin: 0;
+ font-size: 1rem;
+}
+
+.widget-footer {
+ margin: 0.75rem 0 0;
+ font-size: 0.9rem;
+}
+
+.badge {
+ display: inline-block;
+ padding: 0.15rem 0.5rem;
+ border-radius: 999px;
+ font-size: 0.75rem;
+ font-weight: 600;
+}
+
+.status-active,
+.status-open {
+ background: #e6f4ea;
+ color: #1e6b3a;
+}
+
+.status-paused,
+.status-progress {
+ background: #eef4fc;
+ color: #1a4d8f;
+}
+
+.status-blocked {
+ background: #fdecea;
+ color: #b00020;
+}
+
+.status-done,
+.status-completed {
+ background: #eee;
+ color: #555;
+}
+
+.status-discarded,
+.status-archived {
+ background: #f3f3f3;
+ color: #777;
+}
+
+.priority-low {
+ background: #f3f3f3;
+ color: #666;
+}
+
+.priority-normal {
+ background: #eef4fc;
+ color: #1a4d8f;
+}
+
+.priority-high {
+ background: #fff3e0;
+ color: #b45309;
+}
+
+.empty-state {
+ padding: 1rem 0;
+ color: #555;
+}
+
+.empty-state-title {
+ font-weight: 600;
+ margin: 0 0 0.35rem;
+}
+
+.empty-state-message {
+ margin: 0;
+ font-size: 0.92rem;
+}
+
+.state-loading {
+ color: #555;
+ font-size: 0.92rem;
+}
+
+.state-error {
+ color: #b00020;
+ font-size: 0.92rem;
+}
+
+.state-error button {
+ margin-top: 0.5rem;
+}
+
+.item-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 0.75rem;
+}
+
+.list-item {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 0.5rem;
+ align-items: flex-start;
+}
+
+.card-list-item {
+ background: #fafafa;
+ border: 1px solid #eee;
+ border-radius: 6px;
+ padding: 0.75rem;
+}
+
+.list-item-main {
+ flex: 1;
+ min-width: 180px;
+}
+
+.list-item-desc {
+ margin: 0.35rem 0 0;
+ font-size: 0.9rem;
+ color: #555;
+}
+
+.list-item-sub {
+ font-size: 0.85rem;
+ display: block;
+ margin-top: 0.25rem;
+}
+
+.list-item-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ align-items: center;
+}
+
+.list-item-link {
+ text-decoration: none;
+ color: inherit;
+}
+
+.link-button {
+ background: none;
+ border: none;
+ color: #1a4d8f;
+ padding: 0;
+ cursor: pointer;
+ font: inherit;
+ text-decoration: underline;
+ width: auto;
+}
+
+.context-dl {
+ margin: 0;
+ display: grid;
+ gap: 0.5rem;
+}
+
+.context-dl dt {
+ font-size: 0.75rem;
+ color: #777;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.context-dl dd {
+ margin: 0.1rem 0 0;
+}
+
+.workspace-form textarea,
+.workspace-form select {
+ padding: 0.5rem 0.6rem;
+ border: 1px solid #ccc;
+ border-radius: 6px;
+ font: inherit;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.form-actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.inline-form-card {
+ margin-bottom: 1rem;
+}
+
+.section-header {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 1rem;
+}
+
+.section-header h2 {
+ margin: 0;
+ font-size: 1.05rem;
+}
+
+.section-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ align-items: center;
+}
+
+.breadcrumb {
+ font-size: 0.9rem;
+ margin-bottom: 0.75rem;
+ color: #555;
+}
+
+.breadcrumb a {
+ color: #1a4d8f;
+}
+
+.detail-goal {
+ color: #555;
+ margin: 0.5rem 0;
+}
+
+.detail-badges {
+ display: flex;
+ gap: 0.35rem;
+ flex-wrap: wrap;
+}
+
+.action-controls {
+ align-items: center;
+}
+
+.inline-select {
+ padding: 0.35rem 0.5rem;
+ border: 1px solid #ccc;
+ border-radius: 6px;
+ font: inherit;
+}
+
+.checkbox-label {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ font-size: 0.9rem;
+}
+
+.actor-select {
+ border: 1px solid #eee;
+ border-radius: 6px;
+ padding: 0.75rem;
+ margin: 0;
+}
+
+.actor-select legend {
+ font-size: 0.9rem;
+ padding: 0 0.25rem;
+}
+
+.actor-select-hint {
+ font-size: 0.85rem;
+ margin: 0.35rem 0 0;
+}
+
+.inline-filter {
+ font-size: 0.85rem;
+}
+
+.page-footer-link {
+ margin-top: 1.5rem;
+}
+
+@media (max-width: 640px) {
+ .app-header {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .app-nav {
+ order: 3;
+ width: 100%;
+ }
+
+ .widget-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .page-header {
+ flex-direction: column;
+ }
+
+ button {
+ width: 100%;
+ }
+
+ .form-actions button {
+ width: auto;
+ }
+}
diff --git a/frontend/src/widgets/BlockedActionsWidget.jsx b/frontend/src/widgets/BlockedActionsWidget.jsx
new file mode 100644
index 0000000..c8540e7
--- /dev/null
+++ b/frontend/src/widgets/BlockedActionsWidget.jsx
@@ -0,0 +1,54 @@
+import { useCallback, useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { listMyOpenActions } from '../api/actions.js'
+import { ActionList } from '../components/ActionList.jsx'
+import { EmptyState } from '../components/EmptyState.jsx'
+import { ErrorState } from '../components/ErrorState.jsx'
+import { LoadingState } from '../components/LoadingState.jsx'
+
+export function BlockedActionsWidget() {
+ 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 listMyOpenActions()
+ setActions(data.filter((a) => a.status === 'blocked'))
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ load()
+ }, [load])
+
+ return (
+
+
+
Blockierte Maßnahmen
+
+ Aktualisieren
+
+
+ {loading &&
}
+ {!loading && error &&
}
+ {!loading && !error && (
+
`/initiatives/${a.initiative_id}`}
+ empty={ }
+ />
+ )}
+ {!loading && !error && actions.length > 0 && (
+ Blockierte Maßnahmen erfordern Aufmerksamkeit.
+ )}
+
+ )
+}
diff --git a/frontend/src/widgets/InitiativesWidget.jsx b/frontend/src/widgets/InitiativesWidget.jsx
new file mode 100644
index 0000000..560b0cd
--- /dev/null
+++ b/frontend/src/widgets/InitiativesWidget.jsx
@@ -0,0 +1,64 @@
+import { useCallback, useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { listInitiatives } from '../api/initiatives.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'
+
+export function InitiativesWidget() {
+ const [initiatives, setInitiatives] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const data = await listInitiatives()
+ setInitiatives(
+ data.filter((i) => i.status === 'active' || i.status === 'paused').slice(0, 5),
+ )
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ load()
+ }, [load])
+
+ return (
+
+
+
Aktive Vorhaben
+
+ Alle
+
+
+ {loading &&
}
+ {!loading && error &&
}
+ {!loading && !error && initiatives.length === 0 && (
+
+ )}
+ {!loading && !error && initiatives.length > 0 && (
+
+ {initiatives.map((item) => (
+
+
+ {item.title}
+
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/widgets/MyOpenActionsWidget.jsx b/frontend/src/widgets/MyOpenActionsWidget.jsx
new file mode 100644
index 0000000..ef8a268
--- /dev/null
+++ b/frontend/src/widgets/MyOpenActionsWidget.jsx
@@ -0,0 +1,58 @@
+import { useCallback, useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { listMyOpenActions } from '../api/actions.js'
+import { ActionList } from '../components/ActionList.jsx'
+import { EmptyState } from '../components/EmptyState.jsx'
+import { ErrorState } from '../components/ErrorState.jsx'
+import { LoadingState } from '../components/LoadingState.jsx'
+
+export function MyOpenActionsWidget() {
+ 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 listMyOpenActions()
+ setActions(data.filter((a) => a.status !== 'blocked'))
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ load()
+ }, [load])
+
+ return (
+
+
+
Meine offenen Maßnahmen
+
+ Aktualisieren
+
+
+ {loading &&
}
+ {!loading && error &&
}
+ {!loading && !error && (
+
`/initiatives/${a.initiative_id}`}
+ empty={
+
+ }
+ />
+ )}
+ {!loading && !error && actions.length > 0 && (
+
+ Alle Vorhaben →
+
+ )}
+
+ )
+}
diff --git a/frontend/src/widgets/TenantContextWidget.jsx b/frontend/src/widgets/TenantContextWidget.jsx
new file mode 100644
index 0000000..4916c49
--- /dev/null
+++ b/frontend/src/widgets/TenantContextWidget.jsx
@@ -0,0 +1,42 @@
+import { useSession } from '../context/SessionContext.jsx'
+
+export function TenantContextWidget() {
+ const { context, me } = useSession()
+
+ if (!context?.tenant) {
+ return (
+
+
Kontext
+
Kein aktiver Tenant — bitte Tenant wählen.
+
+ )
+ }
+
+ return (
+
+
Kontext
+
+
+
Nutzer
+ {me?.display_name}
+
+
+
Tenant
+ {context.tenant.name}
+
+
+
Tenant-Rolle
+ {context.tenant.role}
+
+ {context.actor && (
+
+
Actor
+
+ {context.actor.type} · {context.actor.id.slice(0, 8)}…
+
+
+ )}
+
+
+ )
+}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index c544669..aef9abb 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -9,4 +9,8 @@ export default defineConfig({
'/api': 'http://127.0.0.1:8097',
},
},
+ test: {
+ environment: 'jsdom',
+ include: ['src/**/*.test.{js,jsx}'],
+ },
})