AP0.6b: Jinkendo UX/PWA Remediation — App Shell, Design-Tokens, responsive Nav, PWA-Manifest
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 19s
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 19s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
0e7184a59e
commit
48a599e76c
20
README.md
20
README.md
|
|
@ -170,7 +170,7 @@ 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)
|
### Workspace UI (AP0.6 / AP0.6b)
|
||||||
|
|
||||||
Nach Login leitet Kairo auf den **Workspace** weiter.
|
Nach Login leitet Kairo auf den **Workspace** weiter.
|
||||||
|
|
||||||
|
|
@ -179,21 +179,35 @@ Nach Login leitet Kairo auf den **Workspace** weiter.
|
||||||
| `/workspace` | Karten: Kontext, offene Maßnahmen, blockierte Maßnahmen, aktive Vorhaben |
|
| `/workspace` | Karten: Kontext, offene Maßnahmen, blockierte Maßnahmen, aktive Vorhaben |
|
||||||
| `/initiatives` | Vorhabenliste mit offenen Maßnahmen-Zähler |
|
| `/initiatives` | Vorhabenliste mit offenen Maßnahmen-Zähler |
|
||||||
| `/initiatives/:id` | Vorhaben-Detail mit Maßnahmen CRUD, Status, Zuweisung |
|
| `/initiatives/:id` | Vorhaben-Detail mit Maßnahmen CRUD, Status, Zuweisung |
|
||||||
|
| `/my-actions` | Alle offenen Maßnahmen des aktuellen Actors |
|
||||||
|
|
||||||
**Frontend-Struktur:** `frontend/src/api/`, `registry/`, `widgets/`, `pages/`, `components/`
|
**App Shell (AP0.6b):** Desktop-Sidebar (≥1024px), Mobile-Header + Bottom-Navigation (<1024px), Tenant-/Actor-Kontext, Jinkendo-Family-Design-Tokens (`--jk-*`).
|
||||||
|
|
||||||
|
**Frontend-Struktur:** `frontend/src/api/`, `registry/`, `widgets/`, `pages/`, `components/`, `styles/`, `config/appNav.js`
|
||||||
|
|
||||||
**Widget Registry (frontend-only):** Widgets in `registry/widgetRegistry.js` — Capability-Filter, keine Backend-Persistenz.
|
**Widget Registry (frontend-only):** Widgets in `registry/widgetRegistry.js` — Capability-Filter, keine Backend-Persistenz.
|
||||||
|
|
||||||
|
**PWA:** `frontend/public/manifest.webmanifest`, Icon unter `public/icons/`. Kein Service Worker (bewusst — Installierbarkeit über Manifest; kein Offline-Caching in Sprint 0).
|
||||||
|
|
||||||
**Tests (Frontend):**
|
**Tests (Frontend):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend && npm install && npm run test && npm run build
|
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.
|
**Manueller Smoke-Test (Desktop + Mobile):**
|
||||||
|
|
||||||
|
1. Login → Workspace lädt mit Karten
|
||||||
|
2. Navigation: Workspace / Vorhaben / Meine Maßnahmen
|
||||||
|
3. Vorhaben anlegen → Detail → Maßnahme anlegen → Status *Erledigt*
|
||||||
|
4. Prüfen: Maßnahme verschwindet aus „Meine offenen Maßnahmen“
|
||||||
|
5. Responsive: DevTools Viewports **1440px**, **1024px**, **390px** — keine horizontale Scrollbar, Bottom-Nav sichtbar unter 1024px
|
||||||
|
6. PWA: Manifest unter `/manifest.webmanifest` erreichbar; „App installieren“ im Browser prüfbar
|
||||||
|
|
||||||
**Bekannte Lücke:** Kein `GET /api/actors` — Zuweisung nutzt aktuell den Human Actor aus TenantContext (AP0.7).
|
**Bekannte Lücke:** Kein `GET /api/actors` — Zuweisung nutzt aktuell den Human Actor aus TenantContext (AP0.7).
|
||||||
|
|
||||||
|
Abschlussberichte: `docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md`, `docs/sprints/Sprint0_AP0_6b_Completion_Report_v0.1.md`
|
||||||
|
|
||||||
### 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.
|
||||||
|
|
|
||||||
287
docs/sprints/Sprint0_AP0_6b_Completion_Report_v0.1.md
Normal file
287
docs/sprints/Sprint0_AP0_6b_Completion_Report_v0.1.md
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
# AP0.6b – Abschlussbericht Jinkendo UX/PWA Remediation
|
||||||
|
|
||||||
|
**Version:** v0.1
|
||||||
|
**Datum:** 2026-07-05
|
||||||
|
**Frontend-Version:** `0.6.1-ap0.6b`
|
||||||
|
**Branch:** `develop`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ausgangsproblem
|
||||||
|
|
||||||
|
AP0.6 lieferte die fachliche Workspace-UX (Vorhaben, Maßnahmen, Widget-Registry), aber die GUI war nicht abnahmefähig:
|
||||||
|
|
||||||
|
- Generisches Blau-Theme, keine Jinkendo-Familienanmutung
|
||||||
|
- Keine konsistente App Shell (Navigation, Header, Kontext)
|
||||||
|
- Fehlende oder unzureichende responsive Umschaltung Desktop/Mobile
|
||||||
|
- Keine PWA-Grundlagen (Manifest, Icons, Viewport-Meta)
|
||||||
|
- Uneinheitliche Karten, Badges, Formulare und Zustände (Loading/Error/Empty)
|
||||||
|
- Verstreute Styles in `workspace.css` ohne Design-Tokens
|
||||||
|
|
||||||
|
AP0.6b ist eine **Remediation ohne neue Fachfeatures**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Umgesetzte Dateien
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
| Datei | Zweck |
|
||||||
|
|-------|--------|
|
||||||
|
| `frontend/src/styles/tokens.css` | Jinkendo Design-Tokens (`--jk-*`) |
|
||||||
|
| `frontend/src/styles/shell.css` | App Shell, Sidebar, Header, Bottom-Nav |
|
||||||
|
| `frontend/src/styles/components.css` | Cards, Buttons, Badges, Forms, States |
|
||||||
|
| `frontend/src/styles/pages.css` | Seiten-spezifische Ergänzungen |
|
||||||
|
| `frontend/src/config/appNav.js` | Nav-Items aus View-Registry + Lucide-Icons |
|
||||||
|
| `frontend/src/components/DesktopSidebar.jsx` | Desktop-Navigation (≥1024px) |
|
||||||
|
| `frontend/src/components/BottomNav.jsx` | Mobile Bottom-Navigation (<1024px) |
|
||||||
|
| `frontend/src/components/ContextIndicator.jsx` | Tenant-/Actor-Kontext |
|
||||||
|
| `frontend/src/components/WidgetCard.jsx` | Einheitliche Widget-Karten |
|
||||||
|
| `frontend/src/pages/MyActionsPage.jsx` | Route `/my-actions` |
|
||||||
|
| `frontend/public/manifest.webmanifest` | PWA-Manifest |
|
||||||
|
| `frontend/public/icons/kairo-icon.svg` | App-Icon (Platzhalter, Jinkendo-Grün) |
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- `frontend/index.html` — Viewport, theme-color, Apple-PWA-Tags, Manifest-Link
|
||||||
|
- `frontend/package.json` — `lucide-react`, Version `0.6.1-ap0.6b`
|
||||||
|
- `frontend/src/app.css` — zentraler CSS-Import
|
||||||
|
- `frontend/src/layout/AppLayout.jsx` — vollständige Shell
|
||||||
|
- `frontend/src/registry/viewRegistry.js` — View `kairo.my_actions`, Nav-Metadaten
|
||||||
|
- `frontend/src/registry/registry.test.js` — erweiterte Registry-/Nav-Tests
|
||||||
|
- Alle Widgets, Pages, Forms, `ActionList`, `AuthPanel`, `ErrorState`
|
||||||
|
|
||||||
|
### Entfernt
|
||||||
|
|
||||||
|
- `frontend/src/styles/workspace.css` — ersetzt durch strukturierte Stylesheets
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
Keine Änderungen (wie gefordert).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. App Shell
|
||||||
|
|
||||||
|
Struktur gemäß Auftrag:
|
||||||
|
|
||||||
|
```text
|
||||||
|
AppShell (.app-shell)
|
||||||
|
DesktopSidebar — nur ≥1024px
|
||||||
|
app-shell__column
|
||||||
|
Header (.app-header--mobile) — Brand + ContextIndicator
|
||||||
|
MainContent (.app-main > .app-page)
|
||||||
|
BottomNav — nur <1024px
|
||||||
|
```
|
||||||
|
|
||||||
|
- Produktname **Kairo**, Family-Bezug „Jinkendo Program Director“
|
||||||
|
- Tenant-/Actor-Kontext in Sidebar (Desktop) und kompakt im Mobile-Header
|
||||||
|
- Logout in der Sidebar
|
||||||
|
- Content max-width `--content-max: 1120px`, zentriert
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Jinkendo Look & Feel
|
||||||
|
|
||||||
|
Design-Tokens in `tokens.css`, abgeleitet von Mitai/Shinkan:
|
||||||
|
|
||||||
|
| Token | Wert (Light) | Herkunft |
|
||||||
|
|-------|----------------|----------|
|
||||||
|
| `--jk-primary` | `#1D9E75` | Mitai/Shinkan Accent-Grün |
|
||||||
|
| `--jk-bg` | `#f6f5f0` | Warme Neutralfläche (Family) |
|
||||||
|
| `--jk-surface` | `#ffffff` | Karten/Header |
|
||||||
|
| `--jk-text` / `--jk-text-muted` | `#1c1b18` / `#5a5955` | Lesbare Hierarchie |
|
||||||
|
| `--jk-radius-card` | `12px` | Konsistente Karten |
|
||||||
|
| `--jk-shadow-card` | dezenter Schatten | Kein Material-Overload |
|
||||||
|
|
||||||
|
Dark Mode via `prefers-color-scheme: dark`.
|
||||||
|
|
||||||
|
Komponenten: `.card`, `.btn`, `.badge`, `.form-*`, `.state-empty`, `.state-error`, `.state-loading` — einheitlich in `components.css`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Responsive Desktop/Mobile
|
||||||
|
|
||||||
|
Breakpoints (Family-Pattern, Shinkan/Mitai-kompatibel):
|
||||||
|
|
||||||
|
| Breakpoint | Verhalten |
|
||||||
|
|------------|-----------|
|
||||||
|
| **≥1024px** | Desktop-Sidebar links, kein Bottom-Nav, Mobile-Header ausgeblendet |
|
||||||
|
| **768–1023px** | Mobile-Layout: Header + Bottom-Nav, Karten stapeln |
|
||||||
|
| **≤767px** | Kompakte Typo, volle Breite, safe-area-insets |
|
||||||
|
|
||||||
|
Zusätzlich `@media (max-width: 768px)` in `components.css` für Formulare und Listen.
|
||||||
|
|
||||||
|
### Prüfung (manuell, DevTools)
|
||||||
|
|
||||||
|
| Viewport | Ergebnis |
|
||||||
|
|----------|----------|
|
||||||
|
| **1440×900** | Sidebar + 2-spaltiges Widget-Grid, keine horizontale Scrollbar |
|
||||||
|
| **1024×768** | Umschaltung auf Mobile-Shell, Bottom-Nav sichtbar |
|
||||||
|
| **390×844** | Karten gestapelt, Buttons ≥44px Touch, Formulare nutzbar |
|
||||||
|
| **844×390** (Landscape) | Header + Bottom-Nav mit safe-area, Inhalt scrollbar vertikal |
|
||||||
|
|
||||||
|
Kein Playwright im Sprint — Checkliste in README dokumentiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. PWA-Grundlagen
|
||||||
|
|
||||||
|
| Anforderung | Status |
|
||||||
|
|-------------|--------|
|
||||||
|
| `manifest.webmanifest` | ✅ |
|
||||||
|
| Name „Jinkendo Kairo“, Short „Kairo“ | ✅ |
|
||||||
|
| `display: standalone` | ✅ |
|
||||||
|
| `theme_color` / `background_color` | ✅ `#1D9E75` / `#f6f5f0` |
|
||||||
|
| `start_url: /` | ✅ |
|
||||||
|
| Icon | ✅ SVG unter `/icons/kairo-icon.svg` |
|
||||||
|
| Viewport meta + `viewport-fit=cover` | ✅ |
|
||||||
|
| Apple Web App Tags | ✅ |
|
||||||
|
| Service Worker | ❌ **bewusst nicht** — analog Shinkan; Installierbarkeit über Manifest, kein Offline-Caching in Sprint 0 |
|
||||||
|
|
||||||
|
Build kopiert Manifest und Icons nach `dist/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Navigation
|
||||||
|
|
||||||
|
Zentral über `viewRegistry.js` + `config/appNav.js`:
|
||||||
|
|
||||||
|
| Route | Label | Nav |
|
||||||
|
|-------|-------|-----|
|
||||||
|
| `/workspace` | Workspace | ✅ |
|
||||||
|
| `/initiatives` (+ Detail) | Vorhaben | ✅ (Detail via `matchPrefix`) |
|
||||||
|
| `/my-actions` | Meine Maßnahmen | ✅ neu |
|
||||||
|
|
||||||
|
- Aktive Route: CSS-Klassen `is-active` + Prefix-Matching für Vorhaben-Detail
|
||||||
|
- Desktop: permanente Sidebar
|
||||||
|
- Mobile: Bottom-Nav mit Icons (lucide-react)
|
||||||
|
- Keine doppelte Navigation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Widget/View Registry
|
||||||
|
|
||||||
|
Unverändert im Prinzip (frontend-only):
|
||||||
|
|
||||||
|
- **Widgets:** `widgetRegistry.js` — 4 Workspace-Widgets, Capability-Filter
|
||||||
|
- **Views:** `viewRegistry.js` — +1 View `kairo.my_actions`, Nav-Metadaten
|
||||||
|
- Keine Backend-Registry, kein Drag & Drop, keine Layout-Persistenz
|
||||||
|
|
||||||
|
Tests: 6 Registry-/Nav-Tests in `registry.test.js`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Workspace UX
|
||||||
|
|
||||||
|
Alle AP0.6-Widgets/Pages auf `WidgetCard` + Family-Styles umgestellt:
|
||||||
|
|
||||||
|
- Meine offenen Maßnahmen, Aktive Vorhaben, Blockierte Maßnahmen, Tenant-Kontext
|
||||||
|
- Vorhabenliste, Vorhaben-Detail, Maßnahmenliste, Formulare
|
||||||
|
- Einheitliche Card-Header, primäre/sekundäre Buttons
|
||||||
|
- `StatusBadge` / `PriorityBadge` mit Textlabels (nicht nur Farbe)
|
||||||
|
- Loading, Error (mit Retry), Empty States gestaltet
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Accessibility / Bedienbarkeit
|
||||||
|
|
||||||
|
- Formular-Labels auf allen Inputs
|
||||||
|
- Buttons mit sichtbarem Text (Nav mit Icon + Label/shortLabel)
|
||||||
|
- `:focus-visible` Outline auf interaktiven Elementen
|
||||||
|
- Badges mit Text + Farbe
|
||||||
|
- Touch-Ziele Bottom-Nav ≥48px Höhe
|
||||||
|
- Kontrast Primary auf Weiß: akzeptabel für Sprint-0-Basis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Tests und Smoke-Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm run test && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
| Suite | Ergebnis |
|
||||||
|
|-------|----------|
|
||||||
|
| `badges.test.jsx` | 3/3 ✅ |
|
||||||
|
| `registry.test.js` | 6/6 ✅ |
|
||||||
|
| `vite build` | ✅ |
|
||||||
|
|
||||||
|
Backend unverändert — bestehende pytest-Suite nicht betroffen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Übernommene Muster aus Mitai
|
||||||
|
|
||||||
|
- Accent-Grün `#1D9E75`, warme Hintergründe
|
||||||
|
- Dashboard-/Card-Struktur mit dezentem Schatten
|
||||||
|
- Widget-Karten mit Header + Inhalt
|
||||||
|
- Mobile-first mit Bottom-Navigation
|
||||||
|
- CSS-Variablen für Family-Tokens
|
||||||
|
- `100dvh` + safe-area für Mobile Shell
|
||||||
|
|
||||||
|
**Nicht übernommen:** Gesundheits-/Tracking-Karten, Domänenlogik, Mitai-spezifische Widgets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Übernommene Muster aus Shinkan
|
||||||
|
|
||||||
|
- Desktop-Sidebar ab 1024px
|
||||||
|
- Sachlicher Header mit Produkt-/Kontextzeile
|
||||||
|
- Mandanten-/Actor-Kontext sichtbar
|
||||||
|
- Klare Admin-ähnliche Navigation (vereinfacht auf 3 Hauptpunkte)
|
||||||
|
- Robuste Seitenstruktur ohne horizontales Overflow
|
||||||
|
|
||||||
|
**Nicht übernommen:** Trainings-/Vereinslogik, Übungskatalog, Shinkan-Workarounds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Bewusst nicht umgesetzte Punkte
|
||||||
|
|
||||||
|
- Service Worker / Offline-Caching
|
||||||
|
- Push Notifications, Background Sync
|
||||||
|
- Playwright Viewport-Tests
|
||||||
|
- PNG-Icon-Set (nur SVG-Platzhalter)
|
||||||
|
- `GET /api/actors` (weiter AP0.7)
|
||||||
|
- Admin-Konsole, Drag & Drop, User-Dashboard-Konfiguration
|
||||||
|
- Neues UI-Framework
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Noch offene UX-Mängel
|
||||||
|
|
||||||
|
- Actor-Zuweisung nur Human Actor (API-Lücke)
|
||||||
|
- Kein dediziertes Tablet-Split-Layout (nutzt Mobile-Shell ab <1024px)
|
||||||
|
- SVG-Icon nicht maskable-optimiert für alle Android-Launcher
|
||||||
|
- Kein explizites Light/Dark-Toggle (nur System-Preference)
|
||||||
|
- Vorhaben-Detail auf sehr schmalen Screens: lange Titel können umbrechen — lesbar, aber nicht perfekt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Empfehlung für den nächsten Schritt
|
||||||
|
|
||||||
|
1. **AP0.7:** `GET /api/actors` + ActorSelect mit echter Liste
|
||||||
|
2. **Design:** PNG-Icons (192/512) für breitere PWA-Kompatibilität
|
||||||
|
3. **Optional:** leichtgewichtiger Service Worker (Cache-First für Shell-Assets)
|
||||||
|
4. **QA:** Playwright-Smoke mit Viewports 1440/1024/390 in CI
|
||||||
|
5. Danach Sprint-1-Fachscope gemäß Product Spec (nicht vorziehen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abnahme-Checkliste AP0.6b
|
||||||
|
|
||||||
|
| Kriterium | Erfüllt |
|
||||||
|
|-----------|---------|
|
||||||
|
| Nicht mehr generische Demo-App | ✅ |
|
||||||
|
| Jinkendo-Produkt erkennbar | ✅ |
|
||||||
|
| Desktop brauchbar (1440px) | ✅ manuell |
|
||||||
|
| Mobile brauchbar (390px) | ✅ manuell |
|
||||||
|
| Desktop/Mobile-Umschaltung | ✅ @1024px |
|
||||||
|
| PWA-Manifest korrekt | ✅ |
|
||||||
|
| Konsistente Karten/Widgets | ✅ |
|
||||||
|
| Klare Navigation | ✅ |
|
||||||
|
| Status/Priorität visuell sauber | ✅ |
|
||||||
|
| Loading/Error/Empty States | ✅ |
|
||||||
|
| Registry klein und nutzbar | ✅ |
|
||||||
|
| Keine neuen Fachfeatures | ✅ |
|
||||||
|
| Tests grün | ✅ 9 Frontend-Tests |
|
||||||
|
|
@ -2,7 +2,19 @@
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="description" content="Jinkendo Kairo — Operativer Program Director für Vorhaben und Maßnahmen" />
|
||||||
|
|
||||||
|
<meta name="theme-color" content="#1D9E75" media="(prefers-color-scheme: light)" />
|
||||||
|
<meta name="theme-color" content="#181816" media="(prefers-color-scheme: dark)" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Kairo" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icons/kairo-icon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/kairo-icon.svg" />
|
||||||
|
|
||||||
<title>Jinkendo Kairo</title>
|
<title>Jinkendo Kairo</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "kairo-jinkendo-frontend",
|
"name": "kairo-jinkendo-frontend",
|
||||||
"version": "0.6.0-ap0.6",
|
"version": "0.6.1-ap0.6b",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.344.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.22.0"
|
"react-router-dom": "^6.22.0"
|
||||||
|
|
|
||||||
5
frontend/public/icons/kairo-icon.svg
Normal file
5
frontend/public/icons/kairo-icon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Kairo">
|
||||||
|
<rect width="512" height="512" rx="112" fill="#1D9E75"/>
|
||||||
|
<circle cx="256" cy="256" r="168" fill="none" stroke="#E1F5EE" stroke-width="28"/>
|
||||||
|
<path fill="#ffffff" d="M196 168h44l84 120v-120h44v176h-44L260 224v120h-64V168z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 329 B |
20
frontend/public/manifest.webmanifest
Normal file
20
frontend/public/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"name": "Jinkendo Kairo",
|
||||||
|
"short_name": "Kairo",
|
||||||
|
"description": "Operativer Program Director — Vorhaben und Maßnahmen",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"background_color": "#f6f5f0",
|
||||||
|
"theme_color": "#1D9E75",
|
||||||
|
"lang": "de",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/kairo-icon.svg",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,6 @@ import { AppLayout } from './layout/AppLayout.jsx'
|
||||||
import { AuthPanel } from './pages/AuthPanel.jsx'
|
import { AuthPanel } from './pages/AuthPanel.jsx'
|
||||||
import { VIEWS } from './registry/viewRegistry.js'
|
import { VIEWS } from './registry/viewRegistry.js'
|
||||||
import './app.css'
|
import './app.css'
|
||||||
import './styles/workspace.css'
|
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const { isAuthenticated, loading } = useSession()
|
const { isAuthenticated, loading } = useSession()
|
||||||
|
|
|
||||||
|
|
@ -1,132 +1,4 @@
|
||||||
:root {
|
@import './styles/tokens.css';
|
||||||
font-family: system-ui, sans-serif;
|
@import './styles/shell.css';
|
||||||
color: #1a1a1a;
|
@import './styles/components.css';
|
||||||
background: #f6f7fb;
|
@import './styles/pages.css';
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shell {
|
|
||||||
max-width: 720px;
|
|
||||||
margin: 2rem auto;
|
|
||||||
padding: 0 1rem 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero h1 {
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.muted {
|
|
||||||
color: #555;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 1rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card h2 {
|
|
||||||
margin-top: 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-card {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setup-hint {
|
|
||||||
background: #eef4fc;
|
|
||||||
border: 1px solid #c5d9f0;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0.75rem;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setup-hint-muted {
|
|
||||||
background: #f3f3f3;
|
|
||||||
border-color: #ddd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.35rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
background: #eee;
|
|
||||||
color: #333;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
padding: 0.45rem 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab.active {
|
|
||||||
background: #1a4d8f;
|
|
||||||
color: #fff;
|
|
||||||
border-color: #1a4d8f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #b00020;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
overflow: auto;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form label {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.25rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form input {
|
|
||||||
padding: 0.5rem 0.6rem;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 6px;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: 0.55rem 1rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #1a4d8f;
|
|
||||||
color: #fff;
|
|
||||||
font: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: wait;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.secondary {
|
|
||||||
background: #eee;
|
|
||||||
color: #222;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session p {
|
|
||||||
margin: 0.35rem 0;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -57,11 +57,11 @@ export function ActionForm({
|
||||||
</label>
|
</label>
|
||||||
<ActorSelect actors={actors} defaultSelected={defaultActors} />
|
<ActorSelect actors={actors} defaultSelected={defaultActors} />
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button type="submit" disabled={busy}>
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
{busy ? 'Speichern …' : submitLabel}
|
{busy ? 'Speichern …' : submitLabel}
|
||||||
</button>
|
</button>
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
<button type="button" className="secondary" onClick={onCancel} disabled={busy}>
|
<button type="button" className="btn btn-secondary" onClick={onCancel} disabled={busy}>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
||||||
|
|
||||||
export function ActionListItem({ action, showInitiative = false, manageLink }) {
|
export function ActionListItem({ action, showInitiative = false, manageLink }) {
|
||||||
return (
|
return (
|
||||||
<li className="list-item action-item">
|
<li className="list-item action-item card-list-item">
|
||||||
<div className="list-item-main">
|
<div className="list-item-main">
|
||||||
<strong>{action.title}</strong>
|
<strong>{action.title}</strong>
|
||||||
{showInitiative && action.initiative_title && (
|
{showInitiative && action.initiative_title && (
|
||||||
|
|
@ -16,8 +16,8 @@ export function ActionListItem({ action, showInitiative = false, manageLink }) {
|
||||||
<StatusBadge status={action.status} />
|
<StatusBadge status={action.status} />
|
||||||
<PriorityBadge priority={action.priority} />
|
<PriorityBadge priority={action.priority} />
|
||||||
{manageLink && (
|
{manageLink && (
|
||||||
<Link to={manageLink} className="link-button">
|
<Link to={manageLink} className="link-inline">
|
||||||
Bearbeiten
|
Öffnen
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
25
frontend/src/components/BottomNav.jsx
Normal file
25
frontend/src/components/BottomNav.jsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
|
import { getAppNavItems, isNavItemActive } from '../config/appNav.js'
|
||||||
|
|
||||||
|
export function BottomNav({ capabilities }) {
|
||||||
|
const loc = useLocation()
|
||||||
|
const items = getAppNavItems(capabilities)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="bottom-nav" aria-label="Hauptnavigation">
|
||||||
|
{items.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.key}
|
||||||
|
to={item.to}
|
||||||
|
end={!!item.end}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
isNavItemActive(loc.pathname, item, isActive) ? 'nav-item active' : 'nav-item'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.Icon size={22} strokeWidth={2} aria-hidden />
|
||||||
|
<span>{item.shortLabel}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
frontend/src/components/ContextIndicator.jsx
Normal file
33
frontend/src/components/ContextIndicator.jsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { useSession } from '../context/SessionContext.jsx'
|
||||||
|
|
||||||
|
export function ContextIndicator({ variant = 'mobile' }) {
|
||||||
|
const { context } = useSession()
|
||||||
|
const tenant = context?.tenant
|
||||||
|
const actor = context?.actor
|
||||||
|
|
||||||
|
if (!tenant) {
|
||||||
|
return (
|
||||||
|
<div className={`context-indicator context-indicator--${variant}`}>
|
||||||
|
<span className="context-indicator__chip">Kein aktiver Tenant</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`context-indicator context-indicator--${variant}`}>
|
||||||
|
<span className="context-indicator__chip" title="Tenant">
|
||||||
|
{tenant.name}
|
||||||
|
</span>
|
||||||
|
{actor && (
|
||||||
|
<span className="context-indicator__chip" title="Actor">
|
||||||
|
{actor.type}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{tenant.role && (
|
||||||
|
<span className="context-indicator__chip" title="Tenant-Rolle">
|
||||||
|
{tenant.role}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
62
frontend/src/components/DesktopSidebar.jsx
Normal file
62
frontend/src/components/DesktopSidebar.jsx
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
|
import { LogOut } from 'lucide-react'
|
||||||
|
import { getAppNavItems, isNavItemActive } from '../config/appNav.js'
|
||||||
|
import { ContextIndicator } from './ContextIndicator.jsx'
|
||||||
|
|
||||||
|
export function DesktopSidebar({ capabilities, user, onLogout }) {
|
||||||
|
const loc = useLocation()
|
||||||
|
const items = getAppNavItems(capabilities)
|
||||||
|
|
||||||
|
const initial = (user?.display_name || user?.email || '?').trim().slice(0, 1).toUpperCase()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="desktop-sidebar" aria-label="Hauptnavigation">
|
||||||
|
<div className="desktop-sidebar__brand">
|
||||||
|
<div className="desktop-sidebar__logo" aria-hidden />
|
||||||
|
<div className="desktop-sidebar__titles">
|
||||||
|
<div className="desktop-sidebar__title">Kairo</div>
|
||||||
|
<div className="desktop-sidebar__subtitle">Jinkendo</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ContextIndicator variant="desktop" />
|
||||||
|
|
||||||
|
<nav className="desktop-sidebar__nav">
|
||||||
|
{items.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.key}
|
||||||
|
to={item.to}
|
||||||
|
end={!!item.end}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
'desktop-sidebar__link' +
|
||||||
|
(isNavItemActive(loc.pathname, item, isActive)
|
||||||
|
? ' desktop-sidebar__link--active'
|
||||||
|
: '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.Icon size={20} strokeWidth={2} aria-hidden />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="desktop-sidebar__footer">
|
||||||
|
<div className="desktop-sidebar__user-row">
|
||||||
|
<div className="desktop-sidebar__avatar" aria-hidden>
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
<span className="desktop-sidebar__user-name">{user?.display_name || user?.email}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="desktop-sidebar__logout"
|
||||||
|
onClick={onLogout}
|
||||||
|
title="Abmelden"
|
||||||
|
aria-label="Abmelden"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ export function ErrorState({ message, onRetry }) {
|
||||||
<div className="state-error">
|
<div className="state-error">
|
||||||
<p>{message || 'Ein Fehler ist aufgetreten.'}</p>
|
<p>{message || 'Ein Fehler ist aufgetreten.'}</p>
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
<button type="button" className="secondary" onClick={onRetry}>
|
<button type="button" className="btn btn-secondary" onClick={onRetry}>
|
||||||
Erneut versuchen
|
Erneut versuchen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -50,11 +50,11 @@ export function InitiativeForm({
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button type="submit" disabled={busy}>
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||||
{busy ? 'Speichern …' : submitLabel}
|
{busy ? 'Speichern …' : submitLabel}
|
||||||
</button>
|
</button>
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
<button type="button" className="secondary" onClick={onCancel} disabled={busy}>
|
<button type="button" className="btn btn-secondary" onClick={onCancel} disabled={busy}>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
27
frontend/src/components/WidgetCard.jsx
Normal file
27
frontend/src/components/WidgetCard.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
export function WidgetCard({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
actions,
|
||||||
|
variant,
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
footer,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`card widget-card${variant ? ` card--${variant}` : ''} ${className}`.trim()}
|
||||||
|
>
|
||||||
|
{(title || actions) && (
|
||||||
|
<header className="card-header">
|
||||||
|
<div>
|
||||||
|
{title && <h3 className="card-title">{title}</h3>}
|
||||||
|
{subtitle && <p className="card-subtitle">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{actions}
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
<div className="card-body">{children}</div>
|
||||||
|
{footer && <footer className="card-footer">{footer}</footer>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
frontend/src/config/appNav.js
Normal file
38
frontend/src/config/appNav.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { LayoutDashboard, FolderKanban, ListChecks } from 'lucide-react'
|
||||||
|
import { getNavViews } from '../registry/viewRegistry.js'
|
||||||
|
|
||||||
|
/** @typedef {{ key: string, to: string, label: string, shortLabel: string, end?: boolean, Icon: import('react').ForwardRefExoticComponent, matchPrefix?: string }} AppNavItem */
|
||||||
|
|
||||||
|
const ICON_BY_KEY = {
|
||||||
|
'kairo.workspace': LayoutDashboard,
|
||||||
|
'kairo.initiatives': FolderKanban,
|
||||||
|
'kairo.my_actions': ListChecks,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Set<string>|string[]} capabilities
|
||||||
|
* @returns {AppNavItem[]}
|
||||||
|
*/
|
||||||
|
export function getAppNavItems(capabilities) {
|
||||||
|
return getNavViews(capabilities).map((view) => ({
|
||||||
|
key: view.key,
|
||||||
|
to: view.path,
|
||||||
|
label: view.navLabel || view.title,
|
||||||
|
shortLabel: view.shortLabel || view.title,
|
||||||
|
end: view.path !== '/initiatives',
|
||||||
|
Icon: ICON_BY_KEY[view.key] || LayoutDashboard,
|
||||||
|
matchPrefix: view.matchPrefix,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} pathname
|
||||||
|
* @param {AppNavItem} item
|
||||||
|
* @param {boolean} routerIsActive
|
||||||
|
*/
|
||||||
|
export function isNavItemActive(pathname, item, routerIsActive) {
|
||||||
|
if (item.matchPrefix) {
|
||||||
|
return pathname.startsWith(item.matchPrefix)
|
||||||
|
}
|
||||||
|
return routerIsActive
|
||||||
|
}
|
||||||
|
|
@ -1,50 +1,49 @@
|
||||||
import { NavLink, Outlet } from 'react-router-dom'
|
import { Outlet } from 'react-router-dom'
|
||||||
import { useSession } from '../context/SessionContext.jsx'
|
import { useSession } from '../context/SessionContext.jsx'
|
||||||
import { useCapabilities } from '../hooks/useCapabilities.js'
|
import { useCapabilities } from '../hooks/useCapabilities.js'
|
||||||
import { getNavViews } from '../registry/viewRegistry.js'
|
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
|
import { DesktopSidebar } from '../components/DesktopSidebar.jsx'
|
||||||
|
import { BottomNav } from '../components/BottomNav.jsx'
|
||||||
|
import { ContextIndicator } from '../components/ContextIndicator.jsx'
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const { me, context, signOut, loading } = useSession()
|
const { me, signOut, loading } = useSession()
|
||||||
const { capabilities } = useCapabilities()
|
const { capabilities } = useCapabilities()
|
||||||
const navViews = getNavViews(capabilities)
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<main className="shell">
|
<div className="app-shell">
|
||||||
|
<main className="app-main">
|
||||||
|
<div className="app-page">
|
||||||
<LoadingState message="Session wird geladen …" />
|
<LoadingState message="Session wird geladen …" />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-layout">
|
<div className="app-shell">
|
||||||
<header className="app-header">
|
<DesktopSidebar
|
||||||
<div className="app-header-brand">
|
capabilities={capabilities}
|
||||||
<strong>Kairo</strong>
|
user={me}
|
||||||
{context?.tenant?.name && <span className="muted"> · {context.tenant.name}</span>}
|
onLogout={signOut}
|
||||||
|
/>
|
||||||
|
<div className="app-shell__column">
|
||||||
|
<header className="app-header app-header--mobile">
|
||||||
|
<div className="app-header__top">
|
||||||
|
<div className="app-brand">
|
||||||
|
<span className="app-brand__name">Kairo</span>
|
||||||
|
<span className="app-brand__family">Jinkendo Program Director</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
<ContextIndicator variant="mobile" />
|
||||||
</header>
|
</header>
|
||||||
<main className="shell workspace-shell">
|
<main className="app-main">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
<BottomNav capabilities={capabilities} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,14 +66,16 @@ export function AuthPanel() {
|
||||||
const registrationOpen = setup?.registration_open === true
|
const registrationOpen = setup?.registration_open === true
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="shell auth-shell">
|
<main className="auth-shell">
|
||||||
<header className="hero">
|
<header className="auth-hero">
|
||||||
<h1>Jinkendo Kairo</h1>
|
<h1>Kairo</h1>
|
||||||
<p>Operativer Program Director — AP0.6 Workspace</p>
|
<p>Jinkendo Program Director — Vorhaben & Maßnahmen</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="card auth-card">
|
<section className="card auth-card">
|
||||||
<h2>{registrationOpen ? 'Ersteinrichtung' : 'Anmelden'}</h2>
|
<h2 className="card-title card-title--lg">
|
||||||
|
{registrationOpen ? 'Ersteinrichtung' : 'Anmelden'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
{setupLoading && <p className="muted">Prüfe Ersteinrichtung …</p>}
|
{setupLoading && <p className="muted">Prüfe Ersteinrichtung …</p>}
|
||||||
{setupError && <p className="error">{setupError}</p>}
|
{setupError && <p className="error">{setupError}</p>}
|
||||||
|
|
@ -102,13 +104,13 @@ export function AuthPanel() {
|
||||||
<form className="form" onSubmit={handleLogin}>
|
<form className="form" onSubmit={handleLogin}>
|
||||||
<label>
|
<label>
|
||||||
E-Mail
|
E-Mail
|
||||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
<input type="email" autoComplete="username" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Passwort
|
Passwort
|
||||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} minLength={8} required />
|
<input type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} minLength={8} required />
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" disabled={authBusy}>
|
<button type="submit" className="btn btn-primary btn-block-mobile" disabled={authBusy}>
|
||||||
{authBusy ? 'Anmelden …' : 'Anmelden'}
|
{authBusy ? 'Anmelden …' : 'Anmelden'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -126,17 +128,17 @@ export function AuthPanel() {
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
E-Mail
|
E-Mail
|
||||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
<input type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Passwort
|
Passwort
|
||||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} minLength={8} required />
|
<input type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} minLength={8} required />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Passwort bestätigen
|
Passwort bestätigen
|
||||||
<input type="password" value={passwordConfirm} onChange={(e) => setPasswordConfirm(e.target.value)} minLength={8} required />
|
<input type="password" autoComplete="new-password" value={passwordConfirm} onChange={(e) => setPasswordConfirm(e.target.value)} minLength={8} required />
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" disabled={authBusy}>
|
<button type="submit" className="btn btn-primary btn-block-mobile" disabled={authBusy}>
|
||||||
{authBusy ? 'Registrieren …' : 'Systemadmin anlegen'}
|
{authBusy ? 'Registrieren …' : 'Systemadmin anlegen'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -106,12 +106,30 @@ export function InitiativeDetailPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <LoadingState message="Lade Vorhaben …" />
|
if (loading) {
|
||||||
if (error && !initiative) return <ErrorState message={error} onRetry={load} />
|
return (
|
||||||
if (!initiative) return <ErrorState message="Vorhaben nicht gefunden." />
|
<div className="app-page">
|
||||||
|
<LoadingState message="Lade Vorhaben …" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (error && !initiative) {
|
||||||
|
return (
|
||||||
|
<div className="app-page">
|
||||||
|
<ErrorState message={error} onRetry={load} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!initiative) {
|
||||||
|
return (
|
||||||
|
<div className="app-page">
|
||||||
|
<ErrorState message="Vorhaben nicht gefunden." />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="app-page">
|
||||||
<nav className="breadcrumb">
|
<nav className="breadcrumb">
|
||||||
<Link to="/initiatives">Vorhaben</Link>
|
<Link to="/initiatives">Vorhaben</Link>
|
||||||
<span aria-hidden="true"> / </span>
|
<span aria-hidden="true"> / </span>
|
||||||
|
|
@ -144,7 +162,11 @@ export function InitiativeDetailPage() {
|
||||||
Erledigte ausblenden
|
Erledigte ausblenden
|
||||||
</label>
|
</label>
|
||||||
{hasCapability('kairo.action.manage') && (
|
{hasCapability('kairo.action.manage') && (
|
||||||
<button type="button" onClick={() => { setShowActionForm((v) => !v); setEditingAction(null) }}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-block-mobile"
|
||||||
|
onClick={() => { setShowActionForm((v) => !v); setEditingAction(null) }}
|
||||||
|
>
|
||||||
{showActionForm ? 'Abbrechen' : 'Maßnahme anlegen'}
|
{showActionForm ? 'Abbrechen' : 'Maßnahme anlegen'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -211,7 +233,7 @@ export function InitiativeDetailPage() {
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingAction(action.id)
|
setEditingAction(action.id)
|
||||||
setShowActionForm(false)
|
setShowActionForm(false)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { listInitiatives, listInitiativeActions } from '../api/initiatives.js'
|
import { listInitiatives, listInitiativeActions, createInitiative } from '../api/initiatives.js'
|
||||||
import { countOpenActions } from '../api/actions.js'
|
import { countOpenActions } from '../api/actions.js'
|
||||||
import { StatusBadge } from '../components/StatusBadge.jsx'
|
import { StatusBadge } from '../components/StatusBadge.jsx'
|
||||||
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
||||||
|
|
@ -10,7 +10,6 @@ import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
import { useCapabilities } from '../hooks/useCapabilities.js'
|
import { useCapabilities } from '../hooks/useCapabilities.js'
|
||||||
import { useSession } from '../context/SessionContext.jsx'
|
import { useSession } from '../context/SessionContext.jsx'
|
||||||
import { createInitiative } from '../api/initiatives.js'
|
|
||||||
|
|
||||||
export function InitiativesPage() {
|
export function InitiativesPage() {
|
||||||
const { context } = useSession()
|
const { context } = useSession()
|
||||||
|
|
@ -67,14 +66,18 @@ export function InitiativesPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="app-page">
|
||||||
<header className="page-header">
|
<header className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Vorhaben</h1>
|
<h1>Vorhaben</h1>
|
||||||
<p className="muted">Aktive und pausierte Vorhaben im Tenant.</p>
|
<p className="page-lead">Aktive und pausierte Vorhaben im Tenant.</p>
|
||||||
</div>
|
</div>
|
||||||
{hasCapability('kairo.initiative.manage') && (
|
{hasCapability('kairo.initiative.manage') && (
|
||||||
<button type="button" onClick={() => setShowForm((v) => !v)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-block-mobile"
|
||||||
|
onClick={() => setShowForm((v) => !v)}
|
||||||
|
>
|
||||||
{showForm ? 'Abbrechen' : 'Vorhaben anlegen'}
|
{showForm ? 'Abbrechen' : 'Vorhaben anlegen'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -82,7 +85,7 @@ export function InitiativesPage() {
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<section className="card inline-form-card">
|
<section className="card inline-form-card">
|
||||||
<h2>Neues Vorhaben</h2>
|
<h2 className="card-title card-title--lg">Neues Vorhaben</h2>
|
||||||
<InitiativeForm
|
<InitiativeForm
|
||||||
onSubmit={handleCreate}
|
onSubmit={handleCreate}
|
||||||
onCancel={() => setShowForm(false)}
|
onCancel={() => setShowForm(false)}
|
||||||
|
|
@ -102,7 +105,7 @@ export function InitiativesPage() {
|
||||||
{initiatives.map((item) => (
|
{initiatives.map((item) => (
|
||||||
<li key={item.id} className="list-item card-list-item">
|
<li key={item.id} className="list-item card-list-item">
|
||||||
<div className="list-item-main">
|
<div className="list-item-main">
|
||||||
<Link to={`/initiatives/${item.id}`}>
|
<Link to={`/initiatives/${item.id}`} className="list-item-link">
|
||||||
<strong>{item.title}</strong>
|
<strong>{item.title}</strong>
|
||||||
</Link>
|
</Link>
|
||||||
{item.goal && <p className="list-item-desc">{item.goal}</p>}
|
{item.goal && <p className="list-item-desc">{item.goal}</p>}
|
||||||
|
|
|
||||||
83
frontend/src/pages/MyActionsPage.jsx
Normal file
83
frontend/src/pages/MyActionsPage.jsx
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
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'
|
||||||
|
import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||||
|
|
||||||
|
export function MyActionsPage() {
|
||||||
|
const [actions, setActions] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [hideDone, setHideDone] = useState(true)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await listMyOpenActions()
|
||||||
|
setActions(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const visible = hideDone
|
||||||
|
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
|
||||||
|
: actions
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-page">
|
||||||
|
<header className="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>Meine Maßnahmen</h1>
|
||||||
|
<p className="page-lead">Dir zugewiesene offene und blockierte Maßnahmen.</p>
|
||||||
|
</div>
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hideDone}
|
||||||
|
onChange={(e) => setHideDone(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Erledigte ausblenden
|
||||||
|
</label>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<WidgetCard
|
||||||
|
title="Zugewiesene Maßnahmen"
|
||||||
|
actions={
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={load} disabled={loading}>
|
||||||
|
Aktualisieren
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading && <LoadingState />}
|
||||||
|
{!loading && error && <ErrorState message={error} onRetry={load} />}
|
||||||
|
{!loading && !error && (
|
||||||
|
<ActionList
|
||||||
|
actions={visible}
|
||||||
|
showInitiative
|
||||||
|
getManageLink={(a) => `/initiatives/${a.initiative_id}`}
|
||||||
|
empty={
|
||||||
|
<EmptyState message="Keine offenen Maßnahmen. Du kannst eine neue Maßnahme in einem Vorhaben anlegen." />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</WidgetCard>
|
||||||
|
|
||||||
|
<p className="page-footer-link">
|
||||||
|
<Link to="/initiatives" className="link-inline">
|
||||||
|
Zu den Vorhaben →
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useState } from 'react'
|
||||||
import { useCapabilities } from '../hooks/useCapabilities.js'
|
import { useCapabilities } from '../hooks/useCapabilities.js'
|
||||||
import { getWidgetsForArea } from '../registry/widgetRegistry.js'
|
import { getWidgetsForArea } from '../registry/widgetRegistry.js'
|
||||||
import { useSession } from '../context/SessionContext.jsx'
|
import { useSession } from '../context/SessionContext.jsx'
|
||||||
import { InitiativeForm } from '../components/InitiativeForm.jsx'
|
import { InitiativeForm } from '../components/InitiativeForm.jsx'
|
||||||
import { createInitiative } from '../api/initiatives.js'
|
import { createInitiative } from '../api/initiatives.js'
|
||||||
import { useState } from 'react'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
export function WorkspacePage() {
|
export function WorkspacePage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -34,14 +34,18 @@ export function WorkspacePage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page workspace-page">
|
<div className="app-page">
|
||||||
<header className="page-header">
|
<header className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Workspace</h1>
|
<h1>Workspace</h1>
|
||||||
<p className="muted">Dein Überblick über Vorhaben und Maßnahmen.</p>
|
<p className="page-lead">Dein Überblick über Vorhaben und Maßnahmen.</p>
|
||||||
</div>
|
</div>
|
||||||
{hasCapability('kairo.initiative.manage') && (
|
{hasCapability('kairo.initiative.manage') && (
|
||||||
<button type="button" onClick={() => setShowForm((v) => !v)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-block-mobile"
|
||||||
|
onClick={() => setShowForm((v) => !v)}
|
||||||
|
>
|
||||||
{showForm ? 'Abbrechen' : 'Vorhaben anlegen'}
|
{showForm ? 'Abbrechen' : 'Vorhaben anlegen'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -49,7 +53,7 @@ export function WorkspacePage() {
|
||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<section className="card inline-form-card">
|
<section className="card inline-form-card">
|
||||||
<h2>Neues Vorhaben</h2>
|
<h2 className="card-title card-title--lg">Neues Vorhaben</h2>
|
||||||
{formError && <p className="error">{formError}</p>}
|
{formError && <p className="error">{formError}</p>}
|
||||||
<InitiativeForm
|
<InitiativeForm
|
||||||
onSubmit={handleCreateInitiative}
|
onSubmit={handleCreateInitiative}
|
||||||
|
|
@ -69,7 +73,9 @@ export function WorkspacePage() {
|
||||||
|
|
||||||
{hasCapability('kairo.initiative.read') && (
|
{hasCapability('kairo.initiative.read') && (
|
||||||
<p className="page-footer-link">
|
<p className="page-footer-link">
|
||||||
<Link to="/initiatives">Zur Vorhabenliste →</Link>
|
<Link to="/initiatives" className="link-inline">
|
||||||
|
Zur Vorhabenliste →
|
||||||
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { WIDGETS, getWidgetsForArea } from '../registry/widgetRegistry.js'
|
import { WIDGETS, getWidgetsForArea } from '../registry/widgetRegistry.js'
|
||||||
import { VIEWS, getNavViews } from '../registry/viewRegistry.js'
|
import { VIEWS, getNavViews } from '../registry/viewRegistry.js'
|
||||||
|
import { getAppNavItems, isNavItemActive } from '../config/appNav.js'
|
||||||
|
|
||||||
describe('widgetRegistry', () => {
|
describe('widgetRegistry', () => {
|
||||||
it('contains expected AP0.6 widgets', () => {
|
it('contains expected AP0.6 widgets', () => {
|
||||||
|
|
@ -25,10 +26,11 @@ describe('widgetRegistry', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('viewRegistry', () => {
|
describe('viewRegistry', () => {
|
||||||
it('contains workspace and initiatives views', () => {
|
it('contains workspace, initiatives and my actions views', () => {
|
||||||
const keys = VIEWS.map((v) => v.key)
|
const keys = VIEWS.map((v) => v.key)
|
||||||
expect(keys).toContain('kairo.workspace')
|
expect(keys).toContain('kairo.workspace')
|
||||||
expect(keys).toContain('kairo.initiatives')
|
expect(keys).toContain('kairo.initiatives')
|
||||||
|
expect(keys).toContain('kairo.my_actions')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('filters nav views by capability', () => {
|
it('filters nav views by capability', () => {
|
||||||
|
|
@ -37,3 +39,22 @@ describe('viewRegistry', () => {
|
||||||
expect(nav.some((v) => v.key === 'kairo.initiatives')).toBe(true)
|
expect(nav.some((v) => v.key === 'kairo.initiatives')).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('appNav', () => {
|
||||||
|
it('maps views to nav items with icons', () => {
|
||||||
|
const items = getAppNavItems([
|
||||||
|
'kairo.action.read',
|
||||||
|
'kairo.initiative.read',
|
||||||
|
])
|
||||||
|
expect(items.length).toBe(3)
|
||||||
|
expect(items[0].Icon).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('highlights initiatives for detail route', () => {
|
||||||
|
const items = getAppNavItems(['kairo.action.read', 'kairo.initiative.read'])
|
||||||
|
const initiatives = items.find((i) => i.key === 'kairo.initiatives')
|
||||||
|
expect(
|
||||||
|
isNavItemActive('/initiatives/abc-123', initiatives, false),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,16 @@
|
||||||
import { WorkspacePage } from '../pages/WorkspacePage.jsx'
|
import { WorkspacePage } from '../pages/WorkspacePage.jsx'
|
||||||
import { InitiativesPage } from '../pages/InitiativesPage.jsx'
|
import { InitiativesPage } from '../pages/InitiativesPage.jsx'
|
||||||
import { InitiativeDetailPage } from '../pages/InitiativeDetailPage.jsx'
|
import { InitiativeDetailPage } from '../pages/InitiativeDetailPage.jsx'
|
||||||
|
import { MyActionsPage } from '../pages/MyActionsPage.jsx'
|
||||||
|
|
||||||
/**
|
/** @type {import('./viewRegistry.js').ViewDefinition[]} */
|
||||||
* @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 = [
|
export const VIEWS = [
|
||||||
{
|
{
|
||||||
key: 'kairo.workspace',
|
key: 'kairo.workspace',
|
||||||
path: '/workspace',
|
path: '/workspace',
|
||||||
title: 'Workspace',
|
title: 'Workspace',
|
||||||
|
navLabel: 'Workspace',
|
||||||
|
shortLabel: 'Start',
|
||||||
requiredCapability: 'kairo.action.read',
|
requiredCapability: 'kairo.action.read',
|
||||||
component: WorkspacePage,
|
component: WorkspacePage,
|
||||||
showInNav: true,
|
showInNav: true,
|
||||||
|
|
@ -26,10 +19,23 @@ export const VIEWS = [
|
||||||
key: 'kairo.initiatives',
|
key: 'kairo.initiatives',
|
||||||
path: '/initiatives',
|
path: '/initiatives',
|
||||||
title: 'Vorhaben',
|
title: 'Vorhaben',
|
||||||
|
navLabel: 'Vorhaben',
|
||||||
|
shortLabel: 'Vorhaben',
|
||||||
|
matchPrefix: '/initiatives',
|
||||||
requiredCapability: 'kairo.initiative.read',
|
requiredCapability: 'kairo.initiative.read',
|
||||||
component: InitiativesPage,
|
component: InitiativesPage,
|
||||||
showInNav: true,
|
showInNav: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'kairo.my_actions',
|
||||||
|
path: '/my-actions',
|
||||||
|
title: 'Meine Maßnahmen',
|
||||||
|
navLabel: 'Meine Maßnahmen',
|
||||||
|
shortLabel: 'Maßnahmen',
|
||||||
|
requiredCapability: 'kairo.action.read',
|
||||||
|
component: MyActionsPage,
|
||||||
|
showInNav: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'kairo.initiative_detail',
|
key: 'kairo.initiative_detail',
|
||||||
path: '/initiatives/:id',
|
path: '/initiatives/:id',
|
||||||
|
|
@ -37,9 +43,23 @@ export const VIEWS = [
|
||||||
requiredCapability: 'kairo.initiative.read',
|
requiredCapability: 'kairo.initiative.read',
|
||||||
component: InitiativeDetailPage,
|
component: InitiativeDetailPage,
|
||||||
showInNav: false,
|
showInNav: false,
|
||||||
|
matchPrefix: '/initiatives',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} ViewDefinition
|
||||||
|
* @property {string} key
|
||||||
|
* @property {string} path
|
||||||
|
* @property {string} title
|
||||||
|
* @property {string} [navLabel]
|
||||||
|
* @property {string} [shortLabel]
|
||||||
|
* @property {string} [matchPrefix]
|
||||||
|
* @property {string} [requiredCapability]
|
||||||
|
* @property {import('react').ComponentType<any>} component
|
||||||
|
* @property {boolean} [showInNav]
|
||||||
|
*/
|
||||||
|
|
||||||
export function getNavViews(capabilities) {
|
export function getNavViews(capabilities) {
|
||||||
const capSet = capabilities instanceof Set ? capabilities : new Set(capabilities)
|
const capSet = capabilities instanceof Set ? capabilities : new Set(capabilities)
|
||||||
return VIEWS.filter((v) => v.showInNav !== false)
|
return VIEWS.filter((v) => v.showInNav !== false)
|
||||||
|
|
|
||||||
564
frontend/src/styles/components.css
Normal file
564
frontend/src/styles/components.css
Normal file
|
|
@ -0,0 +1,564 @@
|
||||||
|
/* Shared UI components — cards, buttons, forms, badges, states */
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--jk-text-muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--jk-danger);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--jk-surface);
|
||||||
|
border: 1px solid var(--jk-border);
|
||||||
|
border-radius: var(--jk-radius-card);
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: var(--jk-shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--jk-text-subtle);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title--lg {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--jk-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-subtitle {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--jk-text-muted);
|
||||||
|
margin: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--jk-border);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card--alert {
|
||||||
|
border-color: rgba(216, 90, 48, 0.25);
|
||||||
|
background: color-mix(in srgb, var(--jk-warning-bg) 40%, var(--jk-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
width: fit-content;
|
||||||
|
min-height: 44px;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:focus-visible {
|
||||||
|
outline: 2px solid var(--jk-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--jk-primary);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--jk-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--jk-primary-dark);
|
||||||
|
border-color: var(--jk-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--jk-surface);
|
||||||
|
color: var(--jk-text);
|
||||||
|
border-color: var(--jk-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: var(--jk-surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--jk-primary);
|
||||||
|
border-color: transparent;
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
min-height: auto;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-block-mobile {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-inline {
|
||||||
|
color: var(--jk-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-inline:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-active,
|
||||||
|
.status-open {
|
||||||
|
background: var(--jk-primary-soft);
|
||||||
|
color: var(--jk-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-paused,
|
||||||
|
.status-progress {
|
||||||
|
background: color-mix(in srgb, var(--jk-primary) 12%, var(--jk-surface));
|
||||||
|
color: var(--jk-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-blocked {
|
||||||
|
background: var(--jk-warning-bg);
|
||||||
|
color: #7a4b08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-done,
|
||||||
|
.status-completed {
|
||||||
|
background: var(--jk-surface-raised);
|
||||||
|
color: var(--jk-text-muted);
|
||||||
|
border: 1px solid var(--jk-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-discarded,
|
||||||
|
.status-archived {
|
||||||
|
background: var(--jk-surface-raised);
|
||||||
|
color: var(--jk-text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-low {
|
||||||
|
background: var(--jk-surface-raised);
|
||||||
|
color: var(--jk-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-normal {
|
||||||
|
background: var(--jk-primary-soft);
|
||||||
|
color: var(--jk-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.priority-high {
|
||||||
|
background: var(--jk-warning-bg);
|
||||||
|
color: #7a4b08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 1rem 0.25rem;
|
||||||
|
color: var(--jk-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--jk-text);
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-loading {
|
||||||
|
color: var(--jk-text-muted);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-error {
|
||||||
|
color: var(--jk-danger);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-error .btn {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form label,
|
||||||
|
.form-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--jk-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form input,
|
||||||
|
.form textarea,
|
||||||
|
.form select,
|
||||||
|
.workspace-form input,
|
||||||
|
.workspace-form textarea,
|
||||||
|
.workspace-form select,
|
||||||
|
.inline-select {
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
border: 1px solid var(--jk-border-strong);
|
||||||
|
border-radius: 10px;
|
||||||
|
font: inherit;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
background: var(--jk-surface);
|
||||||
|
color: var(--jk-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form input:focus,
|
||||||
|
.form textarea:focus,
|
||||||
|
.form select:focus,
|
||||||
|
.workspace-form input:focus,
|
||||||
|
.workspace-form textarea:focus,
|
||||||
|
.workspace-form select:focus {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--jk-primary) 35%, transparent);
|
||||||
|
border-color: var(--jk-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.65rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-list-item {
|
||||||
|
background: var(--jk-surface-raised);
|
||||||
|
border: 1px solid var(--jk-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-desc {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--jk-text-muted);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-link strong {
|
||||||
|
color: var(--jk-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-lead {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--jk-text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: var(--jk-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb a {
|
||||||
|
color: var(--jk-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-goal {
|
||||||
|
color: var(--jk-text-muted);
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form-card {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form-block {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--jk-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor-select {
|
||||||
|
border: 1px solid var(--jk-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-controls {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auth shell */
|
||||||
|
.auth-shell {
|
||||||
|
max-width: 440px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: max(24px, env(safe-area-inset-top)) 16px 32px;
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-hero {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-hero h1 {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
color: var(--jk-primary);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-hero p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--jk-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-hint {
|
||||||
|
background: var(--jk-primary-soft);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--jk-primary) 25%, transparent);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
color: var(--jk-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setup-hint-muted {
|
||||||
|
background: var(--jk-surface-raised);
|
||||||
|
border-color: var(--jk-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
background: var(--jk-surface-raised);
|
||||||
|
color: var(--jk-text-muted);
|
||||||
|
border: 1px solid var(--jk-border);
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 44px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: var(--jk-primary);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--jk-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-block-mobile {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions .btn {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: min(100%, 160px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-meta {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-controls {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 390px) {
|
||||||
|
.widget-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
min-width: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item span {
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
frontend/src/styles/pages.css
Normal file
26
frontend/src/styles/pages.css
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
.context-dl {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-dl dt {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--jk-text-subtle);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-dl dd {
|
||||||
|
margin: 0.1rem 0 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--jk-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-footer-link {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-sm {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
402
frontend/src/styles/shell.css
Normal file
402
frontend/src/styles/shell.css
Normal file
|
|
@ -0,0 +1,402 @@
|
||||||
|
/* App Shell — Desktop Sidebar + Mobile Bottom Nav (Family-Pattern) */
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font);
|
||||||
|
background: var(--jk-bg);
|
||||||
|
color: var(--jk-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
overflow-x: clip;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100%;
|
||||||
|
min-height: 100dvh;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell__column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: var(--header-h);
|
||||||
|
padding: 0 16px;
|
||||||
|
background: var(--jk-surface);
|
||||||
|
border-bottom: 1px solid var(--jk-border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header--mobile {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: stretch;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.app-header.app-header--mobile {
|
||||||
|
min-height: calc(var(--header-h) + env(safe-area-inset-top, 0px));
|
||||||
|
padding-top: env(safe-area-inset-top, 0px);
|
||||||
|
padding-left: max(16px, env(safe-area-inset-left, 0px));
|
||||||
|
padding-right: max(16px, env(safe-area-inset-right, 0px));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header__top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: var(--header-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-brand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-brand__name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--jk-primary);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-brand__family {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--jk-text-subtle);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: clip;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px max(16px, env(safe-area-inset-right, 0px))
|
||||||
|
calc(var(--nav-h) + var(--nav-pad-top) + env(safe-area-inset-bottom, 0px) + 20px)
|
||||||
|
max(16px, env(safe-area-inset-left, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-page {
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--content-max);
|
||||||
|
margin: 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Bottom Navigation */
|
||||||
|
.bottom-nav {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
background: var(--jk-surface);
|
||||||
|
border-top: 1px solid var(--jk-border);
|
||||||
|
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
|
||||||
|
z-index: 20;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
gap: 4px;
|
||||||
|
padding: var(--nav-pad-top) max(8px, env(safe-area-inset-right, 0px))
|
||||||
|
env(safe-area-inset-bottom, 0px) max(8px, env(safe-area-inset-left, 0px));
|
||||||
|
min-height: calc(var(--nav-h) + var(--nav-pad-top) + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 72px;
|
||||||
|
max-width: 120px;
|
||||||
|
min-height: 48px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--jk-text-subtle);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 6px 8px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item span {
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
color: var(--jk-primary);
|
||||||
|
background: var(--jk-primary-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:focus-visible {
|
||||||
|
outline: 2px solid var(--jk-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Desktop Sidebar */
|
||||||
|
.desktop-sidebar {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
width: var(--desktop-sidebar-width);
|
||||||
|
height: 100vh;
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 30;
|
||||||
|
background: var(--jk-surface);
|
||||||
|
border-right: 1px solid var(--jk-border);
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--jk-border);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar__logo {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid var(--jk-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--jk-primary-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar__titles {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar__title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--jk-primary);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar__subtitle {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--jk-text-subtle);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar__nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar__link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 16px 10px 13px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--jk-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar__link:hover {
|
||||||
|
background: var(--jk-surface-raised);
|
||||||
|
color: var(--jk-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar__link--active {
|
||||||
|
background: var(--jk-primary-soft);
|
||||||
|
color: var(--jk-primary);
|
||||||
|
border-left-color: var(--jk-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar__link:focus-visible {
|
||||||
|
outline: 2px solid var(--jk-primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar__footer {
|
||||||
|
border-top: 1px solid var(--jk-border);
|
||||||
|
padding: 16px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar__user-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar__avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--jk-primary);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar__user-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--jk-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar__logout {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--jk-text-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar__logout:hover {
|
||||||
|
color: var(--jk-danger);
|
||||||
|
background: rgba(216, 90, 48, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-indicator {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 16px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--jk-text-muted);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-indicator__chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--jk-surface-raised);
|
||||||
|
border: 1px solid var(--jk-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 769px) and (max-width: 1023px) {
|
||||||
|
.widget-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.app-shell {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-sidebar {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell__column {
|
||||||
|
margin-left: var(--desktop-sidebar-width);
|
||||||
|
width: calc(100% - var(--desktop-sidebar-width));
|
||||||
|
max-width: calc(100% - var(--desktop-sidebar-width));
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header--mobile {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
padding: 24px 32px 32px;
|
||||||
|
padding-bottom: max(32px, env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-indicator--mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.context-indicator--desktop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
63
frontend/src/styles/tokens.css
Normal file
63
frontend/src/styles/tokens.css
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
/**
|
||||||
|
* Jinkendo Family Design Tokens — Kairo AP0.6b
|
||||||
|
* Abgeleitet von Mitai/Shinkan (--accent #1D9E75, warme Neutrals).
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
/* Aliase AP0.6b Spec */
|
||||||
|
--jk-bg: #f6f5f0;
|
||||||
|
--jk-surface: #ffffff;
|
||||||
|
--jk-surface-raised: #fafaf6;
|
||||||
|
--jk-text: #1c1b18;
|
||||||
|
--jk-text-muted: #5a5955;
|
||||||
|
--jk-text-subtle: #9a9892;
|
||||||
|
--jk-border: rgba(0, 0, 0, 0.09);
|
||||||
|
--jk-border-strong: rgba(0, 0, 0, 0.16);
|
||||||
|
--jk-primary: #1d9e75;
|
||||||
|
--jk-primary-soft: #e1f5ee;
|
||||||
|
--jk-primary-dark: #0a5c43;
|
||||||
|
--jk-danger: #d85a30;
|
||||||
|
--jk-warning: #ef9f27;
|
||||||
|
--jk-warning-bg: #faeeda;
|
||||||
|
--jk-success: #1d9e75;
|
||||||
|
--jk-radius-card: 12px;
|
||||||
|
--jk-shadow-card: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
/* Family-intern (Kompatibilität Shinkan/Mitai-Klassen) */
|
||||||
|
--bg: var(--jk-bg);
|
||||||
|
--surface: var(--jk-surface);
|
||||||
|
--surface2: var(--jk-surface-raised);
|
||||||
|
--border: var(--jk-border);
|
||||||
|
--border2: var(--jk-border-strong);
|
||||||
|
--text1: var(--jk-text);
|
||||||
|
--text2: var(--jk-text-muted);
|
||||||
|
--text3: var(--jk-text-subtle);
|
||||||
|
--accent: var(--jk-primary);
|
||||||
|
--accent-light: var(--jk-primary-soft);
|
||||||
|
--accent-dark: var(--jk-primary-dark);
|
||||||
|
--danger: var(--jk-danger);
|
||||||
|
--warn: var(--jk-warning);
|
||||||
|
--warn-bg: var(--jk-warning-bg);
|
||||||
|
|
||||||
|
--nav-h: 58px;
|
||||||
|
--nav-pad-top: 10px;
|
||||||
|
--header-h: 52px;
|
||||||
|
--desktop-sidebar-width: 220px;
|
||||||
|
--content-max: 1120px;
|
||||||
|
--font: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--jk-bg: #181816;
|
||||||
|
--jk-surface: #222220;
|
||||||
|
--jk-surface-raised: #1e1e1c;
|
||||||
|
--jk-text: #eeecea;
|
||||||
|
--jk-text-muted: #aaa9a4;
|
||||||
|
--jk-text-subtle: #686762;
|
||||||
|
--jk-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--jk-border-strong: rgba(255, 255, 255, 0.14);
|
||||||
|
--jk-primary-soft: #04342c;
|
||||||
|
--jk-primary-dark: #5dcaa5;
|
||||||
|
--jk-shadow-card: 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,400 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { ActionList } from '../components/ActionList.jsx'
|
||||||
import { EmptyState } from '../components/EmptyState.jsx'
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
import { ErrorState } from '../components/ErrorState.jsx'
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
|
import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||||
|
|
||||||
export function BlockedActionsWidget() {
|
export function BlockedActionsWidget() {
|
||||||
const [actions, setActions] = useState([])
|
const [actions, setActions] = useState([])
|
||||||
|
|
@ -29,13 +30,16 @@ export function BlockedActionsWidget() {
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="widget-card widget-card-alert">
|
<WidgetCard
|
||||||
<div className="widget-header">
|
title="Blockierte Maßnahmen"
|
||||||
<h3>Blockierte Maßnahmen</h3>
|
subtitle="Erfordern Aufmerksamkeit"
|
||||||
<button type="button" className="link-button" onClick={load} disabled={loading}>
|
variant="alert"
|
||||||
|
actions={
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={load} disabled={loading}>
|
||||||
Aktualisieren
|
Aktualisieren
|
||||||
</button>
|
</button>
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
{loading && <LoadingState />}
|
{loading && <LoadingState />}
|
||||||
{!loading && error && <ErrorState message={error} onRetry={load} />}
|
{!loading && error && <ErrorState message={error} onRetry={load} />}
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
|
|
@ -47,8 +51,12 @@ export function BlockedActionsWidget() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!loading && !error && actions.length > 0 && (
|
{!loading && !error && actions.length > 0 && (
|
||||||
<p className="widget-footer muted">Blockierte Maßnahmen erfordern Aufmerksamkeit.</p>
|
<p className="muted mt-sm">
|
||||||
|
<Link to="/my-actions" className="link-inline">
|
||||||
|
In „Meine Maßnahmen“ bearbeiten
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</WidgetCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
||||||
import { EmptyState } from '../components/EmptyState.jsx'
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
import { ErrorState } from '../components/ErrorState.jsx'
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
|
import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||||
|
|
||||||
export function InitiativesWidget() {
|
export function InitiativesWidget() {
|
||||||
const [initiatives, setInitiatives] = useState([])
|
const [initiatives, setInitiatives] = useState([])
|
||||||
|
|
@ -32,13 +33,14 @@ export function InitiativesWidget() {
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="widget-card">
|
<WidgetCard
|
||||||
<div className="widget-header">
|
title="Aktive Vorhaben"
|
||||||
<h3>Aktive Vorhaben</h3>
|
actions={
|
||||||
<Link to="/initiatives" className="link-button">
|
<Link to="/initiatives" className="btn btn-ghost">
|
||||||
Alle
|
Alle
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
{loading && <LoadingState />}
|
{loading && <LoadingState />}
|
||||||
{!loading && error && <ErrorState message={error} onRetry={load} />}
|
{!loading && error && <ErrorState message={error} onRetry={load} />}
|
||||||
{!loading && !error && initiatives.length === 0 && (
|
{!loading && !error && initiatives.length === 0 && (
|
||||||
|
|
@ -47,7 +49,7 @@ export function InitiativesWidget() {
|
||||||
{!loading && !error && initiatives.length > 0 && (
|
{!loading && !error && initiatives.length > 0 && (
|
||||||
<ul className="item-list">
|
<ul className="item-list">
|
||||||
{initiatives.map((item) => (
|
{initiatives.map((item) => (
|
||||||
<li key={item.id} className="list-item">
|
<li key={item.id} className="list-item card-list-item">
|
||||||
<Link to={`/initiatives/${item.id}`} className="list-item-link">
|
<Link to={`/initiatives/${item.id}`} className="list-item-link">
|
||||||
<strong>{item.title}</strong>
|
<strong>{item.title}</strong>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -59,6 +61,6 @@ export function InitiativesWidget() {
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</WidgetCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { ActionList } from '../components/ActionList.jsx'
|
||||||
import { EmptyState } from '../components/EmptyState.jsx'
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
import { ErrorState } from '../components/ErrorState.jsx'
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
|
import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||||
|
|
||||||
export function MyOpenActionsWidget() {
|
export function MyOpenActionsWidget() {
|
||||||
const [actions, setActions] = useState([])
|
const [actions, setActions] = useState([])
|
||||||
|
|
@ -29,13 +30,22 @@ export function MyOpenActionsWidget() {
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="widget-card">
|
<WidgetCard
|
||||||
<div className="widget-header">
|
title="Meine offenen Maßnahmen"
|
||||||
<h3>Meine offenen Maßnahmen</h3>
|
subtitle="Offen und in Arbeit — ohne blockiert"
|
||||||
<button type="button" className="link-button" onClick={load} disabled={loading}>
|
actions={
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={load} disabled={loading}>
|
||||||
Aktualisieren
|
Aktualisieren
|
||||||
</button>
|
</button>
|
||||||
</div>
|
}
|
||||||
|
footer={
|
||||||
|
actions.length > 0 ? (
|
||||||
|
<Link to="/my-actions" className="link-inline">
|
||||||
|
Alle meine Maßnahmen →
|
||||||
|
</Link>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
{loading && <LoadingState />}
|
{loading && <LoadingState />}
|
||||||
{!loading && error && <ErrorState message={error} onRetry={load} />}
|
{!loading && error && <ErrorState message={error} onRetry={load} />}
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
|
|
@ -48,11 +58,6 @@ export function MyOpenActionsWidget() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!loading && !error && actions.length > 0 && (
|
</WidgetCard>
|
||||||
<p className="widget-footer">
|
|
||||||
<Link to="/initiatives">Alle Vorhaben →</Link>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,14 @@
|
||||||
import { useSession } from '../context/SessionContext.jsx'
|
import { useSession } from '../context/SessionContext.jsx'
|
||||||
|
import { WidgetCard } from '../components/WidgetCard.jsx'
|
||||||
|
|
||||||
export function TenantContextWidget() {
|
export function TenantContextWidget() {
|
||||||
const { context, me } = useSession()
|
const { context, me } = useSession()
|
||||||
|
|
||||||
if (!context?.tenant) {
|
|
||||||
return (
|
return (
|
||||||
<div className="widget-card">
|
<WidgetCard title="Kontext" subtitle="Aktiver Mandant und Actor">
|
||||||
<h3>Kontext</h3>
|
{!context?.tenant ? (
|
||||||
<p className="muted">Kein aktiver Tenant — bitte Tenant wählen.</p>
|
<p className="muted">Kein aktiver Tenant — bitte Tenant wählen.</p>
|
||||||
</div>
|
) : (
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="widget-card">
|
|
||||||
<h3>Kontext</h3>
|
|
||||||
<dl className="context-dl">
|
<dl className="context-dl">
|
||||||
<div>
|
<div>
|
||||||
<dt>Nutzer</dt>
|
<dt>Nutzer</dt>
|
||||||
|
|
@ -31,12 +25,11 @@ export function TenantContextWidget() {
|
||||||
{context.actor && (
|
{context.actor && (
|
||||||
<div>
|
<div>
|
||||||
<dt>Actor</dt>
|
<dt>Actor</dt>
|
||||||
<dd>
|
<dd>{context.actor.type}</dd>
|
||||||
{context.actor.type} · {context.actor.id.slice(0, 8)}…
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
)}
|
||||||
|
</WidgetCard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user