AP0.6: Kairo Workspace UX mit Frontend Widget/View Registry und Vitest.
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 29s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s

This commit is contained in:
Lars 2026-07-05 06:41:23 +02:00
parent 802c9259dc
commit 0e7184a59e
38 changed files with 2362 additions and 386 deletions

View File

@ -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.

View File

@ -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"

View File

@ -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`

View File

@ -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"
}
}

View File

@ -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
if (loading) {
return null
}
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 (!isAuthenticated) {
return <AuthPanel />
}
return fallback
return (
<Routes>
<Route element={<AppLayout />}>
{VIEWS.map((view) => (
<Route key={view.key} path={view.path} element={<view.component />} />
))}
<Route path="/" element={<Navigate to="/workspace" replace />} />
<Route path="*" element={<Navigate to="/workspace" replace />} />
</Route>
</Routes>
)
}
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 (
<main className="shell">
<header className="hero">
<h1>Jinkendo Kairo</h1>
<p>Operativer Program Director Sprint 0 / AP0.5</p>
<p className="muted">Capability Registry &amp; Entitlements</p>
</header>
<section className="card auth-card">
<h2>{token ? 'Session' : registrationOpen ? 'Ersteinrichtung' : 'Anmeldung'}</h2>
{!token && registrationOpen && (
<p className="setup-hint">
Noch kein Systemadministrator. Der erste Account wird automatisch{' '}
<strong>Portal-Admin</strong> und erhält einen Default-Tenant.
</p>
)}
{!token && setupLoading && (
<p className="muted">Prüfe Ersteinrichtung </p>
)}
{!token && setupError && (
<p className="error">{setupError}</p>
)}
{!token && registrationClosedBecauseUsersExist && (
<p className="setup-hint setup-hint-muted">
Es existiert bereits mindestens ein Benutzer ({setup.user_count ?? '≥1'}).
Registrierung ist nur für die Ersteinrichtung vorgesehen bitte anmelden.
Falls der Admin per <code>KAIRO_BOOTSTRAP_*</code> in der <code>.env</code> angelegt
wurde, nutzen Sie diese Zugangsdaten.
</p>
)}
{!token && !setupLoading && !setupError && (
<>
<div className="tabs" role="tablist">
<button
type="button"
role="tab"
className={authMode === 'login' ? 'tab active' : 'tab'}
aria-selected={authMode === 'login'}
onClick={() => { setAuthMode('login'); setAuthError(null) }}
>
Anmelden
</button>
<button
type="button"
role="tab"
className={authMode === 'register' ? 'tab active' : 'tab'}
aria-selected={authMode === 'register'}
disabled={!registrationOpen}
onClick={() => { setAuthMode('register'); setAuthError(null) }}
>
Registrieren
</button>
</div>
{authMode === 'login' && (
<form className="form" onSubmit={handleLogin}>
<label>
E-Mail
<input
type="email"
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
<label>
Passwort
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={8}
required
/>
</label>
<button type="submit" disabled={authBusy}>
{authBusy ? 'Anmelden …' : 'Anmelden'}
</button>
</form>
)}
{authMode === 'register' && registrationOpen && (
<form className="form" onSubmit={handleRegister}>
<label>
Anzeigename
<input
type="text"
autoComplete="name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
required
/>
</label>
<label>
Organisation / Tenant (optional)
<input
type="text"
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
placeholder="z. B. Mein Programm-Team"
/>
</label>
<label>
E-Mail
<input
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
<label>
Passwort
<input
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={8}
required
/>
</label>
<label>
Passwort bestätigen
<input
type="password"
autoComplete="new-password"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
minLength={8}
required
/>
</label>
<button type="submit" disabled={authBusy}>
{authBusy ? 'Registrieren …' : 'Systemadmin anlegen'}
</button>
</form>
)}
{authMode === 'register' && !registrationOpen && (
<p className="muted">
Registrierung ist geschlossen. Bitte melden Sie sich an oder wenden Sie sich an
den Administrator.
</p>
)}
</>
)}
{token && me && (
<div className="session">
<p>
Eingeloggt als <strong>{me.display_name}</strong> ({me.email})
</p>
<p className="muted">
{me.portal_role === 'admin' ? 'Portal-Systemadmin' : `Portal-Rolle: ${me.portal_role}`}
{context?.tenant?.name && ` · ${context.tenant.name}`}
</p>
<button type="button" className="secondary" onClick={handleLogout} disabled={authBusy}>
Abmelden
</button>
</div>
)}
{authError && <p className="error">{authError}</p>}
</section>
<section className="card">
<h2>Ersteinrichtung</h2>
{setupLoading && <p className="muted">Lade </p>}
{setupError && <p className="error">{setupError}</p>}
{setup && <pre>{JSON.stringify(setup, null, 2)}</pre>}
{!setupLoading && !setup && !setupError && (
<p className="muted">Kein Setup-Status verfügbar.</p>
)}
</section>
<section className="card">
<h2>API Health</h2>
{healthError && <p className="error">Fehler: {healthError}</p>}
{!healthError && !health && <p>Lade </p>}
{health && <pre>{JSON.stringify(health, null, 2)}</pre>}
</section>
{token && me && (
<>
<section className="card">
<h2>/api/me</h2>
<pre>{JSON.stringify(me, null, 2)}</pre>
</section>
{context && (
<section className="card">
<h2>/api/me/context</h2>
<pre>{JSON.stringify(context, null, 2)}</pre>
</section>
)}
</>
)}
</main>
<SessionProvider>
<AppRoutes />
</SessionProvider>
)
}

View File

@ -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
}

View File

@ -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',
},
]
}

View File

@ -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
}

View File

@ -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),
})
}

37
frontend/src/api/me.js Normal file
View File

@ -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 })
}

View File

@ -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 (
<form className="form workspace-form" onSubmit={handleSubmit}>
<label>
Titel
<input name="title" defaultValue={initial.title || ''} required maxLength={255} />
</label>
<label>
Beschreibung
<textarea name="description" rows={2} defaultValue={initial.description || ''} />
</label>
<label>
Status
<select name="status" defaultValue={initial.status || 'open'}>
{ACTION_STATUSES.map((s) => (
<option key={s} value={s}>
{ACTION_STATUS_LABELS[s]}
</option>
))}
</select>
</label>
<label>
Priorität
<select name="priority" defaultValue={initial.priority || 'normal'}>
{PRIORITIES.map((p) => (
<option key={p} value={p}>
{PRIORITY_LABELS[p]}
</option>
))}
</select>
</label>
<ActorSelect actors={actors} defaultSelected={defaultActors} />
<div className="form-actions">
<button type="submit" disabled={busy}>
{busy ? 'Speichern …' : submitLabel}
</button>
{onCancel && (
<button type="button" className="secondary" onClick={onCancel} disabled={busy}>
Abbrechen
</button>
)}
</div>
</form>
)
}

View File

@ -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 (
<li className="list-item action-item">
<div className="list-item-main">
<strong>{action.title}</strong>
{showInitiative && action.initiative_title && (
<span className="muted list-item-sub">{action.initiative_title}</span>
)}
{action.description && <p className="list-item-desc">{action.description}</p>}
</div>
<div className="list-item-meta">
<StatusBadge status={action.status} />
<PriorityBadge priority={action.priority} />
{manageLink && (
<Link to={manageLink} className="link-button">
Bearbeiten
</Link>
)}
</div>
</li>
)
}
export function ActionList({ actions, empty, showInitiative, getManageLink }) {
if (!actions.length) return empty
return (
<ul className="item-list">
{actions.map((action) => (
<ActionListItem
key={action.id}
action={action}
showInitiative={showInitiative}
manageLink={getManageLink?.(action)}
/>
))}
</ul>
)
}

View File

@ -0,0 +1,31 @@
export function ActorSelect({ actors = [], defaultSelected = [], label = 'Zugewiesene Actors' }) {
if (!actors.length) {
return (
<p className="muted actor-select-hint">
Kein Actor verfügbar bitte TenantContext prüfen.
</p>
)
}
return (
<fieldset className="actor-select">
<legend>{label}</legend>
{actors.map((actor) => (
<label key={actor.id} className="checkbox-label">
<input
type="checkbox"
name={`actor_${actor.id}`}
defaultChecked={defaultSelected.includes(actor.id)}
/>
{actor.name}
{actor.actor_type && <span className="muted"> ({actor.actor_type})</span>}
</label>
))}
{actors.length === 1 && (
<p className="muted actor-select-hint">
AP0.6: Nur der aktuelle Actor Tenant-Actor-Liste folgt in AP0.7.
</p>
)}
</fieldset>
)
}

View File

@ -0,0 +1,9 @@
export function EmptyState({ title, message, action }) {
return (
<div className="empty-state">
{title && <p className="empty-state-title">{title}</p>}
{message && <p className="empty-state-message">{message}</p>}
{action}
</div>
)
}

View File

@ -0,0 +1,12 @@
export function ErrorState({ message, onRetry }) {
return (
<div className="state-error">
<p>{message || 'Ein Fehler ist aufgetreten.'}</p>
{onRetry && (
<button type="button" className="secondary" onClick={onRetry}>
Erneut versuchen
</button>
)}
</div>
)
}

View File

@ -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 (
<form className="form workspace-form" onSubmit={handleSubmit}>
<label>
Titel
<input name="title" defaultValue={initial.title || ''} required maxLength={255} />
</label>
<label>
Ziel
<textarea name="goal" rows={3} defaultValue={initial.goal || ''} />
</label>
<label>
Status
<select name="status" defaultValue={initial.status || 'active'}>
{INITIATIVE_STATUSES.map((s) => (
<option key={s} value={s}>
{INITIATIVE_STATUS_LABELS[s]}
</option>
))}
</select>
</label>
<label>
Priorität
<select name="priority" defaultValue={initial.priority || 'normal'}>
{PRIORITIES.map((p) => (
<option key={p} value={p}>
{PRIORITY_LABELS[p]}
</option>
))}
</select>
</label>
<div className="form-actions">
<button type="submit" disabled={busy}>
{busy ? 'Speichern …' : submitLabel}
</button>
{onCancel && (
<button type="button" className="secondary" onClick={onCancel} disabled={busy}>
Abbrechen
</button>
)}
</div>
</form>
)
}

View File

@ -0,0 +1,3 @@
export function LoadingState({ message = 'Lade …' }) {
return <p className="state-loading">{message}</p>
}

View File

@ -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 <span className={`badge priority-badge ${variant}`}>{label}</span>
}

View File

@ -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 <span className={`badge status-badge ${variant}`}>{label}</span>
}

View File

@ -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(<StatusBadge status="blocked" />)
expect(screen.getByText('Blockiert')).toBeTruthy()
})
it('renders known initiative status', () => {
render(<StatusBadge kind="initiative" status="active" />)
expect(screen.getByText('Aktiv')).toBeTruthy()
})
})
describe('PriorityBadge', () => {
it('renders known priority', () => {
render(<PriorityBadge priority="high" />)
expect(screen.getByText('Hoch')).toBeTruthy()
})
})

View File

@ -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',
}

View File

@ -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 <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
}
export function useSession() {
const ctx = useContext(SessionContext)
if (!ctx) throw new Error('useSession requires SessionProvider')
return ctx
}

View File

@ -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 }
}

View File

@ -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 (
<main className="shell">
<LoadingState message="Session wird geladen …" />
</main>
)
}
return (
<div className="app-layout">
<header className="app-header">
<div className="app-header-brand">
<strong>Kairo</strong>
{context?.tenant?.name && <span className="muted"> · {context.tenant.name}</span>}
</div>
<nav className="app-nav">
{navViews.map((view) => (
<NavLink
key={view.key}
to={view.path}
className={({ isActive }) => (isActive ? 'nav-link active' : 'nav-link')}
>
{view.title}
</NavLink>
))}
</nav>
<div className="app-header-user">
<span className="user-label">{me?.display_name}</span>
<button type="button" className="secondary" onClick={signOut}>
Abmelden
</button>
</div>
</header>
<main className="shell workspace-shell">
<Outlet />
</main>
</div>
)
}

View File

@ -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(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

View File

@ -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 (
<main className="shell auth-shell">
<header className="hero">
<h1>Jinkendo Kairo</h1>
<p>Operativer Program Director AP0.6 Workspace</p>
</header>
<section className="card auth-card">
<h2>{registrationOpen ? 'Ersteinrichtung' : 'Anmelden'}</h2>
{setupLoading && <p className="muted">Prüfe Ersteinrichtung </p>}
{setupError && <p className="error">{setupError}</p>}
{!setupLoading && !setupError && (
<>
<div className="tabs" role="tablist">
<button
type="button"
className={authMode === 'login' ? 'tab active' : 'tab'}
onClick={() => { setAuthMode('login'); setAuthError(null) }}
>
Anmelden
</button>
<button
type="button"
className={authMode === 'register' ? 'tab active' : 'tab'}
disabled={!registrationOpen}
onClick={() => { setAuthMode('register'); setAuthError(null) }}
>
Registrieren
</button>
</div>
{authMode === 'login' && (
<form className="form" onSubmit={handleLogin}>
<label>
E-Mail
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label>
Passwort
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} minLength={8} required />
</label>
<button type="submit" disabled={authBusy}>
{authBusy ? 'Anmelden …' : 'Anmelden'}
</button>
</form>
)}
{authMode === 'register' && registrationOpen && (
<form className="form" onSubmit={handleRegister}>
<label>
Anzeigename
<input value={displayName} onChange={(e) => setDisplayName(e.target.value)} required />
</label>
<label>
Organisation (optional)
<input value={organizationName} onChange={(e) => setOrganizationName(e.target.value)} />
</label>
<label>
E-Mail
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label>
Passwort
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} minLength={8} required />
</label>
<label>
Passwort bestätigen
<input type="password" value={passwordConfirm} onChange={(e) => setPasswordConfirm(e.target.value)} minLength={8} required />
</label>
<button type="submit" disabled={authBusy}>
{authBusy ? 'Registrieren …' : 'Systemadmin anlegen'}
</button>
</form>
)}
</>
)}
{authError && <p className="error">{authError}</p>}
</section>
</main>
)
}

View File

@ -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 <LoadingState message="Lade Vorhaben …" />
if (error && !initiative) return <ErrorState message={error} onRetry={load} />
if (!initiative) return <ErrorState message="Vorhaben nicht gefunden." />
return (
<div className="page">
<nav className="breadcrumb">
<Link to="/initiatives">Vorhaben</Link>
<span aria-hidden="true"> / </span>
<span>{initiative.title}</span>
</nav>
<header className="page-header detail-header">
<div>
<h1>{initiative.title}</h1>
{initiative.goal && <p className="detail-goal">{initiative.goal}</p>}
<div className="detail-badges">
<StatusBadge kind="initiative" status={initiative.status} />
<PriorityBadge priority={initiative.priority} />
</div>
</div>
</header>
{error && <p className="error">{error}</p>}
<section className="card">
<div className="section-header">
<h2>Maßnahmen</h2>
<div className="section-actions">
<label className="checkbox-label inline-filter">
<input
type="checkbox"
checked={hideDone}
onChange={(e) => setHideDone(e.target.checked)}
/>
Erledigte ausblenden
</label>
{hasCapability('kairo.action.manage') && (
<button type="button" onClick={() => { setShowActionForm((v) => !v); setEditingAction(null) }}>
{showActionForm ? 'Abbrechen' : 'Maßnahme anlegen'}
</button>
)}
</div>
</div>
{showActionForm && (
<div className="inline-form-block">
<ActionForm
actors={actors}
onSubmit={handleCreateAction}
onCancel={() => setShowActionForm(false)}
busy={formBusy}
submitLabel="Maßnahme anlegen"
/>
</div>
)}
{visibleActions.length === 0 && (
<EmptyState message="Noch keine Maßnahmen in diesem Vorhaben." />
)}
<ul className="item-list actions-detail-list">
{visibleActions.map((action) => (
<li key={action.id} className="list-item card-list-item">
{editingAction === action.id ? (
<ActionForm
initial={action}
actors={actors}
onSubmit={(payload) => handleUpdateAction(action.id, payload)}
onCancel={() => setEditingAction(null)}
busy={formBusy}
submitLabel="Speichern"
/>
) : (
<>
<div className="list-item-main">
<strong>{action.title}</strong>
{action.description && (
<p className="list-item-desc">{action.description}</p>
)}
{action.assigned_actor_ids?.length > 0 && (
<p className="muted list-item-sub">
Zugewiesen: {action.assigned_actor_ids.length} Actor(s)
</p>
)}
</div>
<div className="list-item-meta action-controls">
<StatusBadge status={action.status} />
<PriorityBadge priority={action.priority} />
{hasCapability('kairo.action.manage') && (
<>
<select
className="inline-select"
value={action.status}
onChange={(e) => handleQuickStatus(action, e.target.value)}
aria-label="Status ändern"
>
{['open', 'in_progress', 'blocked', 'done', 'discarded'].map((s) => (
<option key={s} value={s}>
{ACTION_STATUS_LABELS[s]}
</option>
))}
</select>
<button
type="button"
className="secondary"
onClick={() => {
setEditingAction(action.id)
setShowActionForm(false)
}}
>
Bearbeiten
</button>
</>
)}
</div>
</>
)}
</li>
))}
</ul>
</section>
</div>
)
}

View File

@ -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 (
<div className="page">
<header className="page-header">
<div>
<h1>Vorhaben</h1>
<p className="muted">Aktive und pausierte Vorhaben im Tenant.</p>
</div>
{hasCapability('kairo.initiative.manage') && (
<button type="button" onClick={() => setShowForm((v) => !v)}>
{showForm ? 'Abbrechen' : 'Vorhaben anlegen'}
</button>
)}
</header>
{showForm && (
<section className="card inline-form-card">
<h2>Neues Vorhaben</h2>
<InitiativeForm
onSubmit={handleCreate}
onCancel={() => setShowForm(false)}
busy={formBusy}
submitLabel="Vorhaben anlegen"
/>
</section>
)}
{loading && <LoadingState message="Lade Vorhaben …" />}
{!loading && error && <ErrorState message={error} onRetry={load} />}
{!loading && !error && initiatives.length === 0 && (
<EmptyState message="Noch keine Vorhaben. Lege dein erstes Vorhaben an." />
)}
{!loading && !error && initiatives.length > 0 && (
<ul className="item-list initiatives-list">
{initiatives.map((item) => (
<li key={item.id} className="list-item card-list-item">
<div className="list-item-main">
<Link to={`/initiatives/${item.id}`}>
<strong>{item.title}</strong>
</Link>
{item.goal && <p className="list-item-desc">{item.goal}</p>}
</div>
<div className="list-item-meta">
<StatusBadge kind="initiative" status={item.status} />
<PriorityBadge priority={item.priority} />
{openCounts[item.id] != null && (
<span className="muted">{openCounts[item.id]} offen</span>
)}
</div>
</li>
))}
</ul>
)}
</div>
)
}

View File

@ -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 (
<div className="page workspace-page">
<header className="page-header">
<div>
<h1>Workspace</h1>
<p className="muted">Dein Überblick über Vorhaben und Maßnahmen.</p>
</div>
{hasCapability('kairo.initiative.manage') && (
<button type="button" onClick={() => setShowForm((v) => !v)}>
{showForm ? 'Abbrechen' : 'Vorhaben anlegen'}
</button>
)}
</header>
{showForm && (
<section className="card inline-form-card">
<h2>Neues Vorhaben</h2>
{formError && <p className="error">{formError}</p>}
<InitiativeForm
onSubmit={handleCreateInitiative}
onCancel={() => setShowForm(false)}
busy={formBusy}
submitLabel="Vorhaben anlegen"
/>
</section>
)}
<div className="widget-grid">
{widgets.map((widget) => {
const Component = widget.component
return <Component key={widget.key} />
})}
</div>
{hasCapability('kairo.initiative.read') && (
<p className="page-footer-link">
<Link to="/initiatives">Zur Vorhabenliste </Link>
</p>
)}
</div>
)
}

View File

@ -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)
})
})

View File

@ -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<any>} 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)
}

View File

@ -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<any>} 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>|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)
}

View File

@ -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;
}
}

View File

@ -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 (
<div className="widget-card widget-card-alert">
<div className="widget-header">
<h3>Blockierte Maßnahmen</h3>
<button type="button" className="link-button" onClick={load} disabled={loading}>
Aktualisieren
</button>
</div>
{loading && <LoadingState />}
{!loading && error && <ErrorState message={error} onRetry={load} />}
{!loading && !error && (
<ActionList
actions={actions}
showInitiative
getManageLink={(a) => `/initiatives/${a.initiative_id}`}
empty={<EmptyState message="Keine blockierten Maßnahmen." />}
/>
)}
{!loading && !error && actions.length > 0 && (
<p className="widget-footer muted">Blockierte Maßnahmen erfordern Aufmerksamkeit.</p>
)}
</div>
)
}

View File

@ -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 (
<div className="widget-card">
<div className="widget-header">
<h3>Aktive Vorhaben</h3>
<Link to="/initiatives" className="link-button">
Alle
</Link>
</div>
{loading && <LoadingState />}
{!loading && error && <ErrorState message={error} onRetry={load} />}
{!loading && !error && initiatives.length === 0 && (
<EmptyState message="Noch keine aktiven Vorhaben. Lege dein erstes Vorhaben an." />
)}
{!loading && !error && initiatives.length > 0 && (
<ul className="item-list">
{initiatives.map((item) => (
<li key={item.id} className="list-item">
<Link to={`/initiatives/${item.id}`} className="list-item-link">
<strong>{item.title}</strong>
</Link>
<div className="list-item-meta">
<StatusBadge kind="initiative" status={item.status} />
<PriorityBadge priority={item.priority} />
</div>
</li>
))}
</ul>
)}
</div>
)
}

View File

@ -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 (
<div className="widget-card">
<div className="widget-header">
<h3>Meine offenen Maßnahmen</h3>
<button type="button" className="link-button" onClick={load} disabled={loading}>
Aktualisieren
</button>
</div>
{loading && <LoadingState />}
{!loading && error && <ErrorState message={error} onRetry={load} />}
{!loading && !error && (
<ActionList
actions={actions}
showInitiative
getManageLink={(a) => `/initiatives/${a.initiative_id}`}
empty={
<EmptyState message="Keine offenen Maßnahmen. Du kannst eine neue Maßnahme in einem Vorhaben anlegen." />
}
/>
)}
{!loading && !error && actions.length > 0 && (
<p className="widget-footer">
<Link to="/initiatives">Alle Vorhaben </Link>
</p>
)}
</div>
)
}

View File

@ -0,0 +1,42 @@
import { useSession } from '../context/SessionContext.jsx'
export function TenantContextWidget() {
const { context, me } = useSession()
if (!context?.tenant) {
return (
<div className="widget-card">
<h3>Kontext</h3>
<p className="muted">Kein aktiver Tenant bitte Tenant wählen.</p>
</div>
)
}
return (
<div className="widget-card">
<h3>Kontext</h3>
<dl className="context-dl">
<div>
<dt>Nutzer</dt>
<dd>{me?.display_name}</dd>
</div>
<div>
<dt>Tenant</dt>
<dd>{context.tenant.name}</dd>
</div>
<div>
<dt>Tenant-Rolle</dt>
<dd>{context.tenant.role}</dd>
</div>
{context.actor && (
<div>
<dt>Actor</dt>
<dd>
{context.actor.type} · {context.actor.id.slice(0, 8)}
</dd>
</div>
)}
</dl>
</div>
)
}

View File

@ -9,4 +9,8 @@ export default defineConfig({
'/api': 'http://127.0.0.1:8097',
},
},
test: {
environment: 'jsdom',
include: ['src/**/*.test.{js,jsx}'],
},
})