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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-05 06:58:48 +02:00
parent 0e7184a59e
commit 48a599e76c
34 changed files with 1895 additions and 681 deletions

View File

@ -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"
```
### Workspace UI (AP0.6)
### Workspace UI (AP0.6 / AP0.6b)
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 |
| `/initiatives` | Vorhabenliste mit offenen Maßnahmen-Zähler |
| `/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.
**PWA:** `frontend/public/manifest.webmanifest`, Icon unter `public/icons/`. Kein Service Worker (bewusst — Installierbarkeit über Manifest; kein Offline-Caching in Sprint 0).
**Tests (Frontend):**
```bash
cd frontend && npm install && npm run test && npm run build
```
**Manueller Smoke-Test:** Login → Workspace → Vorhaben anlegen → Maßnahme anlegen → Status auf *Erledigt* → prüfen, dass sie aus „Meine offenen Maßnahmen“ verschwindet.
**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).
Abschlussberichte: `docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md`, `docs/sprints/Sprint0_AP0_6b_Completion_Report_v0.1.md`
### Registries (AP0.4)
Feature-, Prompt-, Placeholder- und Config-Registry analog zur Rights Registry: Code-Registrierung → Startup-Sync → DB.

View 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 |
| **7681023px** | 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 |

View File

@ -2,7 +2,19 @@
<html lang="de">
<head>
<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>
</head>
<body>

View File

@ -1,6 +1,6 @@
{
"name": "kairo-jinkendo-frontend",
"version": "0.6.0-ap0.6",
"version": "0.6.1-ap0.6b",
"private": true,
"type": "module",
"scripts": {
@ -11,6 +11,7 @@
"test:watch": "vitest"
},
"dependencies": {
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.22.0"

View 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

View 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"
}
]
}

View File

@ -4,7 +4,6 @@ import { AppLayout } from './layout/AppLayout.jsx'
import { AuthPanel } from './pages/AuthPanel.jsx'
import { VIEWS } from './registry/viewRegistry.js'
import './app.css'
import './styles/workspace.css'
function AppRoutes() {
const { isAuthenticated, loading } = useSession()

View File

@ -1,132 +1,4 @@
:root {
font-family: system-ui, sans-serif;
color: #1a1a1a;
background: #f6f7fb;
}
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;
}
@import './styles/tokens.css';
@import './styles/shell.css';
@import './styles/components.css';
@import './styles/pages.css';

View File

@ -57,11 +57,11 @@ export function ActionForm({
</label>
<ActorSelect actors={actors} defaultSelected={defaultActors} />
<div className="form-actions">
<button type="submit" disabled={busy}>
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Speichern …' : submitLabel}
</button>
{onCancel && (
<button type="button" className="secondary" onClick={onCancel} disabled={busy}>
<button type="button" className="btn btn-secondary" onClick={onCancel} disabled={busy}>
Abbrechen
</button>
)}

View File

@ -4,7 +4,7 @@ import { PriorityBadge } from '../components/PriorityBadge.jsx'
export function ActionListItem({ action, showInitiative = false, manageLink }) {
return (
<li className="list-item action-item">
<li className="list-item action-item card-list-item">
<div className="list-item-main">
<strong>{action.title}</strong>
{showInitiative && action.initiative_title && (
@ -16,8 +16,8 @@ export function ActionListItem({ action, showInitiative = false, manageLink }) {
<StatusBadge status={action.status} />
<PriorityBadge priority={action.priority} />
{manageLink && (
<Link to={manageLink} className="link-button">
Bearbeiten
<Link to={manageLink} className="link-inline">
Öffnen
</Link>
)}
</div>

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

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

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

View File

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

View File

@ -50,11 +50,11 @@ export function InitiativeForm({
</select>
</label>
<div className="form-actions">
<button type="submit" disabled={busy}>
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Speichern …' : submitLabel}
</button>
{onCancel && (
<button type="button" className="secondary" onClick={onCancel} disabled={busy}>
<button type="button" className="btn btn-secondary" onClick={onCancel} disabled={busy}>
Abbrechen
</button>
)}

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

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

View File

@ -1,50 +1,49 @@
import { NavLink, Outlet } from 'react-router-dom'
import { Outlet } from 'react-router-dom'
import { useSession } from '../context/SessionContext.jsx'
import { useCapabilities } from '../hooks/useCapabilities.js'
import { getNavViews } from '../registry/viewRegistry.js'
import { LoadingState } from '../components/LoadingState.jsx'
import { DesktopSidebar } from '../components/DesktopSidebar.jsx'
import { BottomNav } from '../components/BottomNav.jsx'
import { ContextIndicator } from '../components/ContextIndicator.jsx'
export function AppLayout() {
const { me, context, signOut, loading } = useSession()
const { me, signOut, loading } = useSession()
const { capabilities } = useCapabilities()
const navViews = getNavViews(capabilities)
if (loading) {
return (
<main className="shell">
<LoadingState message="Session wird geladen …" />
</main>
<div className="app-shell">
<main className="app-main">
<div className="app-page">
<LoadingState message="Session wird geladen …" />
</div>
</main>
</div>
)
}
return (
<div className="app-layout">
<header className="app-header">
<div className="app-header-brand">
<strong>Kairo</strong>
{context?.tenant?.name && <span className="muted"> · {context.tenant.name}</span>}
</div>
<nav className="app-nav">
{navViews.map((view) => (
<NavLink
key={view.key}
to={view.path}
className={({ isActive }) => (isActive ? 'nav-link active' : 'nav-link')}
>
{view.title}
</NavLink>
))}
</nav>
<div className="app-header-user">
<span className="user-label">{me?.display_name}</span>
<button type="button" className="secondary" onClick={signOut}>
Abmelden
</button>
</div>
</header>
<main className="shell workspace-shell">
<Outlet />
</main>
<div className="app-shell">
<DesktopSidebar
capabilities={capabilities}
user={me}
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>
<ContextIndicator variant="mobile" />
</header>
<main className="app-main">
<Outlet />
</main>
<BottomNav capabilities={capabilities} />
</div>
</div>
)
}

View File

@ -66,14 +66,16 @@ export function AuthPanel() {
const registrationOpen = setup?.registration_open === true
return (
<main className="shell auth-shell">
<header className="hero">
<h1>Jinkendo Kairo</h1>
<p>Operativer Program Director AP0.6 Workspace</p>
<main className="auth-shell">
<header className="auth-hero">
<h1>Kairo</h1>
<p>Jinkendo Program Director Vorhaben &amp; Maßnahmen</p>
</header>
<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>}
{setupError && <p className="error">{setupError}</p>}
@ -102,13 +104,13 @@ export function AuthPanel() {
<form className="form" onSubmit={handleLogin}>
<label>
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>
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>
<button type="submit" disabled={authBusy}>
<button type="submit" className="btn btn-primary btn-block-mobile" disabled={authBusy}>
{authBusy ? 'Anmelden …' : 'Anmelden'}
</button>
</form>
@ -126,17 +128,17 @@ export function AuthPanel() {
</label>
<label>
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>
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>
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>
<button type="submit" disabled={authBusy}>
<button type="submit" className="btn btn-primary btn-block-mobile" disabled={authBusy}>
{authBusy ? 'Registrieren …' : 'Systemadmin anlegen'}
</button>
</form>

View File

@ -106,12 +106,30 @@ export function InitiativeDetailPage() {
}
}
if (loading) return <LoadingState message="Lade Vorhaben …" />
if (error && !initiative) return <ErrorState message={error} onRetry={load} />
if (!initiative) return <ErrorState message="Vorhaben nicht gefunden." />
if (loading) {
return (
<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 (
<div className="page">
<div className="app-page">
<nav className="breadcrumb">
<Link to="/initiatives">Vorhaben</Link>
<span aria-hidden="true"> / </span>
@ -144,7 +162,11 @@ export function InitiativeDetailPage() {
Erledigte ausblenden
</label>
{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'}
</button>
)}
@ -211,7 +233,7 @@ export function InitiativeDetailPage() {
</select>
<button
type="button"
className="secondary"
className="btn btn-secondary"
onClick={() => {
setEditingAction(action.id)
setShowActionForm(false)

View File

@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
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 { StatusBadge } from '../components/StatusBadge.jsx'
import { PriorityBadge } from '../components/PriorityBadge.jsx'
@ -10,7 +10,6 @@ import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
import { useCapabilities } from '../hooks/useCapabilities.js'
import { useSession } from '../context/SessionContext.jsx'
import { createInitiative } from '../api/initiatives.js'
export function InitiativesPage() {
const { context } = useSession()
@ -67,14 +66,18 @@ export function InitiativesPage() {
}
return (
<div className="page">
<div className="app-page">
<header className="page-header">
<div>
<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>
{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'}
</button>
)}
@ -82,7 +85,7 @@ export function InitiativesPage() {
{showForm && (
<section className="card inline-form-card">
<h2>Neues Vorhaben</h2>
<h2 className="card-title card-title--lg">Neues Vorhaben</h2>
<InitiativeForm
onSubmit={handleCreate}
onCancel={() => setShowForm(false)}
@ -102,7 +105,7 @@ export function InitiativesPage() {
{initiatives.map((item) => (
<li key={item.id} className="list-item card-list-item">
<div className="list-item-main">
<Link to={`/initiatives/${item.id}`}>
<Link to={`/initiatives/${item.id}`} className="list-item-link">
<strong>{item.title}</strong>
</Link>
{item.goal && <p className="list-item-desc">{item.goal}</p>}

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

View File

@ -1,11 +1,11 @@
import { Link } from 'react-router-dom'
import { useNavigate } from 'react-router-dom'
import { useState } from 'react'
import { useCapabilities } from '../hooks/useCapabilities.js'
import { getWidgetsForArea } from '../registry/widgetRegistry.js'
import { useSession } from '../context/SessionContext.jsx'
import { InitiativeForm } from '../components/InitiativeForm.jsx'
import { createInitiative } from '../api/initiatives.js'
import { useState } from 'react'
import { Link } from 'react-router-dom'
export function WorkspacePage() {
const navigate = useNavigate()
@ -34,14 +34,18 @@ export function WorkspacePage() {
}
return (
<div className="page workspace-page">
<div className="app-page">
<header className="page-header">
<div>
<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>
{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'}
</button>
)}
@ -49,7 +53,7 @@ export function WorkspacePage() {
{showForm && (
<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>}
<InitiativeForm
onSubmit={handleCreateInitiative}
@ -69,7 +73,9 @@ export function WorkspacePage() {
{hasCapability('kairo.initiative.read') && (
<p className="page-footer-link">
<Link to="/initiatives">Zur Vorhabenliste </Link>
<Link to="/initiatives" className="link-inline">
Zur Vorhabenliste
</Link>
</p>
)}
</div>

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import { WIDGETS, getWidgetsForArea } from '../registry/widgetRegistry.js'
import { VIEWS, getNavViews } from '../registry/viewRegistry.js'
import { getAppNavItems, isNavItemActive } from '../config/appNav.js'
describe('widgetRegistry', () => {
it('contains expected AP0.6 widgets', () => {
@ -25,10 +26,11 @@ describe('widgetRegistry', () => {
})
describe('viewRegistry', () => {
it('contains workspace and initiatives views', () => {
it('contains workspace, initiatives and my actions views', () => {
const keys = VIEWS.map((v) => v.key)
expect(keys).toContain('kairo.workspace')
expect(keys).toContain('kairo.initiatives')
expect(keys).toContain('kairo.my_actions')
})
it('filters nav views by capability', () => {
@ -37,3 +39,22 @@ describe('viewRegistry', () => {
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)
})
})

View File

@ -1,23 +1,16 @@
import { WorkspacePage } from '../pages/WorkspacePage.jsx'
import { InitiativesPage } from '../pages/InitiativesPage.jsx'
import { InitiativeDetailPage } from '../pages/InitiativeDetailPage.jsx'
import { MyActionsPage } from '../pages/MyActionsPage.jsx'
/**
* @typedef {Object} ViewDefinition
* @property {string} key
* @property {string} path
* @property {string} title
* @property {string} [requiredCapability]
* @property {import('react').ComponentType<any>} component
* @property {boolean} [showInNav]
*/
/** @type {ViewDefinition[]} */
/** @type {import('./viewRegistry.js').ViewDefinition[]} */
export const VIEWS = [
{
key: 'kairo.workspace',
path: '/workspace',
title: 'Workspace',
navLabel: 'Workspace',
shortLabel: 'Start',
requiredCapability: 'kairo.action.read',
component: WorkspacePage,
showInNav: true,
@ -26,10 +19,23 @@ export const VIEWS = [
key: 'kairo.initiatives',
path: '/initiatives',
title: 'Vorhaben',
navLabel: 'Vorhaben',
shortLabel: 'Vorhaben',
matchPrefix: '/initiatives',
requiredCapability: 'kairo.initiative.read',
component: InitiativesPage,
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',
path: '/initiatives/:id',
@ -37,9 +43,23 @@ export const VIEWS = [
requiredCapability: 'kairo.initiative.read',
component: InitiativeDetailPage,
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) {
const capSet = capabilities instanceof Set ? capabilities : new Set(capabilities)
return VIEWS.filter((v) => v.showInNav !== false)

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

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

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

View 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);
}
}

View File

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

View File

@ -5,6 +5,7 @@ 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 BlockedActionsWidget() {
const [actions, setActions] = useState([])
@ -29,13 +30,16 @@ export function BlockedActionsWidget() {
}, [load])
return (
<div className="widget-card widget-card-alert">
<div className="widget-header">
<h3>Blockierte Maßnahmen</h3>
<button type="button" className="link-button" onClick={load} disabled={loading}>
<WidgetCard
title="Blockierte Maßnahmen"
subtitle="Erfordern Aufmerksamkeit"
variant="alert"
actions={
<button type="button" className="btn btn-ghost" onClick={load} disabled={loading}>
Aktualisieren
</button>
</div>
}
>
{loading && <LoadingState />}
{!loading && error && <ErrorState message={error} onRetry={load} />}
{!loading && !error && (
@ -47,8 +51,12 @@ export function BlockedActionsWidget() {
/>
)}
{!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>
)
}

View File

@ -6,6 +6,7 @@ import { PriorityBadge } from '../components/PriorityBadge.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 InitiativesWidget() {
const [initiatives, setInitiatives] = useState([])
@ -32,13 +33,14 @@ export function InitiativesWidget() {
}, [load])
return (
<div className="widget-card">
<div className="widget-header">
<h3>Aktive Vorhaben</h3>
<Link to="/initiatives" className="link-button">
<WidgetCard
title="Aktive Vorhaben"
actions={
<Link to="/initiatives" className="btn btn-ghost">
Alle
</Link>
</div>
}
>
{loading && <LoadingState />}
{!loading && error && <ErrorState message={error} onRetry={load} />}
{!loading && !error && initiatives.length === 0 && (
@ -47,7 +49,7 @@ export function InitiativesWidget() {
{!loading && !error && initiatives.length > 0 && (
<ul className="item-list">
{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">
<strong>{item.title}</strong>
</Link>
@ -59,6 +61,6 @@ export function InitiativesWidget() {
))}
</ul>
)}
</div>
</WidgetCard>
)
}

View File

@ -5,6 +5,7 @@ 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 MyOpenActionsWidget() {
const [actions, setActions] = useState([])
@ -29,13 +30,22 @@ export function MyOpenActionsWidget() {
}, [load])
return (
<div className="widget-card">
<div className="widget-header">
<h3>Meine offenen Maßnahmen</h3>
<button type="button" className="link-button" onClick={load} disabled={loading}>
<WidgetCard
title="Meine offenen Maßnahmen"
subtitle="Offen und in Arbeit — ohne blockiert"
actions={
<button type="button" className="btn btn-ghost" onClick={load} disabled={loading}>
Aktualisieren
</button>
</div>
}
footer={
actions.length > 0 ? (
<Link to="/my-actions" className="link-inline">
Alle meine Maßnahmen
</Link>
) : null
}
>
{loading && <LoadingState />}
{!loading && error && <ErrorState message={error} onRetry={load} />}
{!loading && !error && (
@ -48,11 +58,6 @@ export function MyOpenActionsWidget() {
}
/>
)}
{!loading && !error && actions.length > 0 && (
<p className="widget-footer">
<Link to="/initiatives">Alle Vorhaben </Link>
</p>
)}
</div>
</WidgetCard>
)
}

View File

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