pdf generator #103

Merged
Lars merged 5 commits from develop into main 2026-04-29 21:55:59 +02:00
45 changed files with 2465 additions and 690 deletions

View File

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

View File

@ -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 790.
### 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` |

View 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` 7365
- `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).

View File

@ -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).

View File

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

View File

@ -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,

View File

@ -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 13 sein")
if not isinstance(v, int):
raise ValueError(f"{label}: capture_scale muss ganze Zahl 13 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

View File

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

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

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

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

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

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

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

View 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 (BrustTaille)")
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"]))

View File

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

View File

@ -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
View 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}"'},
)

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

View File

@ -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(

View File

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

View File

@ -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",

View File

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

View File

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

View File

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

View File

@ -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 (

View File

@ -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()

View File

@ -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%' }}

View File

@ -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>
)

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

View File

@ -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' },
]

View File

@ -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>
)

View File

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

View File

@ -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 (790), 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 790 Tage; <strong>KPI</strong>: Kacheln
wählen &amp; 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>
)
}

View File

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

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

View File

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

View File

@ -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)),

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

View File

@ -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 = [

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

View File

@ -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 },
],
}

View File

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

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