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
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:
parent
802c9259dc
commit
0e7184a59e
24
README.md
24
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"
|
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)
|
### Registries (AP0.4)
|
||||||
|
|
||||||
Feature-, Prompt-, Placeholder- und Config-Registry analog zur Rights Registry: Code-Registrierung → Startup-Sync → DB.
|
Feature-, Prompt-, Placeholder- und Config-Registry analog zur Rights Registry: Code-Registrierung → Startup-Sync → DB.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
APP_VERSION = "0.5.0-ap0.5"
|
APP_VERSION = "0.6.0-ap0.6"
|
||||||
DB_SCHEMA_VERSION = "006"
|
DB_SCHEMA_VERSION = "006"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
276
docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md
Normal file
276
docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md
Normal 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`
|
||||||
|
|
@ -1,19 +1,25 @@
|
||||||
{
|
{
|
||||||
"name": "kairo-jinkendo-frontend",
|
"name": "kairo-jinkendo-frontend",
|
||||||
"version": "0.1.0-ap0.1",
|
"version": "0.6.0-ap0.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --port 3097",
|
"dev": "vite --port 3097",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.22.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/react": "^14.2.1",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"vite": "^5.1.4"
|
"jsdom": "^24.0.0",
|
||||||
|
"vite": "^5.1.4",
|
||||||
|
"vitest": "^1.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
if (loading) {
|
||||||
const headers = { 'Content-Type': 'application/json' }
|
return null
|
||||||
if (token) headers['X-Auth-Token'] = token
|
|
||||||
return headers
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseError(body, fallback) {
|
if (!isAuthenticated) {
|
||||||
if (!body) return fallback
|
return <AuthPanel />
|
||||||
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
|
|
||||||
|
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() {
|
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 (
|
return (
|
||||||
<main className="shell">
|
<SessionProvider>
|
||||||
<header className="hero">
|
<AppRoutes />
|
||||||
<h1>Jinkendo Kairo</h1>
|
</SessionProvider>
|
||||||
<p>Operativer Program Director — Sprint 0 / AP0.5</p>
|
|
||||||
<p className="muted">Capability Registry & 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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
32
frontend/src/api/actions.js
Normal file
32
frontend/src/api/actions.js
Normal 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
|
||||||
|
}
|
||||||
16
frontend/src/api/actors.js
Normal file
16
frontend/src/api/actors.js
Normal 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',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
44
frontend/src/api/client.js
Normal file
44
frontend/src/api/client.js
Normal 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
|
||||||
|
}
|
||||||
38
frontend/src/api/initiatives.js
Normal file
38
frontend/src/api/initiatives.js
Normal 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
37
frontend/src/api/me.js
Normal 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 })
|
||||||
|
}
|
||||||
71
frontend/src/components/ActionForm.jsx
Normal file
71
frontend/src/components/ActionForm.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
frontend/src/components/ActionList.jsx
Normal file
42
frontend/src/components/ActionList.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
frontend/src/components/ActorSelect.jsx
Normal file
31
frontend/src/components/ActorSelect.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
frontend/src/components/EmptyState.jsx
Normal file
9
frontend/src/components/EmptyState.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
frontend/src/components/ErrorState.jsx
Normal file
12
frontend/src/components/ErrorState.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
frontend/src/components/InitiativeForm.jsx
Normal file
64
frontend/src/components/InitiativeForm.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
frontend/src/components/LoadingState.jsx
Normal file
3
frontend/src/components/LoadingState.jsx
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export function LoadingState({ message = 'Lade …' }) {
|
||||||
|
return <p className="state-loading">{message}</p>
|
||||||
|
}
|
||||||
13
frontend/src/components/PriorityBadge.jsx
Normal file
13
frontend/src/components/PriorityBadge.jsx
Normal 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>
|
||||||
|
}
|
||||||
20
frontend/src/components/StatusBadge.jsx
Normal file
20
frontend/src/components/StatusBadge.jsx
Normal 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>
|
||||||
|
}
|
||||||
23
frontend/src/components/badges.test.jsx
Normal file
23
frontend/src/components/badges.test.jsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
25
frontend/src/constants/status.js
Normal file
25
frontend/src/constants/status.js
Normal 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',
|
||||||
|
}
|
||||||
100
frontend/src/context/SessionContext.jsx
Normal file
100
frontend/src/context/SessionContext.jsx
Normal 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
|
||||||
|
}
|
||||||
20
frontend/src/hooks/useCapabilities.js
Normal file
20
frontend/src/hooks/useCapabilities.js
Normal 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 }
|
||||||
|
}
|
||||||
50
frontend/src/layout/AppLayout.jsx
Normal file
50
frontend/src/layout/AppLayout.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
import './app.css'
|
import './app.css'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
151
frontend/src/pages/AuthPanel.jsx
Normal file
151
frontend/src/pages/AuthPanel.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
233
frontend/src/pages/InitiativeDetailPage.jsx
Normal file
233
frontend/src/pages/InitiativeDetailPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
123
frontend/src/pages/InitiativesPage.jsx
Normal file
123
frontend/src/pages/InitiativesPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
frontend/src/pages/WorkspacePage.jsx
Normal file
77
frontend/src/pages/WorkspacePage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
frontend/src/registry/registry.test.js
Normal file
39
frontend/src/registry/registry.test.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
51
frontend/src/registry/viewRegistry.js
Normal file
51
frontend/src/registry/viewRegistry.js
Normal 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)
|
||||||
|
}
|
||||||
71
frontend/src/registry/widgetRegistry.js
Normal file
71
frontend/src/registry/widgetRegistry.js
Normal 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)
|
||||||
|
}
|
||||||
400
frontend/src/styles/workspace.css
Normal file
400
frontend/src/styles/workspace.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
frontend/src/widgets/BlockedActionsWidget.jsx
Normal file
54
frontend/src/widgets/BlockedActionsWidget.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
64
frontend/src/widgets/InitiativesWidget.jsx
Normal file
64
frontend/src/widgets/InitiativesWidget.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
frontend/src/widgets/MyOpenActionsWidget.jsx
Normal file
58
frontend/src/widgets/MyOpenActionsWidget.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
frontend/src/widgets/TenantContextWidget.jsx
Normal file
42
frontend/src/widgets/TenantContextWidget.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -9,4 +9,8 @@ export default defineConfig({
|
||||||
'/api': 'http://127.0.0.1:8097',
|
'/api': 'http://127.0.0.1:8097',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['src/**/*.test.{js,jsx}'],
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user