pdf generator #103
|
|
@ -52,7 +52,7 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
|
|||
|--------|-------------|-------------------|
|
||||
| Data Layer / Charts (Phase 0c) | `functional/DATA_ARCHITECTURE.md`, `technical/DATA_LAYER_EXTENSION_GUIDE.md` | `backend/data_layer/`, `backend/routers/charts.py` |
|
||||
| Platzhalter / Registry | `technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md`, `technical/PLACEHOLDER_DEVELOPMENT_GUIDE.md` | `backend/placeholder_registrations/`, `backend/placeholder_resolver.py` |
|
||||
| Dashboard-Lab-Widgets | `technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` | Widget-Katalog + Registrierung (siehe Guide) |
|
||||
| Dashboard-Widgets | `technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` | Widget-Katalog + Registrierung (siehe Guide) |
|
||||
| Training Profiler / Resolver | `technical/TRAINING_PROFILE_RESOLVER_LAYER1.md`, `functional/TRAINING_TYPE_PROFILES.md` | Resolver-Module wie im Guide genannt |
|
||||
| Universal CSV Import | `technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | `backend/csv_parser/`, `routers/csv_import.py`, `routers/admin_csv_templates.py` |
|
||||
| Aktivität Produktionsreife | `technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` (+ EAV-Guide) | `backend/data_layer/activity_session_metrics.py`, `activity_metrics.py`, CSV-Orchestrierung |
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Dashboard-Lab-Widgets – Anleitung für Coding-Agenten
|
||||
# Dashboard-Widgets – Anleitung für Coding-Agenten
|
||||
|
||||
Ziel: Ein neues Dashboard-Widget **end-to-end** korrekt einbinden (Backend-Katalog, Validierung, API-Layout, Frontend-Registrierung, optional Lab-Editor für `config`).
|
||||
Kontext: **Dashboard-Lab** unter geschützten Endpoints `GET/PUT /api/app/...` (siehe `backend/routers/app_dashboard.py`). Layout liegt pro Profil in `profiles.dashboard_layout` (JSON).
|
||||
Ziel: Ein neues Dashboard-Widget **end-to-end** korrekt einbinden (Backend-Katalog, Validierung, API-Layout, Frontend-Registrierung, optional Editor für `config` in **Übersicht anpassen**).
|
||||
Kontext: Geschützte Endpoints `GET/PUT /api/app/...` (siehe `backend/routers/app_dashboard.py`). Layout liegt pro Profil in `profiles.dashboard_layout` (JSON). Nutzer-Oberfläche: `frontend/src/pages/DashboardConfigurePage.jsx` (Route z. B. `/settings/dashboard-layout`).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ Kontext: **Dashboard-Lab** unter geschützten Endpoints `GET/PUT /api/app/...` (
|
|||
| Anforderung | Beschreibung |
|
||||
|-------------|--------------|
|
||||
| **A1 – Zentrale Auflösung** | Backend ermittelt pro Profil (effektiver Tier + Restrictions), welche Widget-IDs **erlaubt** sind – idealerweise in **einer** Stelle (Erweiterung des Katalog-Endpoints oder dedizierter Entitlements-Teil der Response). Intern: `check_feature_access` und später ggf. Mapping Widget-ID → Feature-ID(n) / Cluster. |
|
||||
| **A2 – Nutzer-Konfigurator** | Im Dashboard-Lab (und jedem späteren Layout-Konfigurator): Widgets **ohne Berechtigung nicht anbieten** (ausgeblendet oder gar nicht in der Liste). Alle **erlaubten** Widgets bleiben wie heute wählbar. |
|
||||
| **A2 – Nutzer-Konfigurator** | Im Layout-Konfigurator (**Übersicht anpassen**): Widgets **ohne Berechtigung nicht anbieten** (ausgeblendet oder gar nicht in der Liste). Alle **erlaubten** Widgets bleiben wie heute wählbar. |
|
||||
| **A3 – Layout-Persistenz** | `PUT /api/app/dashboard-layout`: Layout darf **keine** nicht erlaubten Widgets dauerhaft speichern – entweder **ablehnen** (422) oder **beim Speichern entfernen/deaktivieren** (Policy festlegen und dokumentieren). Verhindert „gespeichert, aber nie sichtbar“-Zombies. |
|
||||
| **A4 – API-/Datenschutz** | Sichtbarkeit im UI reicht nicht: Endpoints, die **Inhalte** für gated Widgets liefern (Charts, KI, …), müssen weiterhin wie heute **eigenständig** über Features abgesichert sein (`check_feature_access`, 403). |
|
||||
|
||||
|
|
@ -42,8 +42,8 @@ Kontext: **Dashboard-Lab** unter geschützten Endpoints `GET/PUT /api/app/...` (
|
|||
1. **`backend/widget_catalog.py`** – `WIDGET_CATALOG`: erlaubte Widget-IDs, Reihenfolge, Titel/Beschreibung für API und Default-Layout.
|
||||
2. **`backend/dashboard_layout_schema.py`** – `DashboardLayoutPayload`: jede Zeile hat `id`, `enabled`, optional `config`. IDs müssen in `ALLOWED_WIDGET_IDS` sein (aus dem Katalog abgeleitet).
|
||||
3. **`backend/dashboard_widget_config.py`** – `validate_widget_entry_config`: **nur** Widgets in `WIDGETS_ALLOWING_CONFIG` dürfen **nicht-leere** `config` haben; Keys werden streng validiert (unbekannte Keys → Fehler).
|
||||
4. **Frontend** – `ensurePilotLabWidgetsRegistered()` in `frontend/src/widgetSystem/registerPilotLabWidgets.js`: verbindet jede Katalog-ID mit einer React-Komponente und mappt `ctx.layoutEntry.config` auf Props.
|
||||
5. **Dashboard-Lab-UI** – `frontend/src/pages/DashboardLabPage.jsx`: Umsortieren, Ein/Aus, Speichern; **zusätzliche** UI nur nötig, wenn das Widget konfigurierbare Felder braucht.
|
||||
4. **Frontend** – `ensureDashboardWidgetsRegistered()` in `frontend/src/widgetSystem/registerDashboardWidgets.js`: verbindet jede Katalog-ID mit einer React-Komponente und mappt `ctx.layoutEntry.config` auf Props.
|
||||
5. **Layout-Editor (Produkt)** – `frontend/src/pages/DashboardConfigurePage.jsx`: Umsortieren, Ein/Aus, Speichern; **zusätzliche** UI nur nötig, wenn das Widget konfigurierbare Felder braucht.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -52,9 +52,9 @@ Kontext: **Dashboard-Lab** unter geschützten Endpoints `GET/PUT /api/app/...` (
|
|||
| Schritt | Datei | Aktion |
|
||||
|--------|--------|--------|
|
||||
| A | `backend/widget_catalog.py` | Neuen Eintrag `{ "id", "title", "description" }` in `WIDGET_CATALOG` einfügen (Reihenfolge = Default-Reihenfolge im Layout). Optional `"requires_feature": "<features.id>"` für Tarif-Gating (`dashboard_widget_entitlements`). |
|
||||
| B | `backend/widget_catalog.py` | Optional: ID zu `DEFAULT_LAB_WIDGET_IDS` hinzufügen, wenn es im Standard-Lab **aktiv** sein soll. |
|
||||
| C | `frontend/src/components/dashboard-widgets/MyWidget.jsx` (oder Pilot-Komponente) | React-Komponente implementieren; typischerweise `refreshTick` aus `mapProps` nutzen, um Daten neu zu laden. |
|
||||
| D | `frontend/src/widgetSystem/registerPilotLabWidgets.js` | `import` + `registerDashboardWidget({ id, Component, mapProps })` – `id` **exakt** wie im Katalog. |
|
||||
| B | `backend/widget_catalog.py` | Optional: ID zu `DEFAULT_LAB_WIDGET_IDS` hinzufügen, wenn es im Server-Standardlayout **aktiv** sein soll (Feld `lab_default_layout` in der Layout-API). |
|
||||
| C | `frontend/src/components/dashboard-widgets/MyWidget.jsx` (oder Legacy-Widget unter `dashboard-widgets-legacy/`) | React-Komponente implementieren; typischerweise `refreshTick` aus `mapProps` nutzen, um Daten neu zu laden. |
|
||||
| D | `frontend/src/widgetSystem/registerDashboardWidgets.js` | `import` + `registerDashboardWidget({ id, Component, mapProps })` – `id` **exakt** wie im Katalog. |
|
||||
| E | `backend/tests/test_widget_catalog.py` | Läuft implizit mit; bei Strukturänderungen Katalog-Tests beachten. |
|
||||
| F | `backend/version.py` | `MODULE_VERSIONS["app_dashboard"]` MINOR erhöhen und kurz kommentieren. |
|
||||
| G | Build/Tests | `pytest` (z. B. `tests/test_dashboard_layout_schema.py`, `test_widget_catalog.py`); `npm run build` im `frontend`. |
|
||||
|
|
@ -110,11 +110,11 @@ mapProps: (ctx) => ({
|
|||
|
||||
**Abgleich mit Chart-Zeitraum:** Für `chart_days` existiert `frontend/src/widgetSystem/bodyChartDays.js` (`BODY_CHART_DAYS_MIN/MAX`, `normalizeBodyChartDays`). Entweder in `mapProps` normalisieren (wie `body_overview`) oder rohen Wert durchreichen und in der Widget-Komponente normalisieren (wie `nutrition_detail_charts` / `TrendKcalWeightWidget`) – **beides** ist im Projekt vertreten; wichtig ist Konsistenz mit der Backend-Grenze 7–90.
|
||||
|
||||
### 3.4 Dashboard-Lab-Editor (`DashboardLabPage.jsx`)
|
||||
### 3.4 Layout-Editor (`DashboardConfigurePage.jsx`)
|
||||
|
||||
Ohne UI-Änderung bleibt `config` beim Nutzer `{}` – konfigurierbare Widgets brauchen **Editor-Controls**:
|
||||
|
||||
- **Einfaches Zahlfeld `chart_days`:** Eintrag in `CHART_DAYS_WIDGET_IDS` (Set oben in der Datei) + bestehendes Label/`aria-label`-Pattern für die Zeitraum-Zeile erweitern (siehe `body_overview`, `nutrition_detail_charts`).
|
||||
- **Einfaches Zahlfeld `chart_days`:** Eintrag in `CHART_DAYS_WIDGET_IDS` (Set oben in `DashboardConfigurePage.jsx`) + bestehendes Label/`aria-label`-Pattern für die Zeitraum-Zeile erweitern (siehe `body_overview`, `nutrition_detail_charts`).
|
||||
- **Strukturierte Config (Listen, mehrere Booleans):** Eigenes Editor-Komponenten-File nach Vorbild `KpiBoardConfigEditor.jsx` / `QuickCaptureConfigEditor.jsx` einbinden und `setLayout` + `normalizeLayoutForEditor` wie bei den bestehenden Blöcken verwenden.
|
||||
|
||||
Nach Speichern ruft die Seite `api.putAppDashboardLayout(layout)` auf; das Backend validiert über `DashboardLayoutPayload` → `validate_widget_entry_config`.
|
||||
|
|
@ -137,7 +137,7 @@ Nach Speichern ruft die Seite `api.putAppDashboardLayout(layout)` auf; das Backe
|
|||
## 5. API zum Prüfen
|
||||
|
||||
- `GET /api/app/widgets/catalog` – Katalog inkl. `allowed` je Widget (Auth + `X-Profile-Id` wie andere App-Endpoints).
|
||||
- `GET /api/app/dashboard-layout` – `layout` (effektiv, bereinigt), `custom`, `product_default_layout` (Übersichts-Standard), `lab_default_layout` (Dashboard-Lab-Standard).
|
||||
- `GET /api/app/dashboard-layout` – `layout` (effektiv, bereinigt), `custom`, `product_default_layout` (Übersichts-Standard), `lab_default_layout` (Servertemplate für Editor/Reset; Feldname historisch).
|
||||
- `PUT /api/app/dashboard-layout` – Body `{ "version": 1, "widgets": [ ... ] }` (unerlaubte Widgets werden auf `enabled: false` gesetzt).
|
||||
|
||||
---
|
||||
|
|
@ -159,5 +159,5 @@ Nach Speichern ruft die Seite `api.putAppDashboardLayout(layout)` auf; das Backe
|
|||
| Layout-Pydantic | `backend/dashboard_layout_schema.py` |
|
||||
| HTTP | `backend/routers/app_dashboard.py` |
|
||||
| Registry + Render | `frontend/src/widgetSystem/dashboardWidgetRegistry.jsx` |
|
||||
| Pilot/Lab-Registrierung | `frontend/src/widgetSystem/registerPilotLabWidgets.js` |
|
||||
| Lab-UI | `frontend/src/pages/DashboardLabPage.jsx` |
|
||||
| Dashboard-Widget-Registrierung | `frontend/src/widgetSystem/registerDashboardWidgets.js` |
|
||||
| Layout-Editor (Nutzer) | `frontend/src/pages/DashboardConfigurePage.jsx` |
|
||||
|
|
|
|||
56
.claude/docs/technical/REPORT_PROFILES_AND_PDF.md
Normal file
56
.claude/docs/technical/REPORT_PROFILES_AND_PDF.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# Berichtsprofile & PDF (technisch)
|
||||
|
||||
**Stand:** 2026-04-29
|
||||
|
||||
## Begriffe
|
||||
|
||||
| Begriff | Bedeutung |
|
||||
|--------|-----------|
|
||||
| **Layout-Snapshot** | PDF aus gerasteter DOM-Übersicht (`html2canvas` + `jspdf`), optional Widget `report_export`. |
|
||||
| **Strukturierter Bericht** | Profil mit Blöcken (`section`, `chart`, `ai_insight`), PDF serverseitig via Data Layer + Matplotlib + ReportLab. |
|
||||
|
||||
Die beiden Wege sind bewusst getrennt, damit das Dashboard nicht die einzige „Wahrheit“ für Dokumente wird.
|
||||
|
||||
## Datenbank
|
||||
|
||||
- Tabelle `report_profiles` (Migration `060_report_profiles.sql`): `profile_id` PK → `profiles`, `payload` JSONB, `updated_at`.
|
||||
|
||||
Ohne Zeile gilt ein **Code-Standard** (`default_report_profile_dict` in `report_profile_schema.py`).
|
||||
|
||||
## API (`/api/reports`)
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|--------|------|--------|
|
||||
| GET | `/catalog` | Diagramm-Katalog + Blocktypen für UI |
|
||||
| GET | `/profile` | `{ stored, profile }` |
|
||||
| PUT | `/profile` | Vollständiges Profil-JSON (Pydantic-validiert) |
|
||||
| DELETE | `/profile` | DB-Zeile löschen → wieder Standard |
|
||||
| POST | `/generate-pdf` | PDF-Download; `data_export`-Kontingent + `increment_feature_usage` |
|
||||
|
||||
## Schema v1 (`report_profile_schema.py`)
|
||||
|
||||
- `version`: nur `1`
|
||||
- `document_title`: optional
|
||||
- `blocks`: Liste mit Union:
|
||||
- `section`: `title`
|
||||
- `chart`: `chart_id` ∈ `ALLOWED_CHART_IDS`, `window_days` 7–365
|
||||
- `ai_insight`: optional `insight_id` (UUID, `ai_insights.id`), optional `title`
|
||||
|
||||
## Diagrammdaten
|
||||
|
||||
`report_chart_fetch.fetch_chart_payload` ruft dieselben Bausteine auf wie `/api/charts` (ohne HTTP). Erweiterung: Eintrag in `ALLOWED_CHART_IDS`, Fetcher in `_CHART_FETCHERS`, Zeile in `CHART_CATALOG_FOR_API`.
|
||||
|
||||
## PDF-Rendering
|
||||
|
||||
`report_pdf_render.build_structured_report_pdf`: ReportLab-Flowable-Kette, Diagramme als PNG aus Chart-Payload (Matplotlib, Agg-Backend).
|
||||
|
||||
## Frontend
|
||||
|
||||
- **Einstellungen:** Karte „PDF-Bericht (strukturiert)“ — Blöcke bearbeiten, speichern, Standard, PDF erzeugen.
|
||||
- **Dashboard:** Widget bleibt optionaler **Schnappschuss**; Hinweis verweist auf Einstellungen.
|
||||
|
||||
## Nächste sinnvolle Erweiterungen
|
||||
|
||||
- Dashboard-Layout → Berichtsprofil **einmalig importieren** (Mapping-Tabelle Widget-ID → chart_id).
|
||||
- KI: Insights-Auswahl in der UI statt manueller UUID.
|
||||
- Weitere `chart_id`-Werte / multipage Feintuning (Seitenumbrüche pro Block).
|
||||
|
|
@ -455,15 +455,15 @@ NIEMALS gegen mitai.jinkendo.de
|
|||
|
||||
---
|
||||
|
||||
## 10. Dashboard-Lab-Widgets und Feature-System
|
||||
## 10. Dashboard-Widgets und Feature-System
|
||||
|
||||
**Kontext:** Dashboard-Widgets (`backend/widget_catalog.py`, Lab unter `/api/app/...`) und das **Subscription-/Feature-Modell** (`features`, `tier_limits`, `check_feature_access` in `backend/auth.py`) sind **getrennte Schichten**, müssen aber bei tariffrelevanten Widgets **verknüpft** werden.
|
||||
**Kontext:** Dashboard-Widgets (`backend/widget_catalog.py`, API unter `/api/app/...`) und das **Subscription-/Feature-Modell** (`features`, `tier_limits`, `check_feature_access` in `backend/auth.py`) sind **getrennte Schichten**, müssen aber bei tariffrelevanten Widgets **verknüpft** werden.
|
||||
|
||||
**Bindend:**
|
||||
|
||||
1. **Keine fest codierten Tier-Namen** für Widget-Rechte – Tiers und Limits kommen aus der DB.
|
||||
2. **Komplexität** (Module aus, Unter-Stufen, KI vs. Standard) liegt in der **Feature-/Subscription-Logik**, nicht verteilt in Widget-Komponenten.
|
||||
3. **Nutzer-Konfigurator** (z. B. Dashboard-Lab): Widgets **ohne** passende Berechtigung **nicht anzeigen**; alle erlaubten Widgets bleiben verfügbar.
|
||||
3. **Nutzer-Konfigurator** (**Übersicht anpassen** / `DashboardConfigurePage`): Widgets **ohne** passende Berechtigung **nicht anzeigen**; alle erlaubten Widgets bleiben verfügbar.
|
||||
4. **Backend** liefert die effektive Erlaubnis (z. B. über erweiterten Katalog oder Entitlements), und **validiert beim Speichern** des Layouts, dass keine unerlaubten Widget-IDs persistiert werden (Policy: ablehnen oder strippen – einheitlich halten).
|
||||
5. **Daten/API:** Zusätzlich zur UI-Filterung müssen die **inhaltsliefernden Endpoints** weiterhin über `check_feature_access` geschützt sein (kein Leck über direkte API-Aufrufe).
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
> | **Gitea-Landkarte (lokal gepflegt)** | **`.claude/docs/GITEA_ISSUES_INDEX.md`** |
|
||||
> | **Universal CSV Import** (neues Modul / Executor / Vorlagen) | **`.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md`** |
|
||||
> | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** |
|
||||
> | **Dashboard-Lab-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** |
|
||||
> | **Dashboard-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** |
|
||||
> | **Agent-Einstieg** | **`.claude/README.md`** |
|
||||
> | **Activity Session Metrics (EAV, Attributprofile)** | **`.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md`** |
|
||||
|
||||
|
|
@ -100,6 +100,11 @@ frontend/src/
|
|||
**Branch:** develop
|
||||
**Nächster Schritt:** Frontend Chart Integration → Testing → Prod Deploy v0.9i
|
||||
|
||||
### Updates (23.04.2026 - Dashboard: veraltete Demo-Route entfernt, klare Produkt-Registry)
|
||||
|
||||
- **Frontend:** Veraltete Visualisierungs-Demo-Route und festes Demo-Layout entfernt; Widget-Registrierung in `frontend/src/widgetSystem/registerDashboardWidgets.js` (`ensureDashboardWidgetsRegistered`). Kern-Widgets unter `frontend/src/components/dashboard-widgets-legacy/`. Chart-Hilfen in `frontend/src/widgetSystem/dashboardChartUtils.js`. Experimentelles Layout-Lab entfernt; Konfiguration nur noch **Übersicht anpassen** (`DashboardConfigurePage`).
|
||||
- **Doku:** `.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` und Kommentar in `backend/widget_catalog.py` angepasst.
|
||||
|
||||
### Updates (09.04.2026 - Universal CSV Import, Prod-Migration abgeschlossen)
|
||||
|
||||
- **Agent-Leitfaden:** `.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` (Checkliste für neue Import-Module, Executor, Vorlagen, `source=csv`, SAVEPOINT-/Cursor-Regeln)
|
||||
|
|
@ -891,7 +896,7 @@ Bottom-Padding Mobile: 80px (Navigation)
|
|||
|Auth-Flow|`.claude/library/AUTH.md`|Sicherheit + Sessions|
|
||||
|API-Referenz|`.claude/library/API\_REFERENCE.md`|Alle Endpoints|
|
||||
|Datenbankschema|`.claude/library/DATABASE.md`|Tabellen + Beziehungen|
|
||||
|Dashboard-Lab-Widgets|`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`|Katalog, Validierung, Frontend-Registry, konfigurierbare `config`|
|
||||
|Dashboard-Widgets|`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`|Katalog, Validierung, Frontend-Registry, konfigurierbare `config`|
|
||||
|Projekt-Doku (Git)|`docs/README.md` + `docs/issues/`|Issue-Specs, Reviews, Platzhalter-Governance, Status-Snapshots|
|
||||
|
||||
> Library-Dateien werden mit `/document` generiert und nach größeren
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
Dashboard-Layout v1: Validierung, Produkt-Standard (Übersicht) und Lab-Standard.
|
||||
Dashboard-Layout v1: Validierung, Produkt-Standard (Übersicht) und Servertemplate (`lab_default_layout_dict`).
|
||||
|
||||
Erlaubte Widget-IDs und Reihenfolge: widget_catalog.WIDGET_CATALOG.
|
||||
"""
|
||||
|
|
@ -32,7 +32,7 @@ __all__ = [
|
|||
|
||||
|
||||
def lab_default_layout_dict() -> dict[str, Any]:
|
||||
"""Standard für Dashboard-Lab (Experimentier-Widgets)."""
|
||||
"""Serverseitiges Standardlayout (DEFAULT_LAB_WIDGET_IDS); API-Feld `lab_default_layout`, u. a. für Editor/Reset."""
|
||||
on = DEFAULT_LAB_WIDGET_IDS
|
||||
return {
|
||||
"version": 1,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({
|
|||
"trend_kcal_weight",
|
||||
"nutrition_detail_charts",
|
||||
"recovery_charts_panel",
|
||||
"report_export",
|
||||
})
|
||||
|
||||
_QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({
|
||||
|
|
@ -201,6 +202,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
|
|||
return _validate_recovery_history_viz_config({})
|
||||
if widget_id == "history_overview_viz":
|
||||
return _validate_history_overview_viz_config({})
|
||||
if widget_id == "report_export":
|
||||
return _validate_report_export_config({})
|
||||
return {}
|
||||
|
||||
if widget_id == "body_overview":
|
||||
|
|
@ -227,6 +230,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
|
|||
return _validate_chart_days_only(raw, label="nutrition_detail_charts")
|
||||
if widget_id == "recovery_charts_panel":
|
||||
return _validate_chart_days_only(raw, label="recovery_charts_panel")
|
||||
if widget_id == "report_export":
|
||||
return _validate_report_export_config(raw)
|
||||
|
||||
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
||||
|
||||
|
|
@ -530,3 +535,43 @@ def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, A
|
|||
return {"chart_days": v}
|
||||
|
||||
|
||||
def _validate_report_export_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
label = "report_export"
|
||||
allowed = frozenset({"document_title", "subtitle", "capture_scale"})
|
||||
unknown = set(raw) - allowed
|
||||
if unknown:
|
||||
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
||||
out: dict[str, Any] = {"capture_scale": 2}
|
||||
if "document_title" in raw:
|
||||
t = raw["document_title"]
|
||||
if t is not None and not isinstance(t, str):
|
||||
raise ValueError(f"{label}: document_title muss Text sein")
|
||||
s = (t or "").strip()
|
||||
if len(s) > 120:
|
||||
raise ValueError(f"{label}: document_title max. 120 Zeichen")
|
||||
if s:
|
||||
out["document_title"] = s
|
||||
if "subtitle" in raw:
|
||||
t = raw["subtitle"]
|
||||
if t is not None and not isinstance(t, str):
|
||||
raise ValueError(f"{label}: subtitle muss Text sein")
|
||||
s = (t or "").strip()
|
||||
if len(s) > 240:
|
||||
raise ValueError(f"{label}: subtitle max. 240 Zeichen")
|
||||
if s:
|
||||
out["subtitle"] = s
|
||||
if "capture_scale" in raw:
|
||||
v = raw["capture_scale"]
|
||||
if isinstance(v, bool) or isinstance(v, float):
|
||||
if isinstance(v, float) and math.isfinite(v) and abs(v - round(v)) < 1e-9:
|
||||
v = int(round(v))
|
||||
else:
|
||||
raise ValueError(f"{label}: capture_scale muss ganze Zahl 1–3 sein")
|
||||
if not isinstance(v, int):
|
||||
raise ValueError(f"{label}: capture_scale muss ganze Zahl 1–3 sein")
|
||||
if v < 1 or v > 3:
|
||||
raise ValueError(f"{label}: capture_scale muss zwischen 1 und 3 liegen")
|
||||
out["capture_scale"] = v
|
||||
return out
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,8 @@ from routers import workflow_questions # Phase 1 Workflow Engine - Question Cat
|
|||
from routers import workflows # Phase 2 Workflow Engine - Execution
|
||||
from routers import reference_values # Persönliche Referenzwerte (Profil)
|
||||
from routers import admin_reference_value_types # Admin: Referenzwert-Typen
|
||||
from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Lab Layout
|
||||
from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Layout + Widget-Katalog
|
||||
from routers import reports # Strukturierter PDF-Bericht (Profil v1)
|
||||
from routers import csv_import, admin_csv_templates # Issue #21 Universal CSV Parser
|
||||
from routers import admin_training_parameters, admin_activity_attribute_profiles # EAV session metrics
|
||||
|
||||
|
|
@ -127,6 +128,7 @@ app.include_router(workflows.router) # /api/workflows/* (Phase 2 Exec
|
|||
app.include_router(reference_values.router) # /api/reference-value-types, /api/profile-reference-values
|
||||
app.include_router(admin_reference_value_types.router) # /api/admin/reference-value-types
|
||||
app.include_router(app_dashboard.router) # /api/app/dashboard-layout
|
||||
app.include_router(reports.router) # /api/reports/* (Berichtsprofil + PDF)
|
||||
app.include_router(csv_import.router) # /api/csv/* (Issue #21)
|
||||
app.include_router(admin_csv_templates.router) # /api/admin/csv-templates/* (Issue #21)
|
||||
app.include_router(admin_training_parameters.router) # /api/admin/training-parameters
|
||||
|
|
|
|||
11
backend/migrations/060_report_profiles.sql
Normal file
11
backend/migrations/060_report_profiles.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- Migration 060: Strukturierter Bericht (Profil JSON pro Nutzerprofil, unabhängig vom Dashboard-Layout)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS report_profiles (
|
||||
profile_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_report_profiles_updated ON report_profiles(updated_at);
|
||||
|
||||
COMMENT ON TABLE report_profiles IS 'Konfigurierbarer PDF-Bericht v1 (Blöcke: section, chart, ai_insight); Rendering serverseitig aus Datenlayer';
|
||||
24
backend/migrations/061_report_definitions_multi.sql
Normal file
24
backend/migrations/061_report_definitions_multi.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
-- Migration 061: Mehrere benannte PDF-Berichte pro Nutzerprofil; Daten von report_profiles übernehmen.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS report_definitions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT 'Bericht',
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_report_definitions_profile_sort
|
||||
ON report_definitions (profile_id, sort_order);
|
||||
|
||||
COMMENT ON TABLE report_definitions IS 'Mehrere strukturierte PDF-Berichte pro Profil (payload = ReportProfilePayload v1)';
|
||||
|
||||
INSERT INTO report_definitions (profile_id, name, payload, sort_order)
|
||||
SELECT rp.profile_id, 'Standard', rp.payload, 0
|
||||
FROM report_profiles rp
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM report_definitions rd WHERE rd.profile_id = rp.profile_id
|
||||
);
|
||||
|
||||
DROP TABLE IF EXISTS report_profiles;
|
||||
139
backend/report_chart_fetch.py
Normal file
139
backend/report_chart_fetch.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"""
|
||||
Chart-Daten für Berichts-PDF: dieselbe Logik wie /api/charts/* (Data Layer), ohne HTTP.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from data_layer.activity_metrics import (
|
||||
build_training_type_distribution_chart_payload,
|
||||
build_training_volume_chart_payload,
|
||||
)
|
||||
from data_layer.body_metrics import get_weight_trend_data
|
||||
from data_layer.nutrition_chart_payloads import build_energy_balance_chart_payload
|
||||
from data_layer.nutrition_metrics import get_nutrition_average_data
|
||||
from data_layer.utils import serialize_dates
|
||||
|
||||
|
||||
def _weight_trend_payload(profile_id: str, days: int) -> dict[str, Any]:
|
||||
d = min(max(days, 7), 365)
|
||||
trend_data = get_weight_trend_data(profile_id, d)
|
||||
if trend_data["confidence"] == "insufficient":
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {"labels": [], "datasets": []},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Nicht genug Daten für Trend-Analyse",
|
||||
},
|
||||
}
|
||||
series = trend_data.get("series") or []
|
||||
labels = [
|
||||
pt["date"].isoformat() if hasattr(pt["date"], "isoformat") else str(pt["date"]) for pt in series
|
||||
]
|
||||
values = [pt["weight"] for pt in series]
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Gewicht",
|
||||
"data": values,
|
||||
"borderColor": "#1D9E75",
|
||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
||||
"borderWidth": 2,
|
||||
"tension": 0.4,
|
||||
"fill": True,
|
||||
"pointRadius": 2,
|
||||
}
|
||||
],
|
||||
},
|
||||
"metadata": serialize_dates(
|
||||
{
|
||||
"confidence": trend_data["confidence"],
|
||||
"data_points": trend_data["data_points"],
|
||||
"first_value": trend_data["first_value"],
|
||||
"last_value": trend_data["last_value"],
|
||||
"delta": trend_data["delta"],
|
||||
"direction": trend_data["direction"],
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _macro_distribution_payload(profile_id: str, days: int) -> dict[str, Any]:
|
||||
d = min(max(days, 7), 90)
|
||||
macro_data = get_nutrition_average_data(profile_id, d)
|
||||
if macro_data["confidence"] == "insufficient":
|
||||
return {
|
||||
"chart_type": "pie",
|
||||
"data": {"labels": [], "datasets": []},
|
||||
"metadata": {"confidence": "insufficient", "message": "Keine Ernährungsdaten vorhanden"},
|
||||
}
|
||||
protein_kcal = macro_data["protein_avg"] * 4
|
||||
carbs_kcal = macro_data["carbs_avg"] * 4
|
||||
fat_kcal = macro_data["fat_avg"] * 9
|
||||
total_kcal = protein_kcal + carbs_kcal + fat_kcal
|
||||
if total_kcal == 0:
|
||||
return {
|
||||
"chart_type": "pie",
|
||||
"data": {"labels": [], "datasets": []},
|
||||
"metadata": {"confidence": "insufficient", "message": "Keine Makronährstoff-Daten"},
|
||||
}
|
||||
protein_pct = protein_kcal / total_kcal * 100
|
||||
carbs_pct = carbs_kcal / total_kcal * 100
|
||||
fat_pct = fat_kcal / total_kcal * 100
|
||||
return {
|
||||
"chart_type": "pie",
|
||||
"data": {
|
||||
"labels": ["Protein", "Kohlenhydrate", "Fett"],
|
||||
"datasets": [
|
||||
{
|
||||
"data": [round(protein_pct, 1), round(carbs_pct, 1), round(fat_pct, 1)],
|
||||
"backgroundColor": ["#1D9E75", "#F59E0B", "#EF4444"],
|
||||
"borderWidth": 2,
|
||||
"borderColor": "#fff",
|
||||
}
|
||||
],
|
||||
},
|
||||
"metadata": {"confidence": macro_data.get("confidence", "high")},
|
||||
}
|
||||
|
||||
|
||||
def _training_volume_payload(profile_id: str, window_days: int) -> dict[str, Any]:
|
||||
w = max(4, min(52, window_days // 7))
|
||||
return build_training_volume_chart_payload(profile_id, w)
|
||||
|
||||
|
||||
_CHART_FETCHERS: dict[str, Callable[[str, int], dict[str, Any]]] = {
|
||||
"weight_trend": _weight_trend_payload,
|
||||
"energy_balance": lambda pid, d: build_energy_balance_chart_payload(pid, min(max(d, 7), 90)),
|
||||
"macro_distribution": _macro_distribution_payload,
|
||||
"training_volume": _training_volume_payload,
|
||||
"training_type_distribution": lambda pid, d: build_training_type_distribution_chart_payload(
|
||||
pid, min(max(d, 7), 90)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def fetch_chart_payload(chart_id: str, profile_id: str, window_days: int) -> dict[str, Any]:
|
||||
fn = _CHART_FETCHERS.get(chart_id)
|
||||
if not fn:
|
||||
raise ValueError(f"Unbekanntes chart_id: {chart_id}")
|
||||
return fn(profile_id, window_days)
|
||||
|
||||
|
||||
CHART_CATALOG_FOR_API: list[dict[str, Any]] = [
|
||||
{"id": "weight_trend", "title": "Gewichtstrend", "default_window_days": 90, "window_max": 365},
|
||||
{"id": "energy_balance", "title": "Energiebilanz", "default_window_days": 28, "window_max": 90},
|
||||
{"id": "macro_distribution", "title": "Makroverteilung (Ø)", "default_window_days": 28, "window_max": 90},
|
||||
{"id": "training_volume", "title": "Trainingsvolumen (Wochen)", "default_window_days": 84, "window_max": 365},
|
||||
{
|
||||
"id": "training_type_distribution",
|
||||
"title": "Trainingsart-Verteilung",
|
||||
"default_window_days": 28,
|
||||
"window_max": 90,
|
||||
},
|
||||
]
|
||||
91
backend/report_chart_plotting.py
Normal file
91
backend/report_chart_plotting.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"""Chart.js-ähnliche Payloads → PNG (Matplotlib). Von PDF- und Bundle-Rendering gemeinsam genutzt."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
|
||||
def _color_to_rgb(hex_or_rgba: str) -> tuple[float, float, float]:
|
||||
s = (hex_or_rgba or "#333333").strip()
|
||||
if s.startswith("#") and len(s) >= 7:
|
||||
try:
|
||||
r = int(s[1:3], 16) / 255.0
|
||||
g = int(s[3:5], 16) / 255.0
|
||||
b = int(s[5:7], 16) / 255.0
|
||||
return (r, g, b)
|
||||
except ValueError:
|
||||
pass
|
||||
return (0.12, 0.62, 0.46)
|
||||
|
||||
|
||||
def chart_payload_to_png(payload: dict[str, Any], fig_width_in: float = 6.2, fig_height_in: float = 3.4) -> bytes:
|
||||
chart_type = payload.get("chart_type") or "line"
|
||||
data = payload.get("data") or {}
|
||||
labels = data.get("labels") or []
|
||||
datasets = data.get("datasets") or []
|
||||
|
||||
fig, ax = plt.subplots(figsize=(fig_width_in, fig_height_in), dpi=120)
|
||||
ax.set_facecolor("#fafaf9")
|
||||
fig.patch.set_facecolor("#ffffff")
|
||||
|
||||
if chart_type == "pie" and datasets:
|
||||
ds0 = datasets[0]
|
||||
values = ds0.get("data") or []
|
||||
colors = ds0.get("backgroundColor") or ["#1D9E75", "#378ADD", "#D85A30"]
|
||||
if labels and values and len(labels) == len(values):
|
||||
ax.pie(values, labels=labels, autopct="%1.0f%%", colors=colors[: len(values)], startangle=90)
|
||||
ax.axis("equal")
|
||||
else:
|
||||
ax.text(0.5, 0.5, "Keine Daten", ha="center", va="center", transform=ax.transAxes)
|
||||
|
||||
elif chart_type in ("line", "bar", "scatter") and datasets:
|
||||
x = range(len(labels)) if labels else []
|
||||
for i, ds in enumerate(datasets):
|
||||
y = ds.get("data") or []
|
||||
if not y:
|
||||
continue
|
||||
lab = ds.get("label") or f"Serie {i + 1}"
|
||||
col = _color_to_rgb(str(ds.get("borderColor") or ds.get("backgroundColor") or "#1D9E75"))
|
||||
if chart_type == "bar":
|
||||
yv = y[: len(labels)] if labels else y
|
||||
bg = ds.get("backgroundColor")
|
||||
if isinstance(bg, list):
|
||||
cols = [_color_to_rgb(str(c)) for c in bg[: len(yv)]]
|
||||
else:
|
||||
cols = [_color_to_rgb(str(bg or "#1D9E75"))] * len(yv)
|
||||
ax.bar(list(range(len(yv))), yv, label=lab, color=cols[: len(yv)], alpha=0.88)
|
||||
else:
|
||||
ax.plot(
|
||||
list(x)[: len(y)],
|
||||
y,
|
||||
label=lab,
|
||||
color=col,
|
||||
linewidth=1.6,
|
||||
marker="o",
|
||||
markersize=2,
|
||||
)
|
||||
if labels and chart_type != "bar":
|
||||
step = max(1, len(labels) // 8)
|
||||
ax.set_xticks(list(x)[::step])
|
||||
ax.set_xticklabels([labels[j] for j in range(0, len(labels), step)], rotation=25, fontsize=7)
|
||||
elif labels and chart_type == "bar":
|
||||
ax.set_xticks(list(x))
|
||||
ax.set_xticklabels(labels, rotation=30, fontsize=7)
|
||||
ax.legend(loc="upper right", fontsize=7)
|
||||
ax.grid(True, alpha=0.25)
|
||||
ax.set_xmargin(0.02)
|
||||
|
||||
else:
|
||||
ax.text(0.5, 0.5, "Diagrammtyp nicht unterstützt oder leer", ha="center", va="center", transform=ax.transAxes)
|
||||
|
||||
fig.tight_layout()
|
||||
buf = io.BytesIO()
|
||||
fig.savefig(buf, format="png", bbox_inches="tight", facecolor=fig.get_facecolor())
|
||||
plt.close(fig)
|
||||
buf.seek(0)
|
||||
return buf.read()
|
||||
130
backend/report_pdf_render.py
Normal file
130
backend/report_pdf_render.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""
|
||||
PDF-Bericht aus ReportProfilePayload: ReportLab für Text/Layout, Matplotlib für Chart-Payloads.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
from typing import Any
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import getSampleStyleSheet
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.platypus import Image as RLImage
|
||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
|
||||
|
||||
from db import get_cursor, get_db
|
||||
from report_chart_fetch import fetch_chart_payload
|
||||
from report_chart_plotting import chart_payload_to_png
|
||||
from report_profile_schema import (
|
||||
AiInsightBlock,
|
||||
ChartBlock,
|
||||
ReportProfilePayload,
|
||||
SectionBlock,
|
||||
VizBundleBlock,
|
||||
)
|
||||
from report_viz_bundle_pdf import append_viz_bundle_to_story
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CONTENT_TRUNCATE = 12000
|
||||
|
||||
|
||||
def _insight_text(profile_id: str, insight_id: str | None) -> tuple[str, str]:
|
||||
"""Returns (heading, body_text)."""
|
||||
if not insight_id:
|
||||
return (
|
||||
"KI-Auswertung",
|
||||
"(Noch keine Auswahl — in einer späteren Version kannst du hier eine gespeicherte KI-Analyse "
|
||||
"verknüpfen.)",
|
||||
)
|
||||
try:
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"SELECT scope, content, created FROM ai_insights WHERE id = %s AND profile_id = %s",
|
||||
(insight_id, profile_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return ("KI-Auswertung", "Eintrag nicht gefunden oder keine Berechtigung.")
|
||||
scope = row.get("scope") or "Analyse"
|
||||
content = row.get("content") or ""
|
||||
if len(content) > _CONTENT_TRUNCATE:
|
||||
content = content[:_CONTENT_TRUNCATE] + "\n\n[… gekürzt …]"
|
||||
created = row.get("created")
|
||||
sub = f"{scope}" + (f" · {created}" if created else "")
|
||||
return (sub, content)
|
||||
except Exception as e:
|
||||
logger.warning("report pdf insight load failed: %s", e)
|
||||
return ("KI-Auswertung", "Fehler beim Laden des Eintrags.")
|
||||
|
||||
|
||||
def build_structured_report_pdf(
|
||||
*,
|
||||
profile_id: str,
|
||||
profile_name: str,
|
||||
payload: ReportProfilePayload,
|
||||
) -> bytes:
|
||||
"""Vollständiges PDF als Bytes (A4)."""
|
||||
buf = io.BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buf,
|
||||
pagesize=A4,
|
||||
leftMargin=14 * mm,
|
||||
rightMargin=14 * mm,
|
||||
topMargin=16 * mm,
|
||||
bottomMargin=16 * mm,
|
||||
)
|
||||
styles = getSampleStyleSheet()
|
||||
story: list[Any] = []
|
||||
|
||||
title = (payload.document_title or "").strip() or f"{profile_name} – Bericht"
|
||||
story.append(Paragraph(escape(title), styles["Title"]))
|
||||
story.append(Spacer(1, 6 * mm))
|
||||
|
||||
for block in payload.blocks:
|
||||
if isinstance(block, SectionBlock):
|
||||
story.append(Spacer(1, 4 * mm))
|
||||
story.append(Paragraph(escape(block.title), styles["Heading2"]))
|
||||
story.append(Spacer(1, 2 * mm))
|
||||
elif isinstance(block, VizBundleBlock):
|
||||
append_viz_bundle_to_story(story, styles, profile_id, block.bundle_id, block.config)
|
||||
elif isinstance(block, ChartBlock):
|
||||
try:
|
||||
chart = fetch_chart_payload(block.chart_id, profile_id, block.window_days)
|
||||
except Exception as e:
|
||||
logger.warning("chart fetch %s: %s", block.chart_id, e)
|
||||
story.append(Paragraph(f"Diagramm {block.chart_id}: Fehler bei Daten.", styles["Normal"]))
|
||||
continue
|
||||
meta = chart.get("metadata") or {}
|
||||
if meta.get("confidence") == "insufficient":
|
||||
msg = meta.get("message") or "Nicht genug Daten"
|
||||
story.append(Paragraph(f"<i>{block.chart_id}</i>: {msg}", styles["Normal"]))
|
||||
story.append(Spacer(1, 3 * mm))
|
||||
continue
|
||||
try:
|
||||
png = chart_payload_to_png(chart)
|
||||
img_buf = io.BytesIO(png)
|
||||
iw = 170 * mm
|
||||
ih = 85 * mm
|
||||
story.append(RLImage(img_buf, width=iw, height=ih))
|
||||
except Exception as e:
|
||||
logger.warning("chart render %s: %s", block.chart_id, e)
|
||||
story.append(Paragraph(f"Diagramm {block.chart_id}: Darstellung fehlgeschlagen.", styles["Normal"]))
|
||||
story.append(Spacer(1, 4 * mm))
|
||||
elif isinstance(block, AiInsightBlock):
|
||||
heading, body = _insight_text(profile_id, block.insight_id)
|
||||
if block.title.strip():
|
||||
story.append(Paragraph(escape(block.title), styles["Heading3"]))
|
||||
else:
|
||||
story.append(Paragraph(escape(heading), styles["Heading3"]))
|
||||
for para in body.split("\n\n"):
|
||||
p = (para or "").strip()
|
||||
if p:
|
||||
story.append(Paragraph(escape(p), styles["BodyText"]))
|
||||
story.append(Spacer(1, 4 * mm))
|
||||
|
||||
doc.build(story)
|
||||
return buf.getvalue()
|
||||
126
backend/report_profile_schema.py
Normal file
126
backend/report_profile_schema.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""
|
||||
Konfigurierbarer PDF-Bericht v1: Payload-Schema (unabhängig vom Dashboard-Layout).
|
||||
|
||||
Block-Typen:
|
||||
- section: Überschrift
|
||||
- viz_bundle: Layer-2b-Ver bundles (KPIs, Text, Charts) — gleiche Config wie Dashboard
|
||||
- chart: diagramm via report_chart_fetch (chart_id + window_days)
|
||||
- ai_insight: optional insight_id (UUID), sonst Platzhalter für spätere Auswahl
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, Union
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from dashboard_widget_config import validate_widget_entry_config
|
||||
|
||||
ALLOWED_CHART_IDS: frozenset[str] = frozenset(
|
||||
{
|
||||
"weight_trend",
|
||||
"energy_balance",
|
||||
"macro_distribution",
|
||||
"training_volume",
|
||||
"training_type_distribution",
|
||||
}
|
||||
)
|
||||
|
||||
_MAX_BLOCKS = 32
|
||||
|
||||
ALLOWED_VIZ_BUNDLE_IDS: frozenset[str] = frozenset(
|
||||
{
|
||||
"body_history_viz",
|
||||
"nutrition_history_viz",
|
||||
"fitness_history_viz",
|
||||
"recovery_history_viz",
|
||||
"history_overview_viz",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SectionBlock(BaseModel):
|
||||
type: Literal["section"] = "section"
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
class ChartBlock(BaseModel):
|
||||
type: Literal["chart"] = "chart"
|
||||
chart_id: str = Field(min_length=1, max_length=64)
|
||||
window_days: int = Field(default=28, ge=7, le=365)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _chart_known(self) -> ChartBlock:
|
||||
if self.chart_id not in ALLOWED_CHART_IDS:
|
||||
raise ValueError(f"Unbekanntes chart_id: {self.chart_id!r} (erlaubt: {sorted(ALLOWED_CHART_IDS)})")
|
||||
return self
|
||||
|
||||
|
||||
class AiInsightBlock(BaseModel):
|
||||
type: Literal["ai_insight"] = "ai_insight"
|
||||
title: str = Field(default="", max_length=200)
|
||||
insight_id: str | None = Field(default=None, max_length=48)
|
||||
|
||||
|
||||
class VizBundleBlock(BaseModel):
|
||||
"""Gleiche Layer-2b-Bundles wie im Dashboard; config wie validate_widget_entry_config."""
|
||||
|
||||
type: Literal["viz_bundle"] = "viz_bundle"
|
||||
bundle_id: str = Field(min_length=1, max_length=64)
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _bundle_config(self) -> VizBundleBlock:
|
||||
if self.bundle_id not in ALLOWED_VIZ_BUNDLE_IDS:
|
||||
raise ValueError(
|
||||
f"Unbekanntes bundle_id: {self.bundle_id!r} (erlaubt: {sorted(ALLOWED_VIZ_BUNDLE_IDS)})"
|
||||
)
|
||||
self.config = validate_widget_entry_config(self.bundle_id, self.config)
|
||||
return self
|
||||
|
||||
|
||||
class ReportProfilePayload(BaseModel):
|
||||
version: Literal[1] = 1
|
||||
document_title: str = Field(default="", max_length=120)
|
||||
blocks: list[Union[SectionBlock, ChartBlock, AiInsightBlock, VizBundleBlock]]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _blocks_limit(self) -> ReportProfilePayload:
|
||||
if len(self.blocks) > _MAX_BLOCKS:
|
||||
raise ValueError(f"Maximal {_MAX_BLOCKS} Blöcke erlaubt")
|
||||
if not self.blocks:
|
||||
raise ValueError("Mindestens ein Block erforderlich")
|
||||
return self
|
||||
|
||||
def to_stored_dict(self) -> dict:
|
||||
return {
|
||||
"version": self.version,
|
||||
"document_title": self.document_title,
|
||||
"blocks": [b.model_dump(mode="json") for b in self.blocks],
|
||||
}
|
||||
|
||||
|
||||
def default_report_profile_dict() -> dict:
|
||||
"""Standard-Bericht beim ersten Zugriff (ohne DB-Zeile)."""
|
||||
p = ReportProfilePayload(
|
||||
document_title="",
|
||||
blocks=[
|
||||
SectionBlock(title="Verlauf — Körper"),
|
||||
VizBundleBlock(bundle_id="body_history_viz", config={"chart_days": 90}),
|
||||
SectionBlock(title="Verlauf — Ernährung"),
|
||||
VizBundleBlock(bundle_id="nutrition_history_viz", config={"chart_days": 90}),
|
||||
SectionBlock(title="Verlauf — Fitness"),
|
||||
VizBundleBlock(bundle_id="fitness_history_viz", config={"chart_days": 90}),
|
||||
SectionBlock(title="Verlauf — Erholung"),
|
||||
VizBundleBlock(bundle_id="recovery_history_viz", config={"chart_days": 90}),
|
||||
SectionBlock(title="Gesamtübersicht"),
|
||||
VizBundleBlock(bundle_id="history_overview_viz", config={"chart_days": 90}),
|
||||
],
|
||||
)
|
||||
return p.to_stored_dict()
|
||||
|
||||
|
||||
def parse_report_profile(raw: dict | None) -> ReportProfilePayload:
|
||||
if raw is None or raw == {}:
|
||||
return ReportProfilePayload.model_validate(default_report_profile_dict())
|
||||
return ReportProfilePayload.model_validate(raw)
|
||||
|
||||
386
backend/report_viz_bundle_pdf.py
Normal file
386
backend/report_viz_bundle_pdf.py
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
"""
|
||||
Layer-2b Verlauf-Bundles → PDF-Abschnitte (KPIs + eingebettete Chart-Payloads).
|
||||
|
||||
Gleiche Datenquellen und Config-Validierung wie Dashboard-Widgets (dashboard_widget_config).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.platypus import Image as RLImage
|
||||
from reportlab.platypus import Paragraph, Spacer
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
from dashboard_widget_config import validate_widget_entry_config
|
||||
from data_layer.body_viz import get_body_history_viz_bundle
|
||||
from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle
|
||||
from data_layer.history_overview_viz import get_history_overview_viz_bundle
|
||||
from data_layer.nutrition_viz import get_nutrition_history_viz_bundle
|
||||
from data_layer.recovery_viz import get_recovery_dashboard_viz_bundle
|
||||
from data_layer.utils import safe_float
|
||||
from report_chart_plotting import chart_payload_to_png
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BUNDLE_HEADINGS: dict[str, str] = {
|
||||
"body_history_viz": "Körper — Kennwerte & Verlauf",
|
||||
"nutrition_history_viz": "Ernährung — Kennwerte & Charts",
|
||||
"fitness_history_viz": "Fitness / Training",
|
||||
"recovery_history_viz": "Erholung & Vitalwerte",
|
||||
"history_overview_viz": "Gesamtübersicht & Korrelationen",
|
||||
}
|
||||
|
||||
|
||||
def _add_chart_to_story(story: list, styles: dict, payload: dict[str, Any], caption: str | None = None) -> None:
|
||||
meta = payload.get("metadata") or {}
|
||||
if meta.get("confidence") == "insufficient":
|
||||
msg = escape(meta.get("message") or "Keine Daten")
|
||||
story.append(Paragraph(f"<i>{msg}</i>", styles["Normal"]))
|
||||
story.append(Spacer(1, 2 * mm))
|
||||
return
|
||||
if caption:
|
||||
story.append(Paragraph(f"<b>{escape(caption)}</b>", styles["Normal"]))
|
||||
try:
|
||||
png = chart_payload_to_png(payload)
|
||||
story.append(RLImage(io.BytesIO(png), width=170 * mm, height=85 * mm))
|
||||
except Exception as e:
|
||||
logger.warning("bundle chart png: %s", e)
|
||||
story.append(Paragraph("Diagramm konnte nicht gerendert werden.", styles["Normal"]))
|
||||
story.append(Spacer(1, 4 * mm))
|
||||
|
||||
|
||||
def _append_interpretation_tiles(story: list, styles: dict, tiles: list[dict[str, Any]]) -> None:
|
||||
if not tiles:
|
||||
return
|
||||
story.append(Paragraph("<b>Einschätzungen</b>", styles["Heading4"]))
|
||||
for t in tiles:
|
||||
cat = escape(str(t.get("category") or t.get("title") or "—"))
|
||||
title = t.get("title")
|
||||
detail = t.get("detail")
|
||||
val = t.get("value")
|
||||
parts = [f"<b>{cat}</b>"]
|
||||
if title and str(title) != str(cat):
|
||||
parts.append(escape(str(title)))
|
||||
if val is not None and val != "":
|
||||
parts.append(f"({escape(str(val))})")
|
||||
story.append(Paragraph(" — ".join(parts), styles["Normal"]))
|
||||
if detail:
|
||||
story.append(Paragraph(escape(str(detail)[:500]), styles["BodyText"]))
|
||||
story.append(Spacer(1, 3 * mm))
|
||||
|
||||
|
||||
def _append_kpi_tiles_fitness_nutreco(story: list, styles: dict, tiles: list[dict[str, Any]], compact: bool) -> None:
|
||||
if not tiles:
|
||||
return
|
||||
use = tiles[:4] if compact else tiles
|
||||
story.append(Paragraph("<b>KPI-Kacheln</b>", styles["Heading4"]))
|
||||
for t in use:
|
||||
cat = escape(str(t.get("category") or t.get("title") or "—"))
|
||||
val = escape(str(t.get("value") or "—"))
|
||||
sub = t.get("sublabel") or t.get("body")
|
||||
line = f"• <b>{cat}</b>: {val}"
|
||||
if sub:
|
||||
line += f" — {escape(str(sub)[:180])}"
|
||||
story.append(Paragraph(line, styles["Normal"]))
|
||||
story.append(Spacer(1, 3 * mm))
|
||||
|
||||
|
||||
def _append_insights_lines(story: list, styles: dict, insights: list[dict[str, Any]], label: str) -> None:
|
||||
if not insights:
|
||||
return
|
||||
story.append(Paragraph(f"<b>{escape(label)}</b>", styles["Heading4"]))
|
||||
for item in insights:
|
||||
title = item.get("title") or item.get("heading")
|
||||
body = item.get("body") or item.get("text")
|
||||
if title:
|
||||
story.append(Paragraph(escape(str(title)), styles["Normal"]))
|
||||
if body:
|
||||
story.append(Paragraph(escape(str(body)[:600]), styles["BodyText"]))
|
||||
story.append(Spacer(1, 2 * mm))
|
||||
|
||||
|
||||
def _weight_series_payload(bundle_weight: dict[str, Any]) -> dict[str, Any] | None:
|
||||
series = bundle_weight.get("series") or []
|
||||
if len(series) < 2:
|
||||
return None
|
||||
labels = [str(p.get("date") or "") for p in series]
|
||||
datasets: list[dict[str, Any]] = [
|
||||
{
|
||||
"label": "Gewicht (kg)",
|
||||
"data": [safe_float(p.get("weight")) for p in series],
|
||||
"borderColor": "#1D9E75",
|
||||
}
|
||||
]
|
||||
if any(p.get("avg7") is not None for p in series):
|
||||
datasets.append(
|
||||
{
|
||||
"label": "Ø 7T",
|
||||
"data": [safe_float(p.get("avg7")) for p in series],
|
||||
"borderColor": "#378ADD",
|
||||
}
|
||||
)
|
||||
return {"chart_type": "line", "data": {"labels": labels, "datasets": datasets}, "metadata": {"confidence": "high"}}
|
||||
|
||||
|
||||
def _line_payload_from_points(
|
||||
points: list[dict[str, Any]],
|
||||
x_key: str,
|
||||
y_key: str,
|
||||
label: str,
|
||||
) -> dict[str, Any] | None:
|
||||
if len(points) < 2:
|
||||
return None
|
||||
labels = [str(p.get(x_key) or "") for p in points]
|
||||
ys = [safe_float(p.get(y_key)) for p in points]
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"datasets": [{"label": label, "data": ys, "borderColor": "#1D9E75"}],
|
||||
},
|
||||
"metadata": {"confidence": "high"},
|
||||
}
|
||||
|
||||
|
||||
def _append_body_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None:
|
||||
days = int(cfg.get("chart_days") or 30)
|
||||
bundle = get_body_history_viz_bundle(profile_id, days)
|
||||
story.append(Paragraph(escape(BUNDLE_HEADINGS["body_history_viz"]), styles["Heading2"]))
|
||||
if bundle.get("confidence") == "insufficient":
|
||||
story.append(Paragraph(escape(bundle.get("message") or "Keine Körperdaten"), styles["Normal"]))
|
||||
story.append(Spacer(1, 4 * mm))
|
||||
return
|
||||
summ = bundle.get("summary") or {}
|
||||
if summ:
|
||||
w = summ.get("weight_kg")
|
||||
bf = summ.get("body_fat_pct")
|
||||
parts = []
|
||||
if w is not None:
|
||||
parts.append(f"Gewicht: {w} kg")
|
||||
if bf is not None:
|
||||
parts.append(f"KF%: {bf}")
|
||||
if parts:
|
||||
story.append(Paragraph(escape(" · ".join(parts)), styles["Normal"]))
|
||||
story.append(Spacer(1, 2 * mm))
|
||||
if cfg.get("show_kpis", True):
|
||||
_append_interpretation_tiles(story, styles, bundle.get("interpretation_tiles") or [])
|
||||
w = bundle.get("weight") or {}
|
||||
if cfg.get("show_weight_chart", True):
|
||||
pl = _weight_series_payload(w)
|
||||
if pl:
|
||||
_add_chart_to_story(story, styles, pl, "Gewicht")
|
||||
cal = bundle.get("caliper") or {}
|
||||
if cfg.get("show_body_fat_chart", False):
|
||||
ser = cal.get("series") or []
|
||||
pts = [{"date": p.get("date"), "y": p.get("body_fat_pct")} for p in ser if p.get("body_fat_pct") is not None]
|
||||
pl = _line_payload_from_points(pts, "date", "y", "KF %")
|
||||
if pl:
|
||||
_add_chart_to_story(story, styles, pl, "Körperfett (Caliper)")
|
||||
circ = bundle.get("circumference") or {}
|
||||
if cfg.get("show_proportion_chart", False):
|
||||
prop = circ.get("proportion_series") or []
|
||||
pts = [{"date": p.get("date"), "y": p.get("v_taper_cm")} for p in prop if p.get("v_taper_cm") is not None]
|
||||
pl = _line_payload_from_points(pts, "date", "y", "V-Taper (cm)")
|
||||
if pl:
|
||||
_add_chart_to_story(story, styles, pl, "Proportion (Brust–Taille)")
|
||||
if cfg.get("show_circumference_index_chart", False):
|
||||
idx = circ.get("index_series") or []
|
||||
if len(idx) >= 2:
|
||||
labels = [str(p.get("date") or "") for p in idx]
|
||||
ds: list[dict[str, Any]] = []
|
||||
for key, lab, col in (
|
||||
("waist_idx", "Taille-Index", "#D85A30"),
|
||||
("chest_idx", "Brust-Index", "#1D9E75"),
|
||||
("belly_idx", "Bauch-Index", "#378ADD"),
|
||||
):
|
||||
ys = [safe_float(p.get(key)) for p in idx]
|
||||
if any(v is not None for v in ys):
|
||||
ds.append({"label": lab, "data": ys, "borderColor": col})
|
||||
if ds:
|
||||
pl = {"chart_type": "line", "data": {"labels": labels, "datasets": ds}, "metadata": {"confidence": "high"}}
|
||||
_add_chart_to_story(story, styles, pl, "Umfang-Indizes")
|
||||
story.append(Spacer(1, 2 * mm))
|
||||
|
||||
|
||||
def _append_nutrition_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None:
|
||||
days = int(cfg.get("chart_days") or 30)
|
||||
bundle = get_nutrition_history_viz_bundle(profile_id, days)
|
||||
story.append(Paragraph(escape(BUNDLE_HEADINGS["nutrition_history_viz"]), styles["Heading2"]))
|
||||
if not bundle.get("has_nutrition_entries"):
|
||||
story.append(Paragraph(escape(bundle.get("message") or "Keine Ernährungsdaten"), styles["Normal"]))
|
||||
story.append(Spacer(1, 4 * mm))
|
||||
return
|
||||
compact = cfg.get("kpi_detail") == "compact"
|
||||
if cfg.get("show_kpis", True):
|
||||
_append_kpi_tiles_fitness_nutreco(story, styles, bundle.get("kpi_tiles") or [], compact)
|
||||
if cfg.get("show_heuristics", False):
|
||||
h = bundle.get("nutrition_correlation_heuristics") or []
|
||||
for item in h:
|
||||
t = item.get("text") or item.get("title")
|
||||
if t:
|
||||
story.append(Paragraph(f"• {escape(str(t))}", styles["Normal"]))
|
||||
story.append(Spacer(1, 2 * mm))
|
||||
charts = bundle.get("chart_payloads") or {}
|
||||
if cfg.get("show_calorie_balance_chart", False) or cfg.get("show_energy_protein_charts", False):
|
||||
pl = charts.get("energy_balance")
|
||||
if pl:
|
||||
_add_chart_to_story(story, styles, pl, "Energiebilanz")
|
||||
if cfg.get("show_energy_protein_charts", False) or cfg.get("show_protein_lean_chart", False):
|
||||
pl = charts.get("protein_adequacy")
|
||||
if pl:
|
||||
_add_chart_to_story(story, styles, pl, "Protein-Adäquanz")
|
||||
pl2 = charts.get("nutrition_adherence")
|
||||
if pl2:
|
||||
_add_chart_to_story(story, styles, pl2, "Ernährungs-Adherence")
|
||||
if cfg.get("show_macro_distribution_pair", False) or cfg.get("show_macro_daily_bars", False):
|
||||
wm = bundle.get("weekly_macro_chart")
|
||||
if isinstance(wm, dict) and wm.get("chart_type"):
|
||||
_add_chart_to_story(story, styles, wm, "Makros (wöchentlich)")
|
||||
kw = bundle.get("kcal_vs_weight") or {}
|
||||
if cfg.get("show_kcal_vs_weight", False) and kw.get("points"):
|
||||
pts = kw["points"]
|
||||
if pts:
|
||||
pl = _line_payload_from_points(pts, "date", "kcal", "kcal")
|
||||
if pl:
|
||||
_add_chart_to_story(story, styles, pl, "Kalorien vs. Zeit")
|
||||
story.append(Spacer(1, 2 * mm))
|
||||
|
||||
|
||||
def _append_fitness_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None:
|
||||
days = int(cfg.get("chart_days") or 30)
|
||||
bundle = get_fitness_dashboard_viz_bundle(profile_id, days)
|
||||
story.append(Paragraph(escape(BUNDLE_HEADINGS["fitness_history_viz"]), styles["Heading2"]))
|
||||
if not bundle.get("has_activity_entries"):
|
||||
story.append(Paragraph(escape(bundle.get("message") or "Keine Aktivitätsdaten"), styles["Normal"]))
|
||||
story.append(Spacer(1, 4 * mm))
|
||||
return
|
||||
compact = cfg.get("kpi_detail") == "compact"
|
||||
if cfg.get("show_kpis", True):
|
||||
_append_kpi_tiles_fitness_nutreco(story, styles, bundle.get("kpi_tiles") or [], compact)
|
||||
if cfg.get("show_progress_insights", False):
|
||||
_append_insights_lines(story, styles, bundle.get("progress_insights") or [], "Einschätzungen")
|
||||
charts = bundle.get("charts") or {}
|
||||
if cfg.get("show_chart_training_volume", True) and charts.get("training_volume"):
|
||||
_add_chart_to_story(story, styles, charts["training_volume"], "Trainingsvolumen")
|
||||
if cfg.get("show_chart_training_type_distribution", True) and charts.get("training_type_distribution"):
|
||||
_add_chart_to_story(story, styles, charts["training_type_distribution"], "Trainingsarten")
|
||||
if cfg.get("show_chart_quality_sessions", False) and charts.get("quality_sessions"):
|
||||
_add_chart_to_story(story, styles, charts["quality_sessions"], "Qualitätssessions")
|
||||
if cfg.get("show_chart_load_monitoring", False) and charts.get("load_monitoring"):
|
||||
_add_chart_to_story(story, styles, charts["load_monitoring"], "Last / ACWR")
|
||||
story.append(Spacer(1, 2 * mm))
|
||||
|
||||
|
||||
def _append_recovery_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None:
|
||||
days = int(cfg.get("chart_days") or 30)
|
||||
bundle = get_recovery_dashboard_viz_bundle(profile_id, days)
|
||||
story.append(Paragraph(escape(BUNDLE_HEADINGS["recovery_history_viz"]), styles["Heading2"]))
|
||||
if not bundle.get("has_recovery_data"):
|
||||
story.append(Paragraph(escape(bundle.get("message") or "Keine Erholungsdaten"), styles["Normal"]))
|
||||
story.append(Spacer(1, 4 * mm))
|
||||
return
|
||||
compact = cfg.get("kpi_detail") == "compact"
|
||||
if cfg.get("show_kpis", True):
|
||||
_append_kpi_tiles_fitness_nutreco(story, styles, bundle.get("kpi_tiles") or [], compact)
|
||||
if cfg.get("show_progress_insights", False):
|
||||
_append_insights_lines(story, styles, bundle.get("progress_insights") or [], "Einschätzungen")
|
||||
charts = bundle.get("charts") or {}
|
||||
if cfg.get("show_chart_recovery_score", True) and charts.get("recovery_score"):
|
||||
_add_chart_to_story(story, styles, charts["recovery_score"], "Recovery-Score")
|
||||
if cfg.get("show_chart_hrv_rhr", True) and charts.get("hrv_rhr"):
|
||||
_add_chart_to_story(story, styles, charts["hrv_rhr"], "HRV / RHR")
|
||||
if cfg.get("show_chart_sleep_quality", True) and charts.get("sleep_duration_quality"):
|
||||
_add_chart_to_story(story, styles, charts["sleep_duration_quality"], "Schlaf Dauer & Qualität")
|
||||
if cfg.get("show_chart_sleep_debt", False) and charts.get("sleep_debt"):
|
||||
_add_chart_to_story(story, styles, charts["sleep_debt"], "Schlafschuld")
|
||||
if cfg.get("show_vitals_extra_trends", False):
|
||||
if charts.get("vital_signs_matrix"):
|
||||
_add_chart_to_story(story, styles, charts["vital_signs_matrix"], "Vital-Matrix")
|
||||
if charts.get("vitals_history"):
|
||||
_add_chart_to_story(story, styles, charts["vitals_history"], "Vital-Trends")
|
||||
story.append(Spacer(1, 2 * mm))
|
||||
|
||||
|
||||
def _append_history_overview_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None:
|
||||
days = int(cfg.get("chart_days") or 30)
|
||||
bundle = get_history_overview_viz_bundle(profile_id, days)
|
||||
story.append(Paragraph(escape(BUNDLE_HEADINGS["history_overview_viz"]), styles["Heading2"]))
|
||||
sect_keys = {
|
||||
"body": cfg.get("show_section_body", True),
|
||||
"nutrition": cfg.get("show_section_nutrition", True),
|
||||
"fitness": cfg.get("show_section_fitness", True),
|
||||
"recovery": cfg.get("show_section_recovery", True),
|
||||
}
|
||||
for sec in bundle.get("sections") or []:
|
||||
sid = sec.get("id")
|
||||
if not sect_keys.get(str(sid), True):
|
||||
continue
|
||||
title = escape(str(sec.get("title") or sid))
|
||||
line = escape(str(sec.get("summary_line") or ""))
|
||||
story.append(Paragraph(f"<b>{title}</b>: {line}", styles["Normal"]))
|
||||
for it in sec.get("interpretation_short") or []:
|
||||
t = it.get("title") if isinstance(it, dict) else None
|
||||
if t:
|
||||
story.append(Paragraph(f"• {escape(str(t))}", styles["BodyText"]))
|
||||
for k in sec.get("kpi_short") or []:
|
||||
if isinstance(k, dict):
|
||||
cat = k.get("category") or k.get("title")
|
||||
val = k.get("value")
|
||||
if cat:
|
||||
story.append(Paragraph(f"• {escape(str(cat))}: {escape(str(val or ''))}", styles["BodyText"]))
|
||||
story.append(Spacer(1, 2 * mm))
|
||||
if cfg.get("show_correlation_c1_c3", True) or cfg.get("show_drivers_c4", True):
|
||||
lag = bundle.get("lag_correlations") or {}
|
||||
we = lag.get("weight_energy") or {}
|
||||
if we.get("available") and (we.get("interpretation") or we.get("label")):
|
||||
lab = escape(str(we.get("label") or "C1"))
|
||||
interp = escape(str(we.get("interpretation") or "").strip())
|
||||
if interp:
|
||||
story.append(Paragraph(f"{lab}: {interp}", styles["Normal"]))
|
||||
charts = bundle.get("chart_payloads") or {}
|
||||
if cfg.get("show_correlation_c1_c3", True):
|
||||
for key, cap in (
|
||||
("c1_weight_energy", "Korrelation Gewicht / Energie"),
|
||||
("c2_protein_lbm", "Protein / Magermasse"),
|
||||
("c3_load_vitals", "Last / Vitalwerte"),
|
||||
):
|
||||
pl = charts.get(key)
|
||||
if pl:
|
||||
_add_chart_to_story(story, styles, pl, cap)
|
||||
if cfg.get("show_drivers_c4", True):
|
||||
pl = charts.get("c4_recovery_performance")
|
||||
if pl:
|
||||
_add_chart_to_story(story, styles, pl, "Top-Treiber")
|
||||
drv = (bundle.get("lag_correlations") or {}).get("recovery_performance") or {}
|
||||
for d in (drv.get("drivers") or [])[:12]:
|
||||
if isinstance(d, dict):
|
||||
lab = d.get("label") or d.get("factor")
|
||||
val = d.get("impact") or d.get("score")
|
||||
if lab:
|
||||
story.append(Paragraph(f"• {escape(str(lab))}: {escape(str(val or ''))}", styles["Normal"]))
|
||||
story.append(Spacer(1, 2 * mm))
|
||||
|
||||
|
||||
def append_viz_bundle_to_story(
|
||||
story: list,
|
||||
styles: dict,
|
||||
profile_id: str,
|
||||
bundle_id: str,
|
||||
raw_config: dict[str, Any],
|
||||
) -> None:
|
||||
cfg = validate_widget_entry_config(bundle_id, raw_config)
|
||||
if bundle_id == "body_history_viz":
|
||||
_append_body_bundle(story, styles, profile_id, cfg)
|
||||
elif bundle_id == "nutrition_history_viz":
|
||||
_append_nutrition_bundle(story, styles, profile_id, cfg)
|
||||
elif bundle_id == "fitness_history_viz":
|
||||
_append_fitness_bundle(story, styles, profile_id, cfg)
|
||||
elif bundle_id == "recovery_history_viz":
|
||||
_append_recovery_bundle(story, styles, profile_id, cfg)
|
||||
elif bundle_id == "history_overview_viz":
|
||||
_append_history_overview_bundle(story, styles, profile_id, cfg)
|
||||
else:
|
||||
story.append(Paragraph(escape(f"Unbekanntes Bundle: {bundle_id}"), styles["Normal"]))
|
||||
|
|
@ -10,3 +10,5 @@ slowapi==0.1.9
|
|||
psycopg2-binary==2.9.9
|
||||
python-dateutil==2.9.0
|
||||
tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows
|
||||
matplotlib==3.8.4
|
||||
reportlab==4.2.0
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
Geschützter App-Bereich: Dashboard-Lab Layout (kein Produktiv-Dashboard).
|
||||
Geschützter App-Bereich: Dashboard-Layout und Widget-Katalog.
|
||||
|
||||
/api/app/dashboard-layout — nur mit Session + aktivem Profil (X-Profile-Id).
|
||||
"""
|
||||
|
|
@ -20,7 +20,7 @@ from db import get_cursor, get_db
|
|||
from routers.profiles import get_pid
|
||||
from system_dashboard_product_default import get_product_default_base_dict
|
||||
|
||||
router = APIRouter(prefix="/api/app", tags=["app-dashboard-lab"])
|
||||
router = APIRouter(prefix="/api/app", tags=["app-dashboard"])
|
||||
|
||||
|
||||
@router.get("/widgets/catalog")
|
||||
|
|
|
|||
324
backend/routers/reports.py
Normal file
324
backend/routers/reports.py
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
"""
|
||||
Strukturierter PDF-Bericht: mehrere Definitionen pro Profil, Katalog, PDF-Erzeugung.
|
||||
|
||||
PDF-Zähler: data_export (wie andere Exporte).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
from psycopg2.extras import Json
|
||||
|
||||
from auth import check_feature_access, increment_feature_usage, require_auth
|
||||
from db import get_cursor, get_db
|
||||
from feature_logger import log_feature_usage
|
||||
from report_chart_fetch import CHART_CATALOG_FOR_API
|
||||
from report_pdf_render import build_structured_report_pdf
|
||||
from report_profile_schema import (
|
||||
ALLOWED_VIZ_BUNDLE_IDS,
|
||||
ReportProfilePayload,
|
||||
default_report_profile_dict,
|
||||
parse_report_profile,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_REPORT_DEFINITIONS = 20
|
||||
|
||||
|
||||
class CreateReportDefinitionBody(BaseModel):
|
||||
name: str = Field(default="Neuer Bericht", min_length=1, max_length=120)
|
||||
|
||||
|
||||
class UpdateReportDefinitionBody(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
payload: dict | None = None
|
||||
|
||||
|
||||
class GeneratePdfRequest(BaseModel):
|
||||
definition_id: UUID | None = None
|
||||
|
||||
|
||||
def _profile_display_name(profile_id: str) -> str:
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT name FROM profiles WHERE id = %s", (profile_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return "Profil"
|
||||
return (row.get("name") or "Profil").strip() or "Profil"
|
||||
|
||||
|
||||
def _row_to_definition(row: dict) -> dict:
|
||||
pl = row.get("payload")
|
||||
if not isinstance(pl, dict):
|
||||
pl = {}
|
||||
return {
|
||||
"id": str(row["id"]),
|
||||
"name": (row.get("name") or "Bericht").strip() or "Bericht",
|
||||
"sort_order": int(row.get("sort_order") or 0),
|
||||
"updated_at": row.get("updated_at").isoformat() if row.get("updated_at") else None,
|
||||
"payload": pl,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/catalog")
|
||||
def get_reports_catalog(session: dict = Depends(require_auth)):
|
||||
"""Metadaten für UI: verfügbare Diagramme, Bundles, Zeitraumgrenzen."""
|
||||
viz_titles = {
|
||||
"body_history_viz": "Körper (Verlauf-Bundle)",
|
||||
"nutrition_history_viz": "Ernährung (Verlauf-Bundle)",
|
||||
"fitness_history_viz": "Fitness (Verlauf-Bundle)",
|
||||
"recovery_history_viz": "Erholung (Verlauf-Bundle)",
|
||||
"history_overview_viz": "Gesamtübersicht (Korrelationen)",
|
||||
}
|
||||
return {
|
||||
"catalog_version": 3,
|
||||
"chart_days": {"min": 7, "max": 90},
|
||||
"charts": CHART_CATALOG_FOR_API,
|
||||
"viz_bundles": [{"id": bid, "title": viz_titles.get(bid, bid)} for bid in sorted(ALLOWED_VIZ_BUNDLE_IDS)],
|
||||
"block_types": [
|
||||
{"id": "section", "title": "Überschrift"},
|
||||
{"id": "viz_bundle", "title": "Verlauf-Bundle (KPIs & Charts)"},
|
||||
{"id": "chart", "title": "Einzel-Diagramm (Legacy)"},
|
||||
{"id": "ai_insight", "title": "KI-Auswertung"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/definitions")
|
||||
def list_report_definitions(session: dict = Depends(require_auth)):
|
||||
pid = session["profile_id"]
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, sort_order, updated_at, payload
|
||||
FROM report_definitions
|
||||
WHERE profile_id = %s
|
||||
ORDER BY sort_order ASC, name ASC, updated_at DESC
|
||||
""",
|
||||
(pid,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
return {"definitions": [_row_to_definition(dict(r)) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/definitions")
|
||||
def create_report_definition(
|
||||
body: CreateReportDefinitionBody | None = Body(default=None),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
req = body or CreateReportDefinitionBody()
|
||||
pid = session["profile_id"]
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) AS n FROM report_definitions WHERE profile_id = %s",
|
||||
(pid,),
|
||||
)
|
||||
n = int((cur.fetchone() or {}).get("n") or 0)
|
||||
if n >= _MAX_REPORT_DEFINITIONS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Maximal {_MAX_REPORT_DEFINITIONS} Berichte erlaubt.",
|
||||
)
|
||||
|
||||
payload_dict = default_report_profile_dict()
|
||||
payload_dict["document_title"] = ""
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_so FROM report_definitions WHERE profile_id = %s",
|
||||
(pid,),
|
||||
)
|
||||
next_so = int((cur.fetchone() or {}).get("next_so") or 0)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO report_definitions (profile_id, name, payload, sort_order, updated_at)
|
||||
VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||
RETURNING id, name, sort_order, updated_at, payload
|
||||
""",
|
||||
(pid, req.name.strip(), Json(payload_dict), next_so),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=500, detail="Bericht konnte nicht angelegt werden.")
|
||||
return {"definition": _row_to_definition(dict(row))}
|
||||
|
||||
|
||||
@router.put("/definitions/{definition_id}")
|
||||
def update_report_definition(
|
||||
definition_id: UUID,
|
||||
body: UpdateReportDefinitionBody,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
pid = session["profile_id"]
|
||||
name = body.name.strip() if body.name else None
|
||||
parsed_payload: ReportProfilePayload | None = None
|
||||
if body.payload is not None:
|
||||
try:
|
||||
parsed_payload = ReportProfilePayload.model_validate(body.payload)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if parsed_payload is not None and name is not None:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE report_definitions
|
||||
SET name = %s, payload = %s, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s AND profile_id = %s
|
||||
RETURNING id, name, sort_order, updated_at, payload
|
||||
""",
|
||||
(name, Json(parsed_payload.to_stored_dict()), str(definition_id), pid),
|
||||
)
|
||||
elif parsed_payload is not None:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE report_definitions
|
||||
SET payload = %s, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s AND profile_id = %s
|
||||
RETURNING id, name, sort_order, updated_at, payload
|
||||
""",
|
||||
(Json(parsed_payload.to_stored_dict()), str(definition_id), pid),
|
||||
)
|
||||
elif name is not None:
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE report_definitions
|
||||
SET name = %s, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s AND profile_id = %s
|
||||
RETURNING id, name, sort_order, updated_at, payload
|
||||
""",
|
||||
(name, str(definition_id), pid),
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Nichts zu aktualisieren (name oder payload fehlt).")
|
||||
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Bericht nicht gefunden.")
|
||||
return {"definition": _row_to_definition(dict(row))}
|
||||
|
||||
|
||||
@router.delete("/definitions/{definition_id}")
|
||||
def delete_report_definition(definition_id: UUID, session: dict = Depends(require_auth)):
|
||||
pid = session["profile_id"]
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"DELETE FROM report_definitions WHERE id = %s AND profile_id = %s RETURNING id",
|
||||
(str(definition_id), pid),
|
||||
)
|
||||
deleted = cur.fetchone()
|
||||
conn.commit()
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Bericht nicht gefunden.")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _fetch_definition_payload(profile_id: str, definition_id: UUID | None) -> tuple[dict, str]:
|
||||
"""Returns (raw_payload_dict, report_label_for_filename)."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if definition_id is not None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT payload, name FROM report_definitions
|
||||
WHERE id = %s AND profile_id = %s
|
||||
""",
|
||||
(str(definition_id), profile_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Bericht nicht gefunden.")
|
||||
pl = row.get("payload")
|
||||
label = (row.get("name") or "Bericht").strip() or "Bericht"
|
||||
if not isinstance(pl, dict):
|
||||
raise HTTPException(status_code=400, detail="Ungültige Berichtsdaten.")
|
||||
return pl, label
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT payload, name FROM report_definitions
|
||||
WHERE profile_id = %s
|
||||
ORDER BY sort_order ASC, name ASC, updated_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Kein Bericht angelegt — bitte unter Einstellungen › PDF-Berichte einen Bericht erstellen.",
|
||||
)
|
||||
pl = row.get("payload")
|
||||
label = (row.get("name") or "Bericht").strip() or "Bericht"
|
||||
if not isinstance(pl, dict):
|
||||
raise HTTPException(status_code=400, detail="Ungültige Berichtsdaten.")
|
||||
return pl, label
|
||||
|
||||
|
||||
@router.post("/generate-pdf")
|
||||
def generate_structured_report_pdf(
|
||||
body: GeneratePdfRequest | None = Body(default=None),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
pid = session["profile_id"]
|
||||
req = body or GeneratePdfRequest()
|
||||
|
||||
access = check_feature_access(pid, "data_export")
|
||||
log_feature_usage(pid, "data_export", access, "report_generate_pdf")
|
||||
if not access["allowed"]:
|
||||
logger.warning(
|
||||
"[FEATURE-LIMIT] report pdf blocked: %s used=%s limit=%s",
|
||||
pid,
|
||||
access.get("used"),
|
||||
access.get("limit"),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
"Limit erreicht: Daten-Export nicht möglich "
|
||||
f"({access.get('used')}/{access.get('limit')})."
|
||||
),
|
||||
)
|
||||
|
||||
raw, report_label = _fetch_definition_payload(pid, req.definition_id)
|
||||
try:
|
||||
payload = parse_report_profile(raw)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Berichtsprofil ungültig: {e}")
|
||||
|
||||
profile_name = _profile_display_name(pid)
|
||||
try:
|
||||
pdf_bytes = build_structured_report_pdf(profile_id=pid, profile_name=profile_name, payload=payload)
|
||||
except Exception as e:
|
||||
logger.exception("report pdf build failed")
|
||||
raise HTTPException(status_code=500, detail=f"PDF-Erzeugung fehlgeschlagen: {e}")
|
||||
|
||||
increment_feature_usage(pid, "data_export")
|
||||
|
||||
doc_title = (payload.document_title or "").strip()
|
||||
base_label = doc_title or report_label
|
||||
safe_name = "".join(c for c in base_label if c.isalnum() or c in (" ", "-", "_")).strip() or "bericht"
|
||||
fn = f"mitai-bericht-{safe_name.replace(' ', '-')}-{datetime.now().strftime('%Y-%m-%d')}.pdf"
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="{fn}"'},
|
||||
)
|
||||
56
backend/tests/test_report_profile_schema.py
Normal file
56
backend/tests/test_report_profile_schema.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Berichtsprofil-Schema: Defaults und Validierung."""
|
||||
|
||||
from report_profile_schema import (
|
||||
ReportProfilePayload,
|
||||
default_report_profile_dict,
|
||||
parse_report_profile,
|
||||
)
|
||||
|
||||
|
||||
def test_default_profile_roundtrip():
|
||||
d = default_report_profile_dict()
|
||||
p = ReportProfilePayload.model_validate(d)
|
||||
assert p.version == 1
|
||||
assert len(p.blocks) >= 3
|
||||
|
||||
|
||||
def test_parse_empty_uses_default():
|
||||
p = parse_report_profile({})
|
||||
assert len(p.blocks) >= 1
|
||||
|
||||
|
||||
def test_chart_block_unknown_id_raises():
|
||||
import pytest
|
||||
|
||||
raw = {
|
||||
"version": 1,
|
||||
"document_title": "",
|
||||
"blocks": [{"type": "chart", "chart_id": "not_a_chart", "window_days": 28}],
|
||||
}
|
||||
with pytest.raises(Exception):
|
||||
ReportProfilePayload.model_validate(raw)
|
||||
|
||||
|
||||
def test_viz_bundle_roundtrip():
|
||||
raw = {
|
||||
"version": 1,
|
||||
"document_title": "",
|
||||
"blocks": [
|
||||
{"type": "viz_bundle", "bundle_id": "body_history_viz", "config": {"chart_days": 14}},
|
||||
],
|
||||
}
|
||||
p = ReportProfilePayload.model_validate(raw)
|
||||
assert p.blocks[0].type == "viz_bundle"
|
||||
assert p.blocks[0].config.get("chart_days") == 14
|
||||
|
||||
|
||||
def test_viz_bundle_unknown_raises():
|
||||
import pytest
|
||||
|
||||
raw = {
|
||||
"version": 1,
|
||||
"document_title": "",
|
||||
"blocks": [{"type": "viz_bundle", "bundle_id": "not_a_bundle", "config": {}}],
|
||||
}
|
||||
with pytest.raises(Exception):
|
||||
ReportProfilePayload.model_validate(raw)
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
"""
|
||||
Öffentlicher Widget-Katalog (Dashboard-Lab / später Produkt-Dashboard).
|
||||
Öffentlicher Widget-Katalog (konfigurierbare Übersicht / API).
|
||||
|
||||
Single Source für: erlaubte IDs, Standard-Reihenfolge, Anzeige-Metadaten für API/GUI.
|
||||
Frontend-Komponenten registrieren dieselben IDs lokal (siehe widgetSystem/registerPilotLabWidgets).
|
||||
Frontend-Komponenten registrieren dieselben IDs lokal (siehe widgetSystem/registerDashboardWidgets.js, Funktion ensureDashboardWidgetsRegistered).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -149,6 +149,12 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
|||
"description": "Pipeline starten + Gesamt-Insight; Feature ai_pipeline",
|
||||
"requires_feature": "ai_pipeline",
|
||||
},
|
||||
{
|
||||
"id": "report_export",
|
||||
"title": "Übersicht als Bild-PDF",
|
||||
"description": "Raster-PDF der Startübersicht (html2canvas); für strukturierten Datenbericht siehe Einstellungen. Optional document_title, subtitle, capture_scale; data_export",
|
||||
"requires_feature": "data_export",
|
||||
},
|
||||
]
|
||||
|
||||
DEFAULT_LAB_WIDGET_IDS: frozenset[str] = frozenset(
|
||||
|
|
|
|||
6
frontend/package-lock.json
generated
6
frontend/package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
|||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.11",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^2.5.1",
|
||||
"jspdf-autotable": "^3.8.2",
|
||||
"lucide-react": "^0.383.0",
|
||||
|
|
@ -3147,7 +3148,6 @@
|
|||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
|
|
@ -3418,7 +3418,6 @@
|
|||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
|
|
@ -4454,7 +4453,6 @@
|
|||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
|
|
@ -6320,7 +6318,6 @@
|
|||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
|
|
@ -6591,7 +6588,6 @@
|
|||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.11",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^2.5.1",
|
||||
"jspdf-autotable": "^3.8.2",
|
||||
"lucide-react": "^0.383.0",
|
||||
|
|
|
|||
|
|
@ -25,9 +25,7 @@ import Analysis from './pages/Analysis'
|
|||
import SettingsPage from './pages/SettingsPage'
|
||||
import SettingsShell from './layouts/SettingsShell'
|
||||
import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage'
|
||||
import PilotVizPage from './pages/PilotVizPage'
|
||||
import DashboardLabPage from './pages/DashboardLabPage'
|
||||
import DashboardConfigurePage from './pages/DashboardConfigurePage'
|
||||
import ReportConfigurePage from './pages/ReportConfigurePage'
|
||||
import GuidePage from './pages/GuidePage'
|
||||
import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
|
||||
import AdminFeaturesPage from './pages/AdminFeaturesPage'
|
||||
|
|
@ -243,6 +241,7 @@ function AppShell() {
|
|||
<Route index element={<SettingsPage />} />
|
||||
<Route path="reference-values" element={<ProfileReferenceValuesPage />} />
|
||||
<Route path="dashboard-layout" element={<DashboardConfigurePage />} />
|
||||
<Route path="reports" element={<ReportConfigurePage />} />
|
||||
</Route>
|
||||
<Route element={<RequireAdmin />}>
|
||||
<Route path="admin" element={<AdminShell />}>
|
||||
|
|
@ -271,8 +270,6 @@ function AppShell() {
|
|||
</Route>
|
||||
<Route path="/workflow-editor/:id" element={<WorkflowEditorPage/>}/>
|
||||
<Route path="/subscription" element={<SubscriptionPage/>}/>
|
||||
<Route path="/pilot/viz" element={<PilotVizPage />} />
|
||||
<Route path="/app/dashboard-lab" element={<DashboardLabPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import {
|
|||
BODY_CHART_DAYS_DEFAULT,
|
||||
normalizeBodyChartDays,
|
||||
} from '../../widgetSystem/bodyChartDays'
|
||||
import PilotRuleCard from './PilotRuleCard'
|
||||
import DashboardRuleCard from './DashboardRuleCard'
|
||||
|
||||
export default function PilotActivitySection({ refreshTick = 0, chartDays = BODY_CHART_DAYS_DEFAULT }) {
|
||||
export default function ActivityOverviewWidget({ refreshTick = 0, chartDays = BODY_CHART_DAYS_DEFAULT }) {
|
||||
const periodDays = normalizeBodyChartDays(chartDays)
|
||||
const { activeProfile } = useProfile()
|
||||
const globalQualityLevel = activeProfile?.quality_filter_level
|
||||
|
|
@ -124,7 +124,7 @@ export default function PilotActivitySection({ refreshTick = 0, chartDays = BODY
|
|||
</Link>
|
||||
</p>
|
||||
) : (
|
||||
actRules.map((item, i) => <PilotRuleCard key={i} item={item} />)
|
||||
actRules.map((item, i) => <DashboardRuleCard key={i} item={item} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -15,14 +15,14 @@ import dayjs from 'dayjs'
|
|||
import { api } from '../../utils/api'
|
||||
import { useProfile } from '../../context/ProfileContext'
|
||||
import { getInterpretation } from '../../utils/interpret'
|
||||
import { rollingAvg, fmtDate } from '../../pilot/pilotChartUtils'
|
||||
import { rollingAvg, fmtDate } from '../../widgetSystem/dashboardChartUtils'
|
||||
import {
|
||||
BODY_CHART_DAYS_DEFAULT,
|
||||
normalizeBodyChartDays,
|
||||
} from '../../widgetSystem/bodyChartDays'
|
||||
import PilotRuleCard from './PilotRuleCard'
|
||||
import DashboardRuleCard from './DashboardRuleCard'
|
||||
|
||||
export default function PilotBodySection({ refreshTick = 0, chartDays = BODY_CHART_DAYS_DEFAULT }) {
|
||||
export default function BodyOverviewWidget({ refreshTick = 0, chartDays = BODY_CHART_DAYS_DEFAULT }) {
|
||||
const windowDays = normalizeBodyChartDays(chartDays)
|
||||
const { activeProfile } = useProfile()
|
||||
const [weights, setWeights] = useState([])
|
||||
|
|
@ -221,7 +221,7 @@ export default function PilotBodySection({ refreshTick = 0, chartDays = BODY_CHA
|
|||
Körperfett, Magermasse (FFMI), BMI – gleiche Logik wie auf der Verlauf-Seite (Körper).
|
||||
</p>
|
||||
{rules.map((item, i) => (
|
||||
<PilotRuleCard key={i} item={item} />
|
||||
<DashboardRuleCard key={i} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { getStatusColor, getStatusBg } from '../../utils/interpret'
|
||||
|
||||
export default function PilotRuleCard({ item }) {
|
||||
export default function DashboardRuleCard({ item }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const color = getStatusColor(item.status)
|
||||
return (
|
||||
|
|
@ -38,7 +38,7 @@ function buildAutoTileIds(refTiles, hasBf, hasKcal) {
|
|||
* @param {{ refreshTick?: number, kpiConfig?: Record<string, unknown> }} props
|
||||
* kpiConfig.tiles: geordnete Kachel-ids; fehlend = automatische Belegung (wie bisher).
|
||||
*/
|
||||
export default function PilotKpiBoard({ refreshTick = 0, kpiConfig }) {
|
||||
export default function KpiBoardWidget({ refreshTick = 0, kpiConfig }) {
|
||||
const manualOrder = useMemo(() => kpiTileOrderFromConfig(kpiConfig), [kpiConfig])
|
||||
|
||||
const { activeProfile } = useProfile()
|
||||
|
|
@ -9,7 +9,7 @@ import { api } from '../../utils/api'
|
|||
* @param {{ onSaved?: () => void, captureConfig?: Record<string, unknown> }} props
|
||||
* captureConfig: show_weight, show_resting_hr, show_hrv, show_vo2_max (false = ausblenden; fehlend = true)
|
||||
*/
|
||||
export default function PilotQuickCapture({ onSaved, captureConfig }) {
|
||||
export default function QuickCaptureWidget({ onSaved, captureConfig }) {
|
||||
const cfgRaw = captureConfig && typeof captureConfig === 'object' ? captureConfig : {}
|
||||
const showWeight = cfgRaw.show_weight !== false
|
||||
const showRestingHr = cfgRaw.show_resting_hr !== false
|
||||
|
|
@ -130,8 +130,9 @@ export default function PilotQuickCapture({ onSaved, captureConfig }) {
|
|||
<div className="card section-gap">
|
||||
<div className="card-title">Schnelleingabe (heute)</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text3)', margin: 0 }}>
|
||||
Für dieses Widget sind keine Eingabebereiche aktiviert. Im Dashboard-Lab die Sichtbarkeit prüfen
|
||||
oder <Link to="/vitals">Vitalwerte-Seite</Link> nutzen.
|
||||
Für dieses Widget sind keine Eingabebereiche aktiviert. Unter{' '}
|
||||
<Link to="/settings/dashboard-layout">Übersicht anpassen</Link> die Schnelleingabe-Konfiguration prüfen oder{' '}
|
||||
<Link to="/vitals">Vitalwerte-Seite</Link> nutzen.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -200,7 +201,7 @@ export default function PilotQuickCapture({ onSaved, captureConfig }) {
|
|||
{showRestingHr && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="pqc-resting-hr"
|
||||
htmlFor="qcw-resting-hr"
|
||||
className="form-label"
|
||||
style={{ display: 'block', marginBottom: 4, fontSize: 11, fontWeight: 600, color: 'var(--text2)' }}
|
||||
>
|
||||
|
|
@ -208,7 +209,7 @@ export default function PilotQuickCapture({ onSaved, captureConfig }) {
|
|||
<span style={{ fontWeight: 400, color: 'var(--text3)' }}> (bpm)</span>
|
||||
</label>
|
||||
<input
|
||||
id="pqc-resting-hr"
|
||||
id="qcw-resting-hr"
|
||||
type="number"
|
||||
className="form-input"
|
||||
style={{ width: '100%' }}
|
||||
|
|
@ -221,7 +222,7 @@ export default function PilotQuickCapture({ onSaved, captureConfig }) {
|
|||
{showHrv && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="pqc-hrv"
|
||||
htmlFor="qcw-hrv"
|
||||
className="form-label"
|
||||
style={{ display: 'block', marginBottom: 4, fontSize: 11, fontWeight: 600, color: 'var(--text2)' }}
|
||||
>
|
||||
|
|
@ -229,7 +230,7 @@ export default function PilotQuickCapture({ onSaved, captureConfig }) {
|
|||
<span style={{ fontWeight: 400, color: 'var(--text3)' }}> (ms)</span>
|
||||
</label>
|
||||
<input
|
||||
id="pqc-hrv"
|
||||
id="qcw-hrv"
|
||||
type="number"
|
||||
className="form-input"
|
||||
style={{ width: '100%' }}
|
||||
|
|
@ -242,14 +243,14 @@ export default function PilotQuickCapture({ onSaved, captureConfig }) {
|
|||
{showVo2 && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="pqc-vo2"
|
||||
htmlFor="qcw-vo2"
|
||||
className="form-label"
|
||||
style={{ display: 'block', marginBottom: 4, fontSize: 11, fontWeight: 600, color: 'var(--text2)' }}
|
||||
>
|
||||
VO₂max
|
||||
</label>
|
||||
<input
|
||||
id="pqc-vo2"
|
||||
id="qcw-vo2"
|
||||
type="number"
|
||||
className="form-input"
|
||||
style={{ width: '100%' }}
|
||||
|
|
@ -4,7 +4,7 @@ import { useProfile } from '../../context/ProfileContext'
|
|||
|
||||
dayjs.locale('de')
|
||||
|
||||
export default function PilotWelcome() {
|
||||
export default function WelcomeWidget() {
|
||||
const { activeProfile } = useProfile()
|
||||
return (
|
||||
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||
|
|
@ -12,7 +12,7 @@ export default function PilotWelcome() {
|
|||
Hallo, {activeProfile?.name || 'Nutzer'} 👋
|
||||
</h2>
|
||||
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '6px 0 0' }}>
|
||||
{dayjs().format('dddd, DD. MMMM YYYY')} · Pilot-Übersicht
|
||||
{dayjs().format('dddd, DD. MMMM YYYY')} · Übersicht
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
110
frontend/src/components/dashboard-widgets/ReportExportWidget.jsx
Normal file
110
frontend/src/components/dashboard-widgets/ReportExportWidget.jsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import dayjs from 'dayjs'
|
||||
import { FileDown } from 'lucide-react'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { useProfile } from '../../context/ProfileContext'
|
||||
import { exportDashboardToPdf } from '../../utils/dashboardPdfExport'
|
||||
|
||||
/**
|
||||
* @param {{ reportExportConfig: { document_title: string, subtitle: string, capture_scale: number } }} props
|
||||
*/
|
||||
export default function ReportExportWidget({ reportExportConfig }) {
|
||||
const { canExport } = useAuth()
|
||||
const { activeProfile } = useProfile()
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState(null)
|
||||
|
||||
const profileName = activeProfile?.name?.trim() || 'Profil'
|
||||
const title =
|
||||
reportExportConfig.document_title || `${profileName} – Übersicht`
|
||||
const subtitle =
|
||||
reportExportConfig.subtitle ||
|
||||
`Erstellt am ${dayjs().format('DD.MM.YYYY HH:mm')} · Mitai Jinkendo`
|
||||
|
||||
const runExport = async () => {
|
||||
setErr(null)
|
||||
setBusy(true)
|
||||
try {
|
||||
const slug = (reportExportConfig.document_title || profileName).replace(/\s+/g, '-').slice(0, 80)
|
||||
await exportDashboardToPdf({
|
||||
scale: reportExportConfig.capture_scale,
|
||||
filenameBase: `bericht-${slug}-${dayjs().format('YYYY-MM-DD')}`,
|
||||
})
|
||||
} catch (e) {
|
||||
setErr(e?.message || 'PDF-Export fehlgeschlagen.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<div
|
||||
className="card-title"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8 }}
|
||||
data-dashboard-pdf-exclude="true"
|
||||
>
|
||||
<FileDown size={18} color="var(--accent)" aria-hidden />
|
||||
Übersicht als Bild-PDF
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 17,
|
||||
fontWeight: 650,
|
||||
margin: '0 0 6px',
|
||||
color: 'var(--text1)',
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p style={{ margin: 0, fontSize: 13, color: 'var(--text2)', lineHeight: 1.5 }}>{subtitle}</p>
|
||||
</div>
|
||||
<div data-dashboard-pdf-exclude="true">
|
||||
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 14, lineHeight: 1.55 }}>
|
||||
<strong>Layout-Schnappschuss:</strong> Die sichtbare Übersicht wird im Browser gerastert (html2canvas).
|
||||
Für einen <strong>datenbasierten Bericht</strong> unabhängig vom Dashboard öffne{' '}
|
||||
<Link to="/settings/reports" style={{ color: 'var(--accent)', fontWeight: 600 }}>
|
||||
Einstellungen → PDF-Berichte
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{!canExport ? (
|
||||
<p style={{ fontSize: 13, color: '#D85A30', margin: 0 }}>
|
||||
PDF-Export ist für dieses Profil nicht freigeschaltet.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{err && (
|
||||
<p style={{ fontSize: 13, color: '#D85A30', margin: '0 0 10px' }} role="alert">
|
||||
{err}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy}
|
||||
onClick={runExport}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}
|
||||
>
|
||||
{busy ? (
|
||||
<>
|
||||
<span className="spinner" style={{ width: 18, height: 18 }} aria-hidden />
|
||||
PDF wird erzeugt…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileDown size={18} />
|
||||
PDF-Schnappschuss herunterladen
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,5 +6,6 @@
|
|||
export const SETTINGS_SHELL_NAV_ITEMS = [
|
||||
{ id: 'general', label: 'Allgemein', to: '/settings', end: true },
|
||||
{ id: 'dashboard-layout', label: 'Übersicht', to: '/settings/dashboard-layout' },
|
||||
{ id: 'reports', label: 'PDF-Berichte', to: '/settings/reports' },
|
||||
{ id: 'reference-values', label: 'Referenzwerte', to: '/settings/reference-values' },
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { api } from '../utils/api'
|
|||
import { useProfile } from '../context/ProfileContext'
|
||||
import TrialBanner from '../components/TrialBanner'
|
||||
import EmailVerificationBanner from '../components/EmailVerificationBanner'
|
||||
import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
|
||||
import { ensureDashboardWidgetsRegistered } from '../widgetSystem/registerDashboardWidgets'
|
||||
import { WidgetRenderer } from '../widgetSystem/dashboardWidgetRegistry'
|
||||
|
||||
function catalogMetaById(catalog) {
|
||||
|
|
@ -27,7 +27,7 @@ export default function Dashboard() {
|
|||
const requestRefresh = () => setRefreshTick((t) => t + 1)
|
||||
|
||||
useEffect(() => {
|
||||
ensurePilotLabWidgetsRegistered()
|
||||
ensureDashboardWidgetsRegistered()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -123,7 +123,9 @@ export default function Dashboard() {
|
|||
)}
|
||||
|
||||
{!layoutLoading && layoutForPreview && (
|
||||
<WidgetRenderer layout={layoutForPreview} refreshTick={refreshTick} requestRefresh={requestRefresh} />
|
||||
<div id="dashboard-pdf-capture-root">
|
||||
<WidgetRenderer layout={layoutForPreview} refreshTick={refreshTick} requestRefresh={requestRefresh} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||
import { Link } from 'react-router-dom'
|
||||
import { ChevronDown, ChevronUp, GripVertical, LayoutDashboard, Plus, Search, X } from 'lucide-react'
|
||||
import { api, formatFastApiDetail } from '../utils/api'
|
||||
import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
|
||||
import { ensureDashboardWidgetsRegistered } from '../widgetSystem/registerDashboardWidgets'
|
||||
import {
|
||||
BODY_CHART_DAYS_DEFAULT,
|
||||
BODY_CHART_DAYS_MAX,
|
||||
|
|
@ -16,6 +16,7 @@ import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryViz
|
|||
import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor'
|
||||
import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor'
|
||||
import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor'
|
||||
import ReportExportConfigEditor from '../widgetSystem/ReportExportConfigEditor'
|
||||
import {
|
||||
moveWidget,
|
||||
moveWidgetToIndex,
|
||||
|
|
@ -46,7 +47,7 @@ function catalogMetaById(catalog) {
|
|||
* @param {{ adminMode?: boolean }} [props]
|
||||
*/
|
||||
export default function DashboardConfigurePage({ adminMode = false } = {}) {
|
||||
ensurePilotLabWidgetsRegistered()
|
||||
ensureDashboardWidgetsRegistered()
|
||||
|
||||
const [bundle, setBundle] = useState(null)
|
||||
const [adminFromDatabase, setAdminFromDatabase] = useState(null)
|
||||
|
|
@ -590,6 +591,19 @@ export default function DashboardConfigurePage({ adminMode = false } = {}) {
|
|||
}
|
||||
/>
|
||||
)}
|
||||
{w.id === 'report_export' && (
|
||||
<ReportExportConfigEditor
|
||||
config={w.config || {}}
|
||||
onChange={(next) =>
|
||||
setLayout((L) =>
|
||||
normalizeLayoutForEditor({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) => (j !== i ? x : { ...x, config: { ...next } })),
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,514 +0,0 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, LayoutGrid } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api, formatFastApiDetail } from '../utils/api'
|
||||
import { WidgetRenderer } from '../widgetSystem/dashboardWidgetRegistry'
|
||||
import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
|
||||
import {
|
||||
BODY_CHART_DAYS_DEFAULT,
|
||||
BODY_CHART_DAYS_MAX,
|
||||
BODY_CHART_DAYS_MIN,
|
||||
normalizeBodyChartDays,
|
||||
} from '../widgetSystem/bodyChartDays'
|
||||
import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor'
|
||||
import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor'
|
||||
import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor'
|
||||
import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor'
|
||||
import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor'
|
||||
import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor'
|
||||
import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor'
|
||||
import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor'
|
||||
|
||||
/** Widgets mit optionalem config.chart_days (7–90), gleiche UX im Editor */
|
||||
const CHART_DAYS_WIDGET_IDS = new Set([
|
||||
'body_overview',
|
||||
'body_history_viz',
|
||||
'activity_overview',
|
||||
'nutrition_detail_charts',
|
||||
'nutrition_history_viz',
|
||||
'fitness_history_viz',
|
||||
'recovery_history_viz',
|
||||
'history_overview_viz',
|
||||
'recovery_charts_panel',
|
||||
])
|
||||
|
||||
function catalogMetaById(catalog) {
|
||||
if (!catalog?.widgets?.length) return {}
|
||||
return Object.fromEntries(catalog.widgets.map((w) => [w.id, w]))
|
||||
}
|
||||
|
||||
export default function DashboardLabPage() {
|
||||
ensurePilotLabWidgetsRegistered()
|
||||
|
||||
const [refreshTick, setRefreshTick] = useState(0)
|
||||
const requestRefresh = () => setRefreshTick((t) => t + 1)
|
||||
const [catalog, setCatalog] = useState(null)
|
||||
const [bundle, setBundle] = useState(null)
|
||||
const [layout, setLayout] = useState(null)
|
||||
const [err, setErr] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState(null)
|
||||
/** Pro Widget-ID: Rohstring während der Eingabe (Tippen ohne sofortiges Clampen) */
|
||||
const [chartDaysDraftByWidgetId, setChartDaysDraftByWidgetId] = useState({})
|
||||
|
||||
const metaById = catalogMetaById(catalog)
|
||||
|
||||
const isWidgetCatalogAllowed = useCallback(
|
||||
(widgetId) => {
|
||||
const m = metaById[widgetId]
|
||||
if (m == null) return true
|
||||
return m.allowed !== false
|
||||
},
|
||||
[metaById],
|
||||
)
|
||||
|
||||
const visibleEditorIndices = useMemo(
|
||||
() =>
|
||||
layout?.widgets?.map((_, i) => i).filter((i) => isWidgetCatalogAllowed(layout.widgets[i].id)) ?? [],
|
||||
[layout, isWidgetCatalogAllowed],
|
||||
)
|
||||
|
||||
const layoutForPreview = useMemo(
|
||||
() =>
|
||||
layout
|
||||
? {
|
||||
...layout,
|
||||
widgets: layout.widgets.map((w) => ({
|
||||
...w,
|
||||
enabled: w.enabled && isWidgetCatalogAllowed(w.id),
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
[layout, isWidgetCatalogAllowed],
|
||||
)
|
||||
|
||||
const commitChartDaysDraftToLayout = useCallback((draftStr, baseLayout, widgetId) => {
|
||||
const clamped = normalizeBodyChartDays(
|
||||
draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr
|
||||
)
|
||||
return {
|
||||
...baseLayout,
|
||||
widgets: baseLayout.widgets.map((x) =>
|
||||
x.id !== widgetId ? x : { ...x, config: { ...x.config, chart_days: clamped } }
|
||||
),
|
||||
}
|
||||
}, [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setErr(null)
|
||||
try {
|
||||
const [cat, b] = await Promise.all([api.getAppWidgetsCatalog(), api.getAppDashboardLayout()])
|
||||
setCatalog(cat)
|
||||
setBundle(b)
|
||||
setChartDaysDraftByWidgetId({})
|
||||
setLayout(normalizeLayoutForEditor(b.layout))
|
||||
} catch (e) {
|
||||
setErr(formatFastApiDetail(null, e.message))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const save = async () => {
|
||||
if (!layout) return
|
||||
let toSave = layout
|
||||
const draftEntries = Object.entries(chartDaysDraftByWidgetId)
|
||||
if (draftEntries.length) {
|
||||
for (const [wid, val] of draftEntries) {
|
||||
toSave = normalizeLayoutForEditor(commitChartDaysDraftToLayout(val, toSave, wid))
|
||||
}
|
||||
setLayout(toSave)
|
||||
setChartDaysDraftByWidgetId({})
|
||||
}
|
||||
setBusy(true)
|
||||
setMsg(null)
|
||||
setErr(null)
|
||||
try {
|
||||
await api.putAppDashboardLayout(toSave)
|
||||
setMsg('Layout gespeichert.')
|
||||
await load()
|
||||
} catch (e) {
|
||||
setErr(formatFastApiDetail(null, e.message))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const reset = async () => {
|
||||
if (!confirm('Persönliches Layout löschen und Standard wiederherstellen?')) return
|
||||
setBusy(true)
|
||||
setMsg(null)
|
||||
setErr(null)
|
||||
try {
|
||||
const r = await api.resetAppDashboardLayout()
|
||||
setChartDaysDraftByWidgetId({})
|
||||
setLayout(normalizeLayoutForEditor(r.layout))
|
||||
setMsg('Auf Standard zurückgesetzt.')
|
||||
await load()
|
||||
} catch (e) {
|
||||
setErr(formatFastApiDetail(null, e.message))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const applyDefaultLocal = () => {
|
||||
if (bundle?.lab_default_layout) {
|
||||
setChartDaysDraftByWidgetId({})
|
||||
setLayout(normalizeLayoutForEditor(structuredClone(bundle.lab_default_layout)))
|
||||
setMsg('Lab-Standard geladen (noch nicht gespeichert).')
|
||||
}
|
||||
}
|
||||
|
||||
if (err && !layout) {
|
||||
return (
|
||||
<div style={{ padding: 24, maxWidth: 640, margin: '0 auto' }}>
|
||||
<p style={{ color: '#D85A30' }}>{err}</p>
|
||||
<button type="button" className="btn btn-secondary" onClick={load}>
|
||||
Erneut laden
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!layout) {
|
||||
return (
|
||||
<div style={{ padding: 48, textAlign: 'center' }}>
|
||||
<div className="spinner" style={{ width: 32, height: 32, margin: '0 auto' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ paddingBottom: 96, textAlign: 'left', maxWidth: 920, margin: '0 auto' }}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Link
|
||||
to="/settings"
|
||||
className="btn btn-secondary"
|
||||
style={{ display: 'inline-flex', marginBottom: 12, textDecoration: 'none' }}
|
||||
>
|
||||
← Einstellungen
|
||||
</Link>
|
||||
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<LayoutGrid size={26} color="var(--accent)" />
|
||||
App-Bereich: Dashboard-Lab
|
||||
</h1>
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
|
||||
Widget-System: Katalog, Registry, Renderer; optional pro Widget <code>config</code> (z. B.{' '}
|
||||
<strong>Körper</strong> / <strong>Aktivität</strong>: Zeitraum 7–90 Tage; <strong>KPI</strong>: Kacheln
|
||||
wählen & sortieren). Layout pro Profil in der DB —
|
||||
getrennt vom Produktiv-Dashboard.
|
||||
Vergleich:{' '}
|
||||
<Link to="/pilot/viz" style={{ color: 'var(--accent)' }}>
|
||||
Pilot-Übersicht (festes Standard-Layout)
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
marginBottom: 20,
|
||||
borderStyle: 'dashed',
|
||||
borderColor: 'var(--border2)',
|
||||
background: 'var(--surface2)',
|
||||
}}
|
||||
>
|
||||
<div className="card-title" style={{ fontSize: 14 }}>
|
||||
Layout (v1)
|
||||
</div>
|
||||
{bundle && (
|
||||
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12 }}>
|
||||
Status: {bundle.custom ? 'individuell gespeichert' : 'Standard (nicht in DB)'}
|
||||
</p>
|
||||
)}
|
||||
{err && <p style={{ fontSize: 12, color: '#D85A30', marginBottom: 8 }}>{err}</p>}
|
||||
{msg && <p style={{ fontSize: 12, color: 'var(--accent)', marginBottom: 8 }}>{msg}</p>}
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 12px' }}>
|
||||
{visibleEditorIndices.map((i) => {
|
||||
const w = layout.widgets[i]
|
||||
const label = metaById[w.id]?.title || w.id
|
||||
const chartDaysVal =
|
||||
w.config?.chart_days != null
|
||||
? normalizeBodyChartDays(w.config.chart_days)
|
||||
: BODY_CHART_DAYS_DEFAULT
|
||||
return (
|
||||
<li
|
||||
key={w.id}
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 140px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={w.enabled}
|
||||
onChange={() => setLayout((L) => toggleWidget(L, i))}
|
||||
/>
|
||||
<span style={{ fontSize: 14 }}>{label}</span>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '6px 10px' }}
|
||||
aria-label="Nach oben"
|
||||
onClick={() => setLayout((L) => moveWidget(L, i, -1))}
|
||||
>
|
||||
<ChevronUp size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '6px 10px' }}
|
||||
aria-label="Nach unten"
|
||||
onClick={() => setLayout((L) => moveWidget(L, i, 1))}
|
||||
>
|
||||
<ChevronDown size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{w.id === 'quick_capture' && (
|
||||
<QuickCaptureConfigEditor
|
||||
config={w.config || {}}
|
||||
onChange={(next) =>
|
||||
setLayout((L) =>
|
||||
normalizeLayoutForEditor({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) => {
|
||||
if (j !== i) return x
|
||||
const cfg = { ...(x.config || {}) }
|
||||
for (const k of ['show_weight', 'show_resting_hr', 'show_hrv', 'show_vo2_max']) {
|
||||
delete cfg[k]
|
||||
}
|
||||
Object.assign(cfg, next)
|
||||
return { ...x, config: cfg }
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{w.id === 'kpi_board' && (
|
||||
<KpiBoardConfigEditor
|
||||
tiles={Object.prototype.hasOwnProperty.call(w.config || {}, 'tiles') ? w.config.tiles : undefined}
|
||||
onChange={(next) =>
|
||||
setLayout((L) =>
|
||||
normalizeLayoutForEditor({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) => {
|
||||
if (j !== i) return x
|
||||
const cfg = { ...(x.config || {}) }
|
||||
if (next === undefined) {
|
||||
delete cfg.tiles
|
||||
} else {
|
||||
cfg.tiles = next
|
||||
}
|
||||
return { ...x, config: cfg }
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{CHART_DAYS_WIDGET_IDS.has(w.id) && (
|
||||
<div style={{ marginTop: 10, marginLeft: 28 }}>
|
||||
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 4 }}>
|
||||
{w.id === 'body_overview'
|
||||
? 'Körper-Chart'
|
||||
: w.id === 'body_history_viz'
|
||||
? 'Körper (Verlauf-Bundle)'
|
||||
: w.id === 'activity_overview'
|
||||
? 'Aktivität (Verteilung & Konsistenz)'
|
||||
: w.id === 'nutrition_detail_charts'
|
||||
? 'Ernährung — Charts'
|
||||
: w.id === 'nutrition_history_viz'
|
||||
? 'Ernährung (Verlauf-Bundle)'
|
||||
: w.id === 'fitness_history_viz'
|
||||
? 'Fitness (Verlauf-Bundle)'
|
||||
: w.id === 'history_overview_viz'
|
||||
? 'Gesamtübersicht (Verlauf-Bundle)'
|
||||
: w.id === 'recovery_history_viz'
|
||||
? 'Erholung (Verlauf-Bundle)'
|
||||
: 'Erholung — Charts'}{' '}
|
||||
— Zeitraum (Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
className="form-input"
|
||||
style={{ maxWidth: 120 }}
|
||||
aria-label={
|
||||
w.id === 'body_overview'
|
||||
? 'Körper-Chart Zeitraum in Tagen'
|
||||
: w.id === 'body_history_viz'
|
||||
? 'Körper Verlauf-Bundle Zeitraum in Tagen'
|
||||
: w.id === 'activity_overview'
|
||||
? 'Aktivität Zeitraum in Tagen'
|
||||
: w.id === 'nutrition_detail_charts'
|
||||
? 'Ernährungs-Charts Zeitraum in Tagen'
|
||||
: w.id === 'nutrition_history_viz'
|
||||
? 'Ernährung Verlauf-Bundle Zeitraum in Tagen'
|
||||
: w.id === 'fitness_history_viz'
|
||||
? 'Fitness Verlauf-Bundle Zeitraum in Tagen'
|
||||
: w.id === 'history_overview_viz'
|
||||
? 'Gesamtübersicht Verlauf-Bundle Zeitraum in Tagen'
|
||||
: w.id === 'recovery_history_viz'
|
||||
? 'Erholung Verlauf-Bundle Zeitraum in Tagen'
|
||||
: 'Erholungs-Charts Zeitraum in Tagen'
|
||||
}
|
||||
value={
|
||||
chartDaysDraftByWidgetId[w.id] !== undefined
|
||||
? chartDaysDraftByWidgetId[w.id]
|
||||
: String(chartDaysVal)
|
||||
}
|
||||
onFocus={() =>
|
||||
setChartDaysDraftByWidgetId((prev) => ({
|
||||
...prev,
|
||||
[w.id]: String(chartDaysVal),
|
||||
}))
|
||||
}
|
||||
onChange={(e) =>
|
||||
setChartDaysDraftByWidgetId((prev) => ({
|
||||
...prev,
|
||||
[w.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
onBlur={(e) => {
|
||||
const raw = e.target.value
|
||||
setLayout((L) =>
|
||||
normalizeLayoutForEditor(commitChartDaysDraftToLayout(raw, L, w.id))
|
||||
)
|
||||
setChartDaysDraftByWidgetId((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[w.id]
|
||||
return next
|
||||
})
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{w.id === 'body_history_viz' && (
|
||||
<BodyHistoryVizConfigEditor
|
||||
config={w.config || {}}
|
||||
onChange={(next) =>
|
||||
setLayout((L) =>
|
||||
normalizeLayoutForEditor({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) => {
|
||||
if (j !== i) return x
|
||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{w.id === 'nutrition_history_viz' && (
|
||||
<NutritionHistoryVizConfigEditor
|
||||
config={w.config || {}}
|
||||
onChange={(next) =>
|
||||
setLayout((L) =>
|
||||
normalizeLayoutForEditor({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) => {
|
||||
if (j !== i) return x
|
||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{w.id === 'fitness_history_viz' && (
|
||||
<FitnessHistoryVizConfigEditor
|
||||
config={w.config || {}}
|
||||
onChange={(next) =>
|
||||
setLayout((L) =>
|
||||
normalizeLayoutForEditor({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) => {
|
||||
if (j !== i) return x
|
||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{w.id === 'recovery_history_viz' && (
|
||||
<RecoveryHistoryVizConfigEditor
|
||||
config={w.config || {}}
|
||||
onChange={(next) =>
|
||||
setLayout((L) =>
|
||||
normalizeLayoutForEditor({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) => {
|
||||
if (j !== i) return x
|
||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{w.id === 'history_overview_viz' && (
|
||||
<HistoryOverviewVizConfigEditor
|
||||
config={w.config || {}}
|
||||
onChange={(next) =>
|
||||
setLayout((L) =>
|
||||
normalizeLayoutForEditor({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) => {
|
||||
if (j !== i) return x
|
||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={save}>
|
||||
Speichern
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={reset}>
|
||||
Zurücksetzen (DB)
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={applyDefaultLocal}>
|
||||
Standard in Editor laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{layoutForPreview && (
|
||||
<WidgetRenderer layout={layoutForPreview} refreshTick={refreshTick} requestRefresh={requestRefresh} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { useState } from 'react'
|
||||
import { FlaskConical } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { WidgetRenderer } from '../widgetSystem/dashboardWidgetRegistry'
|
||||
import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
|
||||
import { DEFAULT_LAB_LAYOUT } from '../widgetSystem/defaultLabLayout'
|
||||
|
||||
/**
|
||||
* Pilot-Übersicht nach Product-Spec (festes Standard-Layout).
|
||||
* Nutzt dasselbe Widget-Rendering wie /app/dashboard-lab.
|
||||
*/
|
||||
export default function PilotVizPage() {
|
||||
ensurePilotLabWidgetsRegistered()
|
||||
|
||||
const [refreshTick, setRefreshTick] = useState(0)
|
||||
const requestRefresh = () => setRefreshTick((t) => t + 1)
|
||||
|
||||
return (
|
||||
<div style={{ paddingBottom: 96, textAlign: 'left', maxWidth: 920, margin: '0 auto' }}>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<Link
|
||||
to="/settings"
|
||||
className="btn btn-secondary"
|
||||
style={{ display: 'inline-flex', marginBottom: 12, textDecoration: 'none' }}
|
||||
>
|
||||
← Einstellungen
|
||||
</Link>
|
||||
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<FlaskConical size={26} color="var(--accent)" />
|
||||
Pilot: Übersicht
|
||||
</h1>
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
|
||||
Konfigurierbare Ziel-Übersicht (Test). Produktives Dashboard und Verlauf unverändert. Nach Speichern von
|
||||
Gewicht oder Vitalwerten werden KPIs und Körperbereich neu geladen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<WidgetRenderer
|
||||
layout={DEFAULT_LAB_LAYOUT}
|
||||
refreshTick={refreshTick}
|
||||
requestRefresh={requestRefresh}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
629
frontend/src/pages/ReportConfigurePage.jsx
Normal file
629
frontend/src/pages/ReportConfigurePage.jsx
Normal file
|
|
@ -0,0 +1,629 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Download, FileText, Plus, Save, Trash2 } from 'lucide-react'
|
||||
import { api, formatFastApiDetail } from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useProfile } from '../context/ProfileContext'
|
||||
import UsageBadge from '../components/UsageBadge'
|
||||
import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor'
|
||||
import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor'
|
||||
import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor'
|
||||
import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor'
|
||||
import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor'
|
||||
import {
|
||||
BODY_CHART_DAYS_DEFAULT,
|
||||
BODY_CHART_DAYS_MAX,
|
||||
BODY_CHART_DAYS_MIN,
|
||||
normalizeBodyChartDays,
|
||||
} from '../widgetSystem/bodyChartDays'
|
||||
|
||||
const VIZ_BUNDLES_FALLBACK = [
|
||||
{ id: 'body_history_viz', title: 'Körper (Verlauf-Bundle)' },
|
||||
{ id: 'nutrition_history_viz', title: 'Ernährung (Verlauf-Bundle)' },
|
||||
{ id: 'fitness_history_viz', title: 'Fitness (Verlauf-Bundle)' },
|
||||
{ id: 'recovery_history_viz', title: 'Erholung (Verlauf-Bundle)' },
|
||||
{ id: 'history_overview_viz', title: 'Gesamtübersicht (Korrelationen)' },
|
||||
]
|
||||
|
||||
export default function ReportConfigurePage() {
|
||||
const { canExport } = useAuth()
|
||||
const { activeProfile } = useProfile()
|
||||
const [exportUsage, setExportUsage] = useState(null)
|
||||
const [catalog, setCatalog] = useState(null)
|
||||
const [definitions, setDefinitions] = useState([])
|
||||
const [selectedId, setSelectedId] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState(null)
|
||||
const [err, setErr] = useState(null)
|
||||
|
||||
const chartDaysMin = catalog?.chart_days?.min ?? BODY_CHART_DAYS_MIN
|
||||
const chartDaysMax = catalog?.chart_days?.max ?? BODY_CHART_DAYS_MAX
|
||||
const vizBundles = catalog?.viz_bundles?.length ? catalog.viz_bundles : VIZ_BUNDLES_FALLBACK
|
||||
|
||||
const selected = definitions.find((d) => d.id === selectedId) || null
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setErr(null)
|
||||
try {
|
||||
const [cat, bundle] = await Promise.all([api.getReportsCatalog(), api.listReportDefinitions()])
|
||||
setCatalog(cat)
|
||||
setDefinitions(bundle.definitions || [])
|
||||
} catch (e) {
|
||||
setErr(formatFastApiDetail(null, e.message))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
api.getFeatureUsage().then((features) => {
|
||||
const exportFeature = features.find((f) => f.feature_id === 'data_export')
|
||||
setExportUsage(exportFeature)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeProfile?.id) return
|
||||
load()
|
||||
}, [activeProfile?.id, load])
|
||||
|
||||
useEffect(() => {
|
||||
if (!definitions.length) {
|
||||
setSelectedId(null)
|
||||
return
|
||||
}
|
||||
if (!selectedId || !definitions.some((d) => d.id === selectedId)) {
|
||||
setSelectedId(definitions[0].id)
|
||||
}
|
||||
}, [definitions, selectedId])
|
||||
|
||||
const patchSelectedPayload = useCallback((fn) => {
|
||||
setDefinitions((defs) =>
|
||||
defs.map((d) => {
|
||||
if (d.id !== selectedId) return d
|
||||
const nextPayload = fn(d.payload || { version: 1, document_title: '', blocks: [] })
|
||||
return { ...d, payload: nextPayload }
|
||||
}),
|
||||
)
|
||||
}, [selectedId])
|
||||
|
||||
const reportNewBlock = (kind) => {
|
||||
const charts = catalog?.charts || []
|
||||
const first = charts[0]
|
||||
const firstBundleId = vizBundles[0]?.id || 'body_history_viz'
|
||||
if (kind === 'section') return { type: 'section', title: 'Neue Überschrift' }
|
||||
if (kind === 'viz_bundle') return { type: 'viz_bundle', bundle_id: firstBundleId, config: {} }
|
||||
if (kind === 'chart')
|
||||
return {
|
||||
type: 'chart',
|
||||
chart_id: first?.id || 'weight_trend',
|
||||
window_days: first?.default_window_days || 28,
|
||||
}
|
||||
return { type: 'ai_insight', title: '', insight_id: null }
|
||||
}
|
||||
|
||||
const handleCreateDefinition = async () => {
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
setMsg(null)
|
||||
try {
|
||||
const r = await api.createReportDefinition({
|
||||
name: `Bericht ${definitions.length + 1}`,
|
||||
})
|
||||
const d = r.definition
|
||||
setDefinitions((x) => [...x, d])
|
||||
setSelectedId(d.id)
|
||||
setMsg('Neuer Bericht angelegt. Inhalt speichern nicht vergessen, wenn du Änderungen machst.')
|
||||
} catch (e) {
|
||||
setErr(formatFastApiDetail(null, e.message))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selected) return
|
||||
if (!selected.payload?.blocks?.length) {
|
||||
setErr('Mindestens ein Block erforderlich.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
setMsg(null)
|
||||
try {
|
||||
await api.updateReportDefinition(selected.id, {
|
||||
name: selected.name?.trim() || 'Bericht',
|
||||
payload: selected.payload,
|
||||
})
|
||||
setMsg('Gespeichert.')
|
||||
await load()
|
||||
} catch (e) {
|
||||
setErr(formatFastApiDetail(null, e.message))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteDefinition = async () => {
|
||||
if (!selected) return
|
||||
if (!confirm(`Bericht „${selected.name}“ wirklich löschen?`)) return
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
setMsg(null)
|
||||
try {
|
||||
await api.deleteReportDefinition(selected.id)
|
||||
setDefinitions((defs) => defs.filter((d) => d.id !== selected.id))
|
||||
setSelectedId(null)
|
||||
setMsg('Bericht gelöscht.')
|
||||
} catch (e) {
|
||||
setErr(formatFastApiDetail(null, e.message))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGeneratePdf = async () => {
|
||||
if (!selected) return
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
setMsg(null)
|
||||
try {
|
||||
await api.generateStructuredReportPdf(selected.id)
|
||||
setMsg('PDF wurde heruntergeladen.')
|
||||
} catch (e) {
|
||||
setErr(formatFastApiDetail(null, e.message))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const setSelectedName = (name) => {
|
||||
setDefinitions((defs) => defs.map((d) => (d.id === selectedId ? { ...d, name } : d)))
|
||||
}
|
||||
|
||||
const vizBundleChartDays = (config) =>
|
||||
normalizeBodyChartDays(config?.chart_days ?? BODY_CHART_DAYS_DEFAULT)
|
||||
|
||||
if (!catalog) {
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<p style={{ color: 'var(--text2)' }}>Lade Katalog…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card section-gap">
|
||||
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<FileText size={18} color="var(--accent)" />
|
||||
PDF-Berichte
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.65 }}>
|
||||
Hier legst du <strong>einen oder mehrere strukturierte PDF-Berichte</strong> an. Pro Block vom Typ
|
||||
„Verlauf-Bundle“ gilt ein <strong>Zeitraum in Tagen</strong> (wie bei der Übersicht).{' '}
|
||||
<strong>Technisch:</strong> Es sind dieselben{' '}
|
||||
<strong>Daten-Bundles und dieselbe Konfiguration</strong> wie bei den Verlauf-Widgets — im PDF werden
|
||||
sie serverseitig gerendert (nicht die React-Komponenten der Startseite).
|
||||
</p>
|
||||
{!canExport && (
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
background: '#FCEBEB',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
color: '#D85A30',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
PDF ist mit dem Kontingent „Datenexport“ verknüpft. Bitte Admin kontaktieren.
|
||||
</div>
|
||||
)}
|
||||
{err && (
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
marginBottom: 12,
|
||||
background: '#FCEBEB',
|
||||
color: '#D85A30',
|
||||
}}
|
||||
>
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
{msg && (
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
marginBottom: 12,
|
||||
background: '#E1F5EE',
|
||||
color: 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', marginBottom: 14 }}>
|
||||
{definitions.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
type="button"
|
||||
className={d.id === selectedId ? 'btn btn-primary' : 'btn btn-secondary'}
|
||||
onClick={() => setSelectedId(d.id)}
|
||||
>
|
||||
{d.name || 'Bericht'}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy || !canExport || definitions.length >= 20}
|
||||
onClick={handleCreateDefinition}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Neuer Bericht
|
||||
</button>
|
||||
<Link to="/settings" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
|
||||
← Allgemein
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{canExport && !definitions.length && (
|
||||
<p style={{ fontSize: 14, marginBottom: 12 }}>
|
||||
Noch kein Bericht vorhanden. Lege mit „Neuer Bericht“ einen Standard an.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{canExport && selected && (
|
||||
<>
|
||||
<label className="form-label" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
Name des Berichts (intern)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
maxLength={120}
|
||||
value={selected.name || ''}
|
||||
onChange={(e) => setSelectedName(e.target.value)}
|
||||
style={{ marginBottom: 14, maxWidth: 420 }}
|
||||
/>
|
||||
|
||||
<label className="form-label" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
Dokumenttitel im PDF (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
maxLength={120}
|
||||
placeholder="Leer = Profilname + „Bericht“"
|
||||
value={selected.payload?.document_title || ''}
|
||||
onChange={(e) =>
|
||||
patchSelectedPayload((p) => ({
|
||||
...p,
|
||||
document_title: e.target.value,
|
||||
}))
|
||||
}
|
||||
style={{ marginBottom: 14 }}
|
||||
/>
|
||||
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text2)', marginBottom: 8 }}>Blöcke</div>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 12px' }}>
|
||||
{(selected.payload?.blocks || []).map((b, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
marginBottom: 10,
|
||||
background: 'var(--surface2)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text3)', textTransform: 'uppercase' }}>{b.type}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: '4px 8px' }}
|
||||
aria-label="Block entfernen"
|
||||
onClick={() =>
|
||||
patchSelectedPayload((p) => ({
|
||||
...p,
|
||||
blocks: (p.blocks || []).filter((_, j) => j !== idx),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{b.type === 'section' && (
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
style={{ marginTop: 8 }}
|
||||
value={b.title || ''}
|
||||
onChange={(e) =>
|
||||
patchSelectedPayload((p) => ({
|
||||
...p,
|
||||
blocks: (p.blocks || []).map((x, j) =>
|
||||
j === idx ? { ...x, title: e.target.value } : x,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{b.type === 'chart' && (
|
||||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<select
|
||||
className="form-input"
|
||||
value={b.chart_id}
|
||||
onChange={(e) =>
|
||||
patchSelectedPayload((p) => ({
|
||||
...p,
|
||||
blocks: (p.blocks || []).map((x, j) =>
|
||||
j === idx ? { ...x, chart_id: e.target.value } : x,
|
||||
),
|
||||
}))
|
||||
}
|
||||
>
|
||||
{catalog.charts?.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: 'var(--text3)' }}>Zeitraum (Tage)</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
min={7}
|
||||
max={365}
|
||||
value={b.window_days}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value)
|
||||
patchSelectedPayload((p) => ({
|
||||
...p,
|
||||
blocks: (p.blocks || []).map((x, j) =>
|
||||
j === idx
|
||||
? { ...x, window_days: Number.isFinite(n) ? n : x.window_days }
|
||||
: x,
|
||||
),
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{b.type === 'ai_insight' && (
|
||||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Optional: Überschrift"
|
||||
value={b.title || ''}
|
||||
onChange={(e) =>
|
||||
patchSelectedPayload((p) => ({
|
||||
...p,
|
||||
blocks: (p.blocks || []).map((x, j) =>
|
||||
j === idx ? { ...x, title: e.target.value } : x,
|
||||
),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Optional: Insight-UUID"
|
||||
value={b.insight_id || ''}
|
||||
onChange={(e) =>
|
||||
patchSelectedPayload((p) => {
|
||||
const v = e.target.value.trim() || null
|
||||
return {
|
||||
...p,
|
||||
blocks: (p.blocks || []).map((x, j) =>
|
||||
j === idx ? { ...x, insight_id: v } : x,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{b.type === 'viz_bundle' && (
|
||||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: 'var(--text3)', display: 'block', marginBottom: 4 }}>
|
||||
Verlauf-Bundle
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={b.bundle_id}
|
||||
onChange={(e) =>
|
||||
patchSelectedPayload((p) => ({
|
||||
...p,
|
||||
blocks: (p.blocks || []).map((x, j) =>
|
||||
j === idx ? { ...x, bundle_id: e.target.value, config: {} } : x,
|
||||
),
|
||||
}))
|
||||
}
|
||||
>
|
||||
{vizBundles.map((vb) => (
|
||||
<option key={vb.id} value={vb.id}>
|
||||
{vb.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: 'var(--text3)', display: 'block', marginBottom: 4 }}>
|
||||
Zeitraum für dieses Bundle (Tage): {chartDaysMin}–{chartDaysMax}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
style={{ maxWidth: 160 }}
|
||||
min={chartDaysMin}
|
||||
max={chartDaysMax}
|
||||
value={vizBundleChartDays(b.config)}
|
||||
onChange={(e) => {
|
||||
const days = normalizeBodyChartDays(e.target.value)
|
||||
patchSelectedPayload((p) => ({
|
||||
...p,
|
||||
blocks: (p.blocks || []).map((x, j) =>
|
||||
j === idx ? { ...x, config: { ...x.config, chart_days: days } } : x,
|
||||
),
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{b.bundle_id === 'body_history_viz' && (
|
||||
<BodyHistoryVizConfigEditor
|
||||
config={b.config || {}}
|
||||
onChange={(next) =>
|
||||
patchSelectedPayload((p) => ({
|
||||
...p,
|
||||
blocks: (p.blocks || []).map((x, j) => {
|
||||
if (j !== idx) return x
|
||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||
}),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{b.bundle_id === 'nutrition_history_viz' && (
|
||||
<NutritionHistoryVizConfigEditor
|
||||
config={b.config || {}}
|
||||
onChange={(next) =>
|
||||
patchSelectedPayload((p) => ({
|
||||
...p,
|
||||
blocks: (p.blocks || []).map((x, j) => {
|
||||
if (j !== idx) return x
|
||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||
}),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{b.bundle_id === 'fitness_history_viz' && (
|
||||
<FitnessHistoryVizConfigEditor
|
||||
config={b.config || {}}
|
||||
onChange={(next) =>
|
||||
patchSelectedPayload((p) => ({
|
||||
...p,
|
||||
blocks: (p.blocks || []).map((x, j) => {
|
||||
if (j !== idx) return x
|
||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||
}),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{b.bundle_id === 'recovery_history_viz' && (
|
||||
<RecoveryHistoryVizConfigEditor
|
||||
config={b.config || {}}
|
||||
onChange={(next) =>
|
||||
patchSelectedPayload((p) => ({
|
||||
...p,
|
||||
blocks: (p.blocks || []).map((x, j) => {
|
||||
if (j !== idx) return x
|
||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||
}),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{b.bundle_id === 'history_overview_viz' && (
|
||||
<HistoryOverviewVizConfigEditor
|
||||
config={b.config || {}}
|
||||
onChange={(next) =>
|
||||
patchSelectedPayload((p) => ({
|
||||
...p,
|
||||
blocks: (p.blocks || []).map((x, j) => {
|
||||
if (j !== idx) return x
|
||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||
}),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ maxWidth: 280 }}
|
||||
defaultValue=""
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
if (!v) return
|
||||
patchSelectedPayload((p) => ({
|
||||
...p,
|
||||
blocks: [...(p.blocks || []), reportNewBlock(v)],
|
||||
}))
|
||||
e.target.value = ''
|
||||
}}
|
||||
>
|
||||
<option value="">+ Block hinzufügen…</option>
|
||||
<option value="section">Überschrift</option>
|
||||
<option value="viz_bundle">Verlauf-Bundle (KPIs & Diagramme)</option>
|
||||
<option value="chart">Einzel-Diagramm (Legacy)</option>
|
||||
<option value="ai_insight">KI-Auswertung</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy}
|
||||
onClick={handleSave}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||||
>
|
||||
<Save size={16} />
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={handleDeleteDefinition}
|
||||
>
|
||||
Diesen Bericht löschen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={handleGeneratePdf}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||||
>
|
||||
<Download size={16} />
|
||||
PDF erzeugen
|
||||
</button>
|
||||
</div>
|
||||
{exportUsage && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<UsageBadge {...exportUsage} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target, LayoutGrid, LayoutDashboard } from 'lucide-react'
|
||||
import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target, LayoutDashboard, FileText } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useProfile } from '../context/ProfileContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
|
@ -458,44 +458,6 @@ export default function SettingsPage() {
|
|||
</Link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="card section-gap"
|
||||
style={{ borderStyle: 'dashed', borderColor: 'var(--border2)', background: 'var(--surface2)' }}
|
||||
>
|
||||
<div className="card-title" style={{ fontSize: 14 }}>
|
||||
Pilot: Visualisierungs-Module
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.5 }}>
|
||||
Ziel-Übersicht-Pilot: Schnelleingabe, KPIs, Körper-Chart, Aktivität. Die reguläre Übersicht konfigurierst du
|
||||
unter <strong>Übersicht anpassen</strong> oben.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Link
|
||||
to="/pilot/viz"
|
||||
className="btn btn-secondary btn-full"
|
||||
style={{ textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box' }}
|
||||
>
|
||||
Pilot öffnen
|
||||
</Link>
|
||||
<Link
|
||||
to="/app/dashboard-lab"
|
||||
className="btn btn-secondary btn-full"
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
textDecoration: 'none',
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<LayoutGrid size={18} />
|
||||
Dashboard-Lab (Layout API)
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth actions */}
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">🔐 Konto</div>
|
||||
|
|
@ -534,6 +496,41 @@ export default function SettingsPage() {
|
|||
<FeatureUsageOverview />
|
||||
</div>
|
||||
|
||||
{/* PDF-Berichte: eigener Tab (wie Übersicht) */}
|
||||
<div className="card section-gap">
|
||||
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<FileText size={18} color="var(--accent)" />
|
||||
PDF-Berichte
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.65 }}>
|
||||
Mehrere strukturierte Berichte, Zeiträume pro Verlauf-Bundle und PDF-Erzeugung findest du im
|
||||
eigenen Bereich — analog „Übersicht“ in den Einstellungen.
|
||||
</p>
|
||||
{!canExport && (
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
background: '#FCEBEB',
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
color: '#D85A30',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
PDF nutzt dasselbe Kontingent wie Datenexporte. Bitte Admin kontaktieren.
|
||||
</div>
|
||||
)}
|
||||
{canExport && (
|
||||
<Link
|
||||
to="/settings/reports"
|
||||
className="btn btn-primary btn-full"
|
||||
style={{ textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box' }}
|
||||
>
|
||||
Zu den PDF-Berichten
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Export */}
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Daten exportieren</div>
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export const api = {
|
|||
getProfile: () => req('/profile'),
|
||||
updateActiveProfile:(d)=> req('/profile', jput(d)),
|
||||
|
||||
// App-Bereich: Dashboard-Lab (Layout JSON, Issue #65) + Widget-Katalog
|
||||
// App-Bereich: konfigurierbares Dashboard (Layout JSON) + Widget-Katalog
|
||||
getAppWidgetsCatalog: () => req('/app/widgets/catalog'),
|
||||
getAppDashboardLayout: () => req('/app/dashboard-layout'),
|
||||
putAppDashboardLayout: (layout) => req('/app/dashboard-layout', jput(layout)),
|
||||
|
|
@ -266,6 +266,45 @@ export const api = {
|
|||
window.URL.revokeObjectURL(url)
|
||||
},
|
||||
|
||||
// Strukturierter PDF-Bericht (Profil v1, unabhängig vom Dashboard)
|
||||
getReportsCatalog: () => req('/reports/catalog'),
|
||||
listReportDefinitions: () => req('/reports/definitions'),
|
||||
createReportDefinition: (body) => req('/reports/definitions', json(body ?? {})),
|
||||
updateReportDefinition: (id, body) =>
|
||||
req(`/reports/definitions/${encodeURIComponent(id)}`, jput(body)),
|
||||
deleteReportDefinition: (id) =>
|
||||
req(`/reports/definitions/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
generateStructuredReportPdf: async (definitionId) => {
|
||||
const res = await fetch(`${BASE}/reports/generate-pdf`, {
|
||||
method: 'POST',
|
||||
headers: { ...hdrs(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(definitionId ? { definition_id: definitionId } : {}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
let msg = `HTTP ${res.status}`
|
||||
try {
|
||||
const d = await res.json()
|
||||
msg = formatFastApiDetail(d.detail, msg)
|
||||
} catch {
|
||||
const t = await res.text()
|
||||
if (t) msg = t
|
||||
}
|
||||
throw new Error(msg)
|
||||
}
|
||||
const cd = res.headers.get('Content-Disposition') || ''
|
||||
const m = /filename="([^"]+)"/.exec(cd)
|
||||
const filename = m ? m[1] : `mitai-bericht-${new Date().toISOString().split('T')[0]}.pdf`
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
},
|
||||
|
||||
// Admin
|
||||
adminListProfiles: () => req('/admin/profiles'),
|
||||
adminCreateProfile: (d) => req('/admin/profiles',json(d)),
|
||||
|
|
|
|||
69
frontend/src/utils/dashboardPdfExport.js
Normal file
69
frontend/src/utils/dashboardPdfExport.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
export const DASHBOARD_PDF_CAPTURE_ROOT_ID = 'dashboard-pdf-capture-root'
|
||||
|
||||
/**
|
||||
* @param {{ scale?: number, filenameBase?: string }} [opts]
|
||||
*/
|
||||
export async function exportDashboardToPdf(opts = {}) {
|
||||
const scale = opts.scale ?? 2
|
||||
const filenameBase = opts.filenameBase ?? 'mitai-uebersicht'
|
||||
|
||||
const [{ default: html2canvas }, { jsPDF }] = await Promise.all([
|
||||
import('html2canvas'),
|
||||
import('jspdf'),
|
||||
])
|
||||
|
||||
const el = document.getElementById(DASHBOARD_PDF_CAPTURE_ROOT_ID)
|
||||
if (!el) throw new Error('Dashboard-Inhalt nicht gefunden (interner Fehler).')
|
||||
|
||||
const prevScroll = window.scrollY
|
||||
window.scrollTo(0, 0)
|
||||
await new Promise((r) => requestAnimationFrame(r))
|
||||
await new Promise((r) => requestAnimationFrame(r))
|
||||
await new Promise((r) => setTimeout(r, 320))
|
||||
|
||||
try {
|
||||
const canvas = await html2canvas(el, {
|
||||
scale,
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
logging: false,
|
||||
backgroundColor: '#ffffff',
|
||||
ignoreElements: (node) => node?.getAttribute?.('data-dashboard-pdf-exclude') === 'true',
|
||||
scrollX: 0,
|
||||
scrollY: 0,
|
||||
width: el.scrollWidth,
|
||||
height: el.scrollHeight,
|
||||
windowWidth: el.scrollWidth,
|
||||
windowHeight: el.scrollHeight,
|
||||
})
|
||||
|
||||
const imgData = canvas.toDataURL('image/png', 1.0)
|
||||
const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4', compress: true })
|
||||
const pageW = pdf.internal.pageSize.getWidth()
|
||||
const pageH = pdf.internal.pageSize.getHeight()
|
||||
const margin = 8
|
||||
const innerW = pageW - 2 * margin
|
||||
const innerH = pageH - 2 * margin
|
||||
const imgW = innerW
|
||||
const imgH = (canvas.height * imgW) / canvas.width
|
||||
|
||||
let position = margin
|
||||
|
||||
pdf.addImage(imgData, 'PNG', margin, position, imgW, imgH, undefined, 'FAST')
|
||||
let heightLeft = imgH - innerH
|
||||
|
||||
while (heightLeft > 0) {
|
||||
position = margin - (imgH - heightLeft)
|
||||
pdf.addPage()
|
||||
pdf.addImage(imgData, 'PNG', margin, position, imgW, imgH, undefined, 'FAST')
|
||||
heightLeft -= innerH
|
||||
}
|
||||
|
||||
const safe = String(filenameBase)
|
||||
.replace(/[\\/:*?"<>|]+/g, '')
|
||||
.trim()
|
||||
pdf.save(`${safe || 'bericht'}.pdf`)
|
||||
} finally {
|
||||
window.scrollTo(0, prevScroll)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Sichtbarkeit der Teile im Schnelleingabe-Widget (Dashboard-Lab).
|
||||
* Sichtbarkeit der Teile im Schnelleingabe-Widget (Übersicht anpassen).
|
||||
* Default: alle sichtbar (leeres config).
|
||||
*/
|
||||
const KEYS = [
|
||||
|
|
|
|||
66
frontend/src/widgetSystem/ReportExportConfigEditor.jsx
Normal file
66
frontend/src/widgetSystem/ReportExportConfigEditor.jsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { normalizeReportExportConfig } from './reportExportConfig'
|
||||
|
||||
function buildStoredConfig(n) {
|
||||
const out = {}
|
||||
if (n.document_title) out.document_title = n.document_title
|
||||
if (n.subtitle) out.subtitle = n.subtitle
|
||||
if (n.capture_scale !== 2) out.capture_scale = n.capture_scale
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* config: Record<string, unknown>,
|
||||
* onChange: (next: Record<string, unknown>) => void
|
||||
* }} props
|
||||
*/
|
||||
export default function ReportExportConfigEditor({ config, onChange }) {
|
||||
const n = normalizeReportExportConfig(config)
|
||||
|
||||
const push = (partial) => {
|
||||
const merged = normalizeReportExportConfig({ ...(config || {}), ...partial })
|
||||
onChange(buildStoredConfig(merged))
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 12, marginLeft: 28, maxWidth: 440 }}>
|
||||
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 4 }}>
|
||||
Dokumenttitel (optional, max. 120 Zeichen)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
maxLength={120}
|
||||
placeholder="Leer = Profilname + „Übersicht“"
|
||||
value={n.document_title}
|
||||
onChange={(e) => push({ document_title: e.target.value })}
|
||||
/>
|
||||
<label
|
||||
style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginTop: 10, marginBottom: 4 }}
|
||||
>
|
||||
Untertitel (optional, max. 240 Zeichen)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
maxLength={240}
|
||||
placeholder="Leer = Datum/Uhrzeit"
|
||||
value={n.subtitle}
|
||||
onChange={(e) => push({ subtitle: e.target.value })}
|
||||
/>
|
||||
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginTop: 10, marginBottom: 4 }}>
|
||||
PDF-Auflösung (1 = schneller/kleiner, 3 = schärfere Grafiken)
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ maxWidth: 120 }}
|
||||
value={String(n.capture_scale)}
|
||||
onChange={(e) => push({ capture_scale: Number(e.target.value) })}
|
||||
>
|
||||
<option value="1">1×</option>
|
||||
<option value="2">2× (Standard)</option>
|
||||
<option value="3">3×</option>
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
/**
|
||||
* Standard-Layout v1 (nur Pilot `/pilot/viz` ohne API).
|
||||
* API-Nutzer: default_layout aus Backend (alle Katalog-IDs; aktiv = DEFAULT_LAB_WIDGET_IDS).
|
||||
* Diese Datei: kompakte feste 5 Widgets für den Pilot – nicht automatisch alle P1-Widgets.
|
||||
*/
|
||||
export const DEFAULT_LAB_LAYOUT = {
|
||||
version: 1,
|
||||
widgets: [
|
||||
{ id: 'welcome', enabled: true },
|
||||
{ id: 'quick_capture', enabled: true },
|
||||
{ id: 'kpi_board', enabled: true },
|
||||
{ id: 'body_overview', enabled: true },
|
||||
{ id: 'activity_overview', enabled: true },
|
||||
],
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
/**
|
||||
* Pilot/Lab-Widgets registrieren. IDs müssen zu backend/widget_catalog.WIDGET_CATALOG passen.
|
||||
* Dashboard-Widget-Registry: Katalog-IDs aus backend/widget_catalog.WIDGET_CATALOG → React-Komponenten.
|
||||
*/
|
||||
import PilotWelcome from '../components/pilot/PilotWelcome'
|
||||
import PilotQuickCapture from '../components/pilot/PilotQuickCapture'
|
||||
import PilotKpiBoard from '../components/pilot/PilotKpiBoard'
|
||||
import PilotBodySection from '../components/pilot/PilotBodySection'
|
||||
import PilotActivitySection from '../components/pilot/PilotActivitySection'
|
||||
import WelcomeWidget from '../components/dashboard-widgets-legacy/WelcomeWidget'
|
||||
import QuickCaptureWidget from '../components/dashboard-widgets-legacy/QuickCaptureWidget'
|
||||
import KpiBoardWidget from '../components/dashboard-widgets-legacy/KpiBoardWidget'
|
||||
import BodyOverviewWidget from '../components/dashboard-widgets-legacy/BodyOverviewWidget'
|
||||
import ActivityOverviewWidget from '../components/dashboard-widgets-legacy/ActivityOverviewWidget'
|
||||
import DashboardGreetingWidget from '../components/dashboard-widgets/DashboardGreetingWidget'
|
||||
import QuickWeightTodayWidget from '../components/dashboard-widgets/QuickWeightTodayWidget'
|
||||
import BodyStatStripWidget from '../components/dashboard-widgets/BodyStatStripWidget'
|
||||
|
|
@ -29,23 +29,25 @@ import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotos
|
|||
import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget'
|
||||
import GoalsFocusTeaserWidget from '../components/dashboard-widgets/GoalsFocusTeaserWidget'
|
||||
import AiPipelineInsightWidget from '../components/dashboard-widgets/AiPipelineInsightWidget'
|
||||
import ReportExportWidget from '../components/dashboard-widgets/ReportExportWidget'
|
||||
import { normalizeBodyChartDays } from './bodyChartDays'
|
||||
import { normalizeReportExportConfig } from './reportExportConfig'
|
||||
import { registerDashboardWidget } from './dashboardWidgetRegistry'
|
||||
|
||||
let _registered = false
|
||||
|
||||
export function ensurePilotLabWidgetsRegistered() {
|
||||
export function ensureDashboardWidgetsRegistered() {
|
||||
if (_registered) return
|
||||
_registered = true
|
||||
|
||||
registerDashboardWidget({
|
||||
id: 'welcome',
|
||||
Component: PilotWelcome,
|
||||
Component: WelcomeWidget,
|
||||
mapProps: () => ({}),
|
||||
})
|
||||
registerDashboardWidget({
|
||||
id: 'quick_capture',
|
||||
Component: PilotQuickCapture,
|
||||
Component: QuickCaptureWidget,
|
||||
mapProps: (ctx) => ({
|
||||
onSaved: ctx.requestRefresh,
|
||||
captureConfig: ctx.layoutEntry?.config || {},
|
||||
|
|
@ -53,7 +55,7 @@ export function ensurePilotLabWidgetsRegistered() {
|
|||
})
|
||||
registerDashboardWidget({
|
||||
id: 'kpi_board',
|
||||
Component: PilotKpiBoard,
|
||||
Component: KpiBoardWidget,
|
||||
mapProps: (ctx) => ({
|
||||
refreshTick: ctx.refreshTick,
|
||||
kpiConfig: ctx.layoutEntry?.config || {},
|
||||
|
|
@ -61,7 +63,7 @@ export function ensurePilotLabWidgetsRegistered() {
|
|||
})
|
||||
registerDashboardWidget({
|
||||
id: 'body_overview',
|
||||
Component: PilotBodySection,
|
||||
Component: BodyOverviewWidget,
|
||||
mapProps: (ctx) => ({
|
||||
refreshTick: ctx.refreshTick,
|
||||
chartDays: normalizeBodyChartDays(ctx.layoutEntry?.config?.chart_days),
|
||||
|
|
@ -77,7 +79,7 @@ export function ensurePilotLabWidgetsRegistered() {
|
|||
})
|
||||
registerDashboardWidget({
|
||||
id: 'activity_overview',
|
||||
Component: PilotActivitySection,
|
||||
Component: ActivityOverviewWidget,
|
||||
mapProps: (ctx) => ({
|
||||
refreshTick: ctx.refreshTick,
|
||||
chartDays: normalizeBodyChartDays(ctx.layoutEntry?.config?.chart_days),
|
||||
|
|
@ -190,9 +192,16 @@ export function ensurePilotLabWidgetsRegistered() {
|
|||
Component: AiPipelineInsightWidget,
|
||||
mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }),
|
||||
})
|
||||
registerDashboardWidget({
|
||||
id: 'report_export',
|
||||
Component: ReportExportWidget,
|
||||
mapProps: (ctx) => ({
|
||||
reportExportConfig: normalizeReportExportConfig(ctx.layoutEntry?.config),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/** @internal Nur für Tests */
|
||||
export function __resetPilotLabRegistrationForTests() {
|
||||
export function __resetDashboardWidgetRegistrationForTests() {
|
||||
_registered = false
|
||||
}
|
||||
15
frontend/src/widgetSystem/reportExportConfig.js
Normal file
15
frontend/src/widgetSystem/reportExportConfig.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* @param {Record<string, unknown> | null | undefined} raw
|
||||
* @returns {{ document_title: string, subtitle: string, capture_scale: number }}
|
||||
*/
|
||||
export function normalizeReportExportConfig(raw) {
|
||||
const c = raw && typeof raw === 'object' ? raw : {}
|
||||
let capture_scale = 2
|
||||
if (c.capture_scale != null && c.capture_scale !== '') {
|
||||
const n = Number(c.capture_scale)
|
||||
if (Number.isFinite(n)) capture_scale = Math.min(3, Math.max(1, Math.round(n)))
|
||||
}
|
||||
const dt = typeof c.document_title === 'string' ? c.document_title.trim().slice(0, 120) : ''
|
||||
const st = typeof c.subtitle === 'string' ? c.subtitle.trim().slice(0, 240) : ''
|
||||
return { document_title: dt, subtitle: st, capture_scale }
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user