diff --git a/README.md b/README.md index 8e84552..7623f1d 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,30 @@ curl -s -X POST http://localhost:8097/api/initiatives/INITIATIVE_ID/actions \ curl -s http://localhost:8097/api/actions/me/open -H "X-Auth-Token: TOKEN" ``` +### Workspace UI (AP0.6) + +Nach Login leitet Kairo auf den **Workspace** weiter. + +| Route | Beschreibung | +|-------|--------------| +| `/workspace` | Karten: Kontext, offene Maßnahmen, blockierte Maßnahmen, aktive Vorhaben | +| `/initiatives` | Vorhabenliste mit offenen Maßnahmen-Zähler | +| `/initiatives/:id` | Vorhaben-Detail mit Maßnahmen CRUD, Status, Zuweisung | + +**Frontend-Struktur:** `frontend/src/api/`, `registry/`, `widgets/`, `pages/`, `components/` + +**Widget Registry (frontend-only):** Widgets in `registry/widgetRegistry.js` — Capability-Filter, keine Backend-Persistenz. + +**Tests (Frontend):** + +```bash +cd frontend && npm install && npm run test && npm run build +``` + +**Manueller Smoke-Test:** Login → Workspace → Vorhaben anlegen → Maßnahme anlegen → Status auf *Erledigt* → prüfen, dass sie aus „Meine offenen Maßnahmen“ verschwindet. + +**Bekannte Lücke:** Kein `GET /api/actors` — Zuweisung nutzt aktuell den Human Actor aus TenantContext (AP0.7). + ### Registries (AP0.4) Feature-, Prompt-, Placeholder- und Config-Registry analog zur Rights Registry: Code-Registrierung → Startup-Sync → DB. diff --git a/backend/version.py b/backend/version.py index 203c59c..8071c24 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.5.0-ap0.5" +APP_VERSION = "0.6.0-ap0.6" DB_SCHEMA_VERSION = "006" APP_NAME = "jinkendo-kairo" diff --git a/docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md b/docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md new file mode 100644 index 0000000..1e81478 --- /dev/null +++ b/docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md @@ -0,0 +1,276 @@ +# AP0.6 – Abschlussbericht Kairo Workspace UX mit minimaler Widget Registry + +**Status:** abgeschlossen +**Stand:** 2026-07-05 +**Branch:** `develop` · Frontend `0.6.0-ap0.6` · Backend Schema `006` (unverändert) +**Backend-Änderungen:** keine + +--- + +## 1. Scope und Einordnung + +AP0.6 macht Kairo erstmals als **Arbeitswerkzeug nutzbar** — ohne neue Foundation-Schicht, ohne Backend-Widget-Registry, ohne KI/MCP/Projects. + +| Anforderung (AP0.6) | Status | +|---------------------|--------| +| Workspace mit Widget-Karten | ✓ | +| Vorhabenliste + Detail | ✓ | +| Maßnahmen CRUD in UI | ✓ | +| Status / Priorität ändern | ✓ | +| Actor-Zuweisung (minimal) | ✓ | +| Meine offenen Maßnahmen | ✓ | +| Blockierte Maßnahmen erkennbar | ✓ | +| Erledigte filterbar/ausblenden | ✓ | +| Frontend Widget Registry | ✓ | +| View Registry + Navigation | ✓ | +| Capability-bewusste UI | ✓ | +| Loading / Error / Empty States | ✓ | +| Vitest (Registry + Badges) | ✓ 7 Tests | +| README UI-Doku | ✓ | + +**Produktfrage beantwortet:** Ja — mit AP0.5-APIs kann ein Nutzer Vorhaben und Maßnahmen praktisch verwalten. + +--- + +## 2. Umgesetzte Dateien + +| Bereich | Dateien | +|---------|---------| +| API-Client | `frontend/src/api/client.js`, `me.js`, `initiatives.js`, `actions.js`, `actors.js` | +| Session | `frontend/src/context/SessionContext.jsx`, `hooks/useCapabilities.js` | +| Registry | `frontend/src/registry/widgetRegistry.js`, `viewRegistry.js` | +| Widgets | `TenantContextWidget`, `MyOpenActionsWidget`, `BlockedActionsWidget`, `InitiativesWidget` | +| Pages | `WorkspacePage`, `InitiativesPage`, `InitiativeDetailPage`, `AuthPanel` | +| Components | Badges, Forms, Lists, Empty/Loading/Error | +| Layout | `frontend/src/layout/AppLayout.jsx` | +| Styles | `frontend/src/styles/workspace.css` | +| App | `App.jsx`, `main.jsx` (React Router) | +| Tests | `registry.test.js`, `badges.test.jsx` | +| Doku | `README.md`, dieser Bericht | + +--- + +## 3. Frontend-Struktur + +```text +frontend/src/ + api/ # HTTP-Schicht + context/ # SessionProvider + hooks/ # useCapabilities + registry/ # Widget + View Registry + widgets/ # Workspace-Karten + pages/ # Routen + components/ # Wiederverwendbare UI-Bausteine + layout/ # App-Shell + Navigation + constants/ # Status/Priority Labels + styles/ # workspace.css +``` + +Keine monolithische God Page — `App.jsx` nur Routing + Session. + +--- + +## 4. Widget Registry + +**Datei:** `registry/widgetRegistry.js` + +| key | title | capability | order | +|-----|-------|------------|-------| +| `kairo.tenant_context` | Kontext | — | 0 | +| `kairo.my_open_actions` | Meine offenen Maßnahmen | `kairo.action.read` | 10 | +| `kairo.blocked_actions` | Blockierte Maßnahmen | `kairo.action.read` | 20 | +| `kairo.active_initiatives` | Aktive Vorhaben | `kairo.initiative.read` | 30 | + +`getWidgetsForArea('workspace', capabilities)` filtert und sortiert. + +**Frontend-only** — keine DB, kein Drag & Drop, keine User-Layouts. + +--- + +## 5. View Registry / Navigation + +**Datei:** `registry/viewRegistry.js` + +| key | path | Nav | capability | +|-----|------|-----|------------| +| `kairo.workspace` | `/workspace` | ✓ | `kairo.action.read` | +| `kairo.initiatives` | `/initiatives` | ✓ | `kairo.initiative.read` | +| `kairo.initiative_detail` | `/initiatives/:id` | — | `kairo.initiative.read` | + +`AppLayout` rendert NavLinks aus `getNavViews(capabilities)`. + +--- + +## 6. Workspace UX + +Route `/workspace`: + +- Tenant-/Actor-Kontext-Karte +- Offene Maßnahmen (ohne `blocked` — die haben eigene Karte) +- Blockierte Maßnahmen (gefiltert aus `/api/actions/me/open`) +- Aktive/pausierte Vorhaben (Top 5) +- Quick-Action „Vorhaben anlegen“ (mit `kairo.initiative.manage`) + +--- + +## 7. Vorhaben-UX + +**Liste** `/initiatives`: aktive + pausierte Vorhaben, Status/Priority-Badges, offene Maßnahmen-Zähler (parallele API-Calls pro Vorhaben). + +**Detail** `/initiatives/:id`: Titel, Goal, Badges, Maßnahmenliste, Filter „Erledigte ausblenden“. + +--- + +## 8. Maßnahmen-UX + +- Anlegen via `ActionForm` im Detail +- Inline Status-Dropdown (Quick-Change) +- Vollständiges Bearbeiten (Titel, Beschreibung, Status, Priority, Assignment) +- Erledigte (`done`/`discarded`) standardmäßig ausgeblendet +- Offene Liste über Backend `/api/actions/me/open` — erledigte erscheinen dort nicht + +--- + +## 9. Actor-Zuweisung + +**Lücke:** Kein `GET /api/actors` im Backend. + +**AP0.6-Lösung:** `api/actors.js` → `actorsFromContext()` liefert nur den aktuellen Human Actor aus `/api/me/context`. + +`ActorSelect` zeigt Checkboxen für verfügbare Actors; Hinweistext auf AP0.7. + +Assignments via `PUT /api/actions/{id}/assignments` — Actor-IDs, nicht User. + +--- + +## 10. Capability-bewusste UI + +Quelle: `/api/me/entitlements` + `/api/me/context` → `useCapabilities()`. + +| Capability | UI-Effekt | +|------------|-----------| +| `kairo.initiative.manage` | Button „Vorhaben anlegen“ | +| `kairo.initiative.read` | Vorhaben-Widgets/Views | +| `kairo.action.manage` | Maßnahme anlegen/bearbeiten, Status, Assignment | +| `kairo.action.read` | Workspace, offene Maßnahmen | + +Backend bleibt autoritativ — Frontend nur UX-Gates. + +--- + +## 11. Loading / Error / Empty States + +Jedes Widget und jede Page nutzt `LoadingState`, `ErrorState` (mit Retry), `EmptyState` mit kontextbezogenen Texten. + +--- + +## 12. Responsive Verhalten + +`workspace.css` — `@media (max-width: 640px)`: + +- Header stapelt vertikal +- Widget-Grid einspaltig +- Buttons full-width (Form-Ausnahmen) + +--- + +## 13. Tests und Smoke-Tests + +### Automatisiert (Vitest) + +```bash +cd frontend && npm run test +``` + +7 Tests: Registry-Inhalt, Capability-Filter, StatusBadge, PriorityBadge. + +### Manueller Smoke-Test + +1. Login (Dev: `lars@stommer.com`) +2. `/workspace` — Karten laden +3. Vorhaben anlegen +4. Detail → Maßnahme anlegen (Self-Assignment) +5. Status → In Arbeit → Erledigt +6. Workspace: Maßnahme nicht mehr unter „Offen“ +7. Blockiert setzen → erscheint in „Blockierte Maßnahmen“ + +Kein Playwright im Repo — dokumentierter manueller Smoke-Test. + +--- + +## 14. Übernommene Muster aus Mitai + +| Muster | Umsetzung | +|--------|-----------| +| Kartenbasierte Oberfläche | `widget-card`, `widget-grid` | +| Widget-Registry-Idee | `widgetRegistry.js` (frontend-only, minimal) | +| Status-Badges | `StatusBadge`, `PriorityBadge` | +| Leere Zustände | `EmptyState` | +| Loading/Error | eigene Komponenten | + +**Nicht übernommen:** volles Widget Dashboard, Layout-Persistenz, Drag & Drop, Backend-Registry. + +--- + +## 15. Übernommene Muster aus Shinkan + +| Muster | Umsetzung | +|--------|-----------| +| Schlankes KPI/Listen-Layout | kompakte Widget-Karten | +| Tenant-Kontext sichtbar | `TenantContextWidget`, App-Header | +| Capability-bewusste Nav | `getNavViews()` | +| Robuste Frontend-Struktur | api/ + pages/ + components/ | + +--- + +## 16. Bewusst nicht übernommene Muster + +- Mitai/Shinkan-Domänenlogik +- Backend-Widget-Registry +- Admin-Konsole, Prompt/Feature/Config UI +- Actor-Admin-API +- Playwright E2E (noch nicht im Stack) +- Designsystem / UI-Library + +--- + +## 17. Backend-Änderungen + +**Keine.** AP0.5-APIs reichen aus. + +--- + +## 18. Abweichungen von der Spezifikation + +| Thema | Abweichung | Begründung | +|-------|------------|------------| +| Actor-Liste | nur Context-Actor | kein GET /api/actors | +| Owner Actor wählen | nicht in UI | keine Actor-List-API | +| Open-Count Vorhaben | N+1 API-Calls | akzeptabel für MVP | +| Playwright Smoke | manuell dokumentiert | nicht im CI-Stack | + +--- + +## 19. Offene UX-/Produktentscheidungen + +1. **`GET /api/actors`** — read-only für Assignment-UI (AP0.7)? +2. **Initiative bearbeiten in UI** — PATCH existiert, Form fehlt noch +3. **Workspace-Refresh** — Widgets laden eigenständig; kein globales Event-Bus +4. **Foundation AP0.5 (Audit/Admin)** — weiterhin AP0.6/0.7 backlog + +--- + +## 20. Empfehlung für AP0.7 + +**Kleinster Nutzwert:** `GET /api/actors` (tenant-scoped, read, `kairo.action.read`) + ActorSelect mit echten Tenant-Actors. + +Alternativ: **Kommentare pro Maßnahme** (aus AP0.5-Empfehlung) — erhöht Kollaboration ohne Backend-Explosion. + +--- + +## Referenzen + +- AP0.5 Backend: `docs/sprints/Sprint0_AP0_5_Completion_Report_v0.2.md` +- ADP Scope: `docs/architecture/ADP_AP0_5_Sprint0_Scope_Shift_v0.1.md` +- Mitai: `docs/reference/design-principles/mitai/DASHBOARD_WIDGETS_DESIGN_PRINCIPLES.md` +- Shinkan: `docs/reference/design-principles/shinkan/NAVIGATION_IA_DESIGN_PRINCIPLES.md` diff --git a/frontend/package.json b/frontend/package.json index e99b667..c4c5db6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,19 +1,25 @@ { "name": "kairo-jinkendo-frontend", - "version": "0.1.0-ap0.1", + "version": "0.6.0-ap0.6", "private": true, "type": "module", "scripts": { "dev": "vite --port 3097", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router-dom": "^6.22.0" }, "devDependencies": { + "@testing-library/react": "^14.2.1", "@vitejs/plugin-react": "^4.2.1", - "vite": "^5.1.4" + "jsdom": "^24.0.0", + "vite": "^5.1.4", + "vitest": "^1.3.1" } } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 586b2d9..d95c354 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,389 +1,39 @@ -import { useCallback, useEffect, useState } from 'react' +import { Navigate, Route, Routes } from 'react-router-dom' +import { SessionProvider, useSession } from './context/SessionContext.jsx' +import { AppLayout } from './layout/AppLayout.jsx' +import { AuthPanel } from './pages/AuthPanel.jsx' +import { VIEWS } from './registry/viewRegistry.js' +import './app.css' +import './styles/workspace.css' -const TOKEN_KEY = 'kairo_auth_token' +function AppRoutes() { + const { isAuthenticated, loading } = useSession() -function apiHeaders(token) { - const headers = { 'Content-Type': 'application/json' } - if (token) headers['X-Auth-Token'] = token - return headers -} - -function parseError(body, fallback) { - if (!body) return fallback - if (typeof body.detail === 'string') return body.detail - if (Array.isArray(body.detail)) { - return body.detail.map((d) => d.msg || JSON.stringify(d)).join(', ') + if (loading) { + return null } - return fallback + + if (!isAuthenticated) { + return + } + + return ( + + }> + {VIEWS.map((view) => ( + } /> + ))} + } /> + } /> + + + ) } export default function App() { - const [health, setHealth] = useState(null) - const [healthError, setHealthError] = useState(null) - const [setup, setSetup] = useState(null) - const [setupLoading, setSetupLoading] = useState(true) - const [setupError, setSetupError] = useState(null) - const [authMode, setAuthMode] = useState('login') - const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY) || '') - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [displayName, setDisplayName] = useState('') - const [organizationName, setOrganizationName] = useState('') - const [passwordConfirm, setPasswordConfirm] = useState('') - const [authError, setAuthError] = useState(null) - const [authBusy, setAuthBusy] = useState(false) - const [me, setMe] = useState(null) - const [context, setContext] = useState(null) - - useEffect(() => { - fetch('/api/health') - .then((res) => { - if (!res.ok) throw new Error(`HTTP ${res.status}`) - return res.json() - }) - .then(setHealth) - .catch((err) => setHealthError(err.message)) - - async function loadSetupStatus() { - setSetupLoading(true) - setSetupError(null) - try { - const res = await fetch('/api/auth/setup-status') - const body = await res.json().catch(() => ({})) - if (!res.ok) { - throw new Error( - res.status === 404 - ? 'Endpoint /api/auth/setup-status nicht gefunden — Backend neu deployen?' - : parseError(body, `Setup-Status fehlgeschlagen (HTTP ${res.status})`), - ) - } - if (typeof body.registration_open !== 'boolean') { - throw new Error('Ungültige Antwort vom Setup-Status — Backend-Version prüfen') - } - setSetup(body) - if (body.registration_open) setAuthMode('register') - } catch (err) { - setSetup(null) - setSetupError(err.message) - } finally { - setSetupLoading(false) - } - } - - loadSetupStatus() - }, []) - - const loadSession = useCallback(async (activeToken) => { - if (!activeToken) { - setMe(null) - setContext(null) - return - } - const meRes = await fetch('/api/me', { headers: apiHeaders(activeToken) }) - if (!meRes.ok) { - localStorage.removeItem(TOKEN_KEY) - setToken('') - setMe(null) - setContext(null) - throw new Error(meRes.status === 401 ? 'Session abgelaufen' : `HTTP ${meRes.status}`) - } - setMe(await meRes.json()) - const ctxRes = await fetch('/api/me/context', { headers: apiHeaders(activeToken) }) - if (ctxRes.ok) setContext(await ctxRes.json()) - }, []) - - useEffect(() => { - if (!token) return - loadSession(token).catch((err) => setAuthError(err.message)) - }, [token, loadSession]) - - function applyLogin(data) { - localStorage.setItem(TOKEN_KEY, data.token) - setToken(data.token) - setPassword('') - setPasswordConfirm('') - setSetup({ registration_open: false, has_users: true, user_count: 1 }) - setAuthMode('login') - } - - async function handleLogin(event) { - event.preventDefault() - setAuthBusy(true) - setAuthError(null) - try { - const res = await fetch('/api/auth/login', { - method: 'POST', - headers: apiHeaders(), - body: JSON.stringify({ email, password }), - }) - const body = await res.json().catch(() => ({})) - if (!res.ok) throw new Error(parseError(body, `Login fehlgeschlagen (${res.status})`)) - applyLogin(body) - } catch (err) { - setAuthError(err.message) - } finally { - setAuthBusy(false) - } - } - - async function handleRegister(event) { - event.preventDefault() - if (password !== passwordConfirm) { - setAuthError('Passwörter stimmen nicht überein') - return - } - setAuthBusy(true) - setAuthError(null) - try { - const res = await fetch('/api/auth/register', { - method: 'POST', - headers: apiHeaders(), - body: JSON.stringify({ - email, - password, - display_name: displayName, - organization_name: organizationName || undefined, - }), - }) - const body = await res.json().catch(() => ({})) - if (!res.ok) throw new Error(parseError(body, `Registrierung fehlgeschlagen (${res.status})`)) - applyLogin(body) - } catch (err) { - setAuthError(err.message) - } finally { - setAuthBusy(false) - } - } - - async function handleLogout() { - setAuthBusy(true) - setAuthError(null) - try { - if (token) { - await fetch('/api/auth/logout', { - method: 'POST', - headers: apiHeaders(token), - }) - } - } catch { - /* lokal entfernen */ - } finally { - localStorage.removeItem(TOKEN_KEY) - setToken('') - setMe(null) - setContext(null) - setAuthBusy(false) - } - } - - const registrationOpen = setup?.registration_open === true - const registrationClosedBecauseUsersExist = setup?.has_users === true && !registrationOpen - return ( -
-
-

Jinkendo Kairo

-

Operativer Program Director — Sprint 0 / AP0.5

-

Capability Registry & Entitlements

-
- -
-

{token ? 'Session' : registrationOpen ? 'Ersteinrichtung' : 'Anmeldung'}

- - {!token && registrationOpen && ( -

- Noch kein Systemadministrator. Der erste Account wird automatisch{' '} - Portal-Admin und erhält einen Default-Tenant. -

- )} - - {!token && setupLoading && ( -

Prüfe Ersteinrichtung …

- )} - - {!token && setupError && ( -

{setupError}

- )} - - {!token && registrationClosedBecauseUsersExist && ( -

- Es existiert bereits mindestens ein Benutzer ({setup.user_count ?? '≥1'}). - Registrierung ist nur für die Ersteinrichtung vorgesehen — bitte anmelden. - Falls der Admin per KAIRO_BOOTSTRAP_* in der .env angelegt - wurde, nutzen Sie diese Zugangsdaten. -

- )} - - {!token && !setupLoading && !setupError && ( - <> -
- - -
- - {authMode === 'login' && ( -
- - - -
- )} - - {authMode === 'register' && registrationOpen && ( -
- - - - - - -
- )} - - {authMode === 'register' && !registrationOpen && ( -

- Registrierung ist geschlossen. Bitte melden Sie sich an oder wenden Sie sich an - den Administrator. -

- )} - - )} - - {token && me && ( -
-

- Eingeloggt als {me.display_name} ({me.email}) -

-

- {me.portal_role === 'admin' ? 'Portal-Systemadmin' : `Portal-Rolle: ${me.portal_role}`} - {context?.tenant?.name && ` · ${context.tenant.name}`} -

- -
- )} - - {authError &&

{authError}

} -
- -
-

Ersteinrichtung

- {setupLoading &&

Lade …

} - {setupError &&

{setupError}

} - {setup &&
{JSON.stringify(setup, null, 2)}
} - {!setupLoading && !setup && !setupError && ( -

Kein Setup-Status verfügbar.

- )} -
- -
-

API Health

- {healthError &&

Fehler: {healthError}

} - {!healthError && !health &&

Lade …

} - {health &&
{JSON.stringify(health, null, 2)}
} -
- - {token && me && ( - <> -
-

/api/me

-
{JSON.stringify(me, null, 2)}
-
- {context && ( -
-

/api/me/context

-
{JSON.stringify(context, null, 2)}
-
- )} - - )} -
+ + + ) } diff --git a/frontend/src/api/actions.js b/frontend/src/api/actions.js new file mode 100644 index 0000000..fba1f9d --- /dev/null +++ b/frontend/src/api/actions.js @@ -0,0 +1,32 @@ +import { apiFetch } from './client.js' + +export function getAction(id) { + return apiFetch(`/api/actions/${id}`) +} + +export function updateAction(id, payload) { + return apiFetch(`/api/actions/${id}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }) +} + +export function setActionAssignments(id, actorIds) { + return apiFetch(`/api/actions/${id}/assignments`, { + method: 'PUT', + body: JSON.stringify({ actor_ids: actorIds }), + }) +} + +export function deleteAction(id) { + return apiFetch(`/api/actions/${id}`, { method: 'DELETE' }) +} + +export function listMyOpenActions() { + return apiFetch('/api/actions/me/open') +} + +/** Offene Maßnahmen = open, in_progress, blocked (Backend-Filter). */ +export function countOpenActions(actions) { + return actions.filter((a) => ['open', 'in_progress', 'blocked'].includes(a.status)).length +} diff --git a/frontend/src/api/actors.js b/frontend/src/api/actors.js new file mode 100644 index 0000000..723a196 --- /dev/null +++ b/frontend/src/api/actors.js @@ -0,0 +1,16 @@ +/** + * AP0.6: Kein GET /api/actors im Backend. + * Actor-Zuweisung nutzt TenantContext (aktueller Human Actor). + * Siehe Abschlussbericht AP0.6 §9. + */ + +export function actorsFromContext(context) { + if (!context?.actor?.id) return [] + return [ + { + id: context.actor.id, + name: context.display_name || 'Aktueller Actor', + actor_type: context.actor.type || 'human', + }, + ] +} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..09a4e8d --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,44 @@ +const TOKEN_KEY = 'kairo_auth_token' + +export { TOKEN_KEY } + +export function getToken() { + return localStorage.getItem(TOKEN_KEY) || '' +} + +export function setToken(token) { + if (token) localStorage.setItem(TOKEN_KEY, token) + else localStorage.removeItem(TOKEN_KEY) +} + +export function apiHeaders(token = getToken()) { + const headers = { 'Content-Type': 'application/json' } + if (token) headers['X-Auth-Token'] = token + return headers +} + +export function parseError(body, fallback) { + if (!body) return fallback + if (typeof body.detail === 'string') return body.detail + if (Array.isArray(body.detail)) { + return body.detail.map((d) => d.msg || JSON.stringify(d)).join(', ') + } + return fallback +} + +export async function apiFetch(path, options = {}) { + const token = options.token ?? getToken() + const res = await fetch(path, { + ...options, + headers: { ...apiHeaders(token), ...options.headers }, + }) + const body = await res.json().catch(() => null) + if (!res.ok) { + const message = parseError(body, `Anfrage fehlgeschlagen (${res.status})`) + const err = new Error(message) + err.status = res.status + err.body = body + throw err + } + return body +} diff --git a/frontend/src/api/initiatives.js b/frontend/src/api/initiatives.js new file mode 100644 index 0000000..f5e79fb --- /dev/null +++ b/frontend/src/api/initiatives.js @@ -0,0 +1,38 @@ +import { apiFetch } from './client.js' + +export function listInitiatives() { + return apiFetch('/api/initiatives') +} + +export function getInitiative(id) { + return apiFetch(`/api/initiatives/${id}`) +} + +export function createInitiative(payload) { + return apiFetch('/api/initiatives', { + method: 'POST', + body: JSON.stringify(payload), + }) +} + +export function updateInitiative(id, payload) { + return apiFetch(`/api/initiatives/${id}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }) +} + +export function deleteInitiative(id) { + return apiFetch(`/api/initiatives/${id}`, { method: 'DELETE' }) +} + +export function listInitiativeActions(initiativeId) { + return apiFetch(`/api/initiatives/${initiativeId}/actions`) +} + +export function createInitiativeAction(initiativeId, payload) { + return apiFetch(`/api/initiatives/${initiativeId}/actions`, { + method: 'POST', + body: JSON.stringify(payload), + }) +} diff --git a/frontend/src/api/me.js b/frontend/src/api/me.js new file mode 100644 index 0000000..da0f4de --- /dev/null +++ b/frontend/src/api/me.js @@ -0,0 +1,37 @@ +import { apiFetch } from './client.js' + +export function fetchMe() { + return apiFetch('/api/me') +} + +export function fetchContext() { + return apiFetch('/api/me/context') +} + +export function fetchEntitlements() { + return apiFetch('/api/me/entitlements') +} + +export function login(email, password) { + return apiFetch('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }), + token: null, + }) +} + +export function register(payload) { + return apiFetch('/api/auth/register', { + method: 'POST', + body: JSON.stringify(payload), + token: null, + }) +} + +export function logout() { + return apiFetch('/api/auth/logout', { method: 'POST' }) +} + +export function fetchSetupStatus() { + return apiFetch('/api/auth/setup-status', { token: null }) +} diff --git a/frontend/src/components/ActionForm.jsx b/frontend/src/components/ActionForm.jsx new file mode 100644 index 0000000..dd927ad --- /dev/null +++ b/frontend/src/components/ActionForm.jsx @@ -0,0 +1,71 @@ +import { ACTION_STATUSES, PRIORITIES } from '../constants/status.js' +import { ACTION_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js' +import { ActorSelect } from './ActorSelect.jsx' + +export function ActionForm({ + initial = {}, + actors = [], + onSubmit, + onCancel, + busy = false, + submitLabel = 'Speichern', +}) { + async function handleSubmit(e) { + e.preventDefault() + const form = e.target + const selected = actors.filter((a) => form[`actor_${a.id}`]?.checked).map((a) => a.id) + await onSubmit({ + title: form.title.value.trim(), + description: form.description.value, + status: form.status.value, + priority: form.priority.value, + assigned_actor_ids: selected, + }) + } + + const defaultActors = initial.assigned_actor_ids || [] + + return ( +
+ +