From ddc87ba5ae9dc367ec35cb13c51c85d60e8b5c1c Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 23 Apr 2026 15:24:13 +0200 Subject: [PATCH 1/5] feat: remove deprecated demo route and enhance dashboard widget registration - Removed outdated visualization demo route and fixed demo layout in the frontend. - Updated widget registration logic in `frontend/src/widgetSystem/registerDashboardWidgets.js` to ensure proper integration of core widgets. - Adjusted documentation in `.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` and comments in `backend/widget_catalog.py` to reflect changes. - Added new dashboard widgets for activity and body overview, enhancing user experience and data visualization capabilities. - Bumped application version to reflect these changes. --- .../DASHBOARD_WIDGETS_AGENT_GUIDE.md | 8 ++-- CLAUDE.md | 5 ++ backend/widget_catalog.py | 2 +- frontend/src/App.jsx | 2 - .../ActivityOverviewWidget.jsx} | 6 +-- .../BodyOverviewWidget.jsx} | 8 ++-- .../DashboardRuleCard.jsx} | 2 +- .../KpiBoardWidget.jsx} | 2 +- .../QuickCaptureWidget.jsx} | 14 +++--- .../WelcomeWidget.jsx} | 4 +- frontend/src/pages/Dashboard.jsx | 4 +- frontend/src/pages/DashboardConfigurePage.jsx | 4 +- frontend/src/pages/DashboardLabPage.jsx | 14 +++--- frontend/src/pages/PilotVizPage.jsx | 45 ------------------ frontend/src/pages/SettingsPage.jsx | 47 ++++++++----------- .../dashboardChartUtils.js} | 0 frontend/src/widgetSystem/defaultLabLayout.js | 15 ------ ...Widgets.js => registerDashboardWidgets.js} | 26 +++++----- 18 files changed, 70 insertions(+), 138 deletions(-) rename frontend/src/components/{pilot/PilotActivitySection.jsx => dashboard-widgets-legacy/ActivityOverviewWidget.jsx} (94%) rename frontend/src/components/{pilot/PilotBodySection.jsx => dashboard-widgets-legacy/BodyOverviewWidget.jsx} (96%) rename frontend/src/components/{pilot/PilotRuleCard.jsx => dashboard-widgets-legacy/DashboardRuleCard.jsx} (97%) rename frontend/src/components/{pilot/PilotKpiBoard.jsx => dashboard-widgets-legacy/KpiBoardWidget.jsx} (99%) rename frontend/src/components/{pilot/PilotQuickCapture.jsx => dashboard-widgets-legacy/QuickCaptureWidget.jsx} (97%) rename frontend/src/components/{pilot/PilotWelcome.jsx => dashboard-widgets-legacy/WelcomeWidget.jsx} (82%) delete mode 100644 frontend/src/pages/PilotVizPage.jsx rename frontend/src/{pilot/pilotChartUtils.js => widgetSystem/dashboardChartUtils.js} (100%) delete mode 100644 frontend/src/widgetSystem/defaultLabLayout.js rename frontend/src/widgetSystem/{registerPilotLabWidgets.js => registerDashboardWidgets.js} (89%) diff --git a/.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md b/.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md index 7fafcac..d0c53da 100644 --- a/.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md +++ b/.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md @@ -42,7 +42,7 @@ 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. +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. **Dashboard-Lab-UI** – `frontend/src/pages/DashboardLabPage.jsx`: Umsortieren, Ein/Aus, Speichern; **zusätzliche** UI nur nötig, wenn das Widget konfigurierbare Felder braucht. --- @@ -53,8 +53,8 @@ Kontext: **Dashboard-Lab** unter geschützten Endpoints `GET/PUT /api/app/...` ( |--------|--------|--------| | A | `backend/widget_catalog.py` | Neuen Eintrag `{ "id", "title", "description" }` in `WIDGET_CATALOG` einfügen (Reihenfolge = Default-Reihenfolge im Layout). Optional `"requires_feature": ""` 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. | +| 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`. | @@ -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` | +| Dashboard-Widget-Registrierung | `frontend/src/widgetSystem/registerDashboardWidgets.js` | | Lab-UI | `frontend/src/pages/DashboardLabPage.jsx` | diff --git a/CLAUDE.md b/CLAUDE.md index 7b3291c..a8384c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.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`. +- **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) diff --git a/backend/widget_catalog.py b/backend/widget_catalog.py index e83720d..fc6ee57 100644 --- a/backend/widget_catalog.py +++ b/backend/widget_catalog.py @@ -2,7 +2,7 @@ Öffentlicher Widget-Katalog (Dashboard-Lab / später Produkt-Dashboard). 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 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4e397fe..fe9e5e1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -25,7 +25,6 @@ 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 GuidePage from './pages/GuidePage' @@ -271,7 +270,6 @@ function AppShell() { }/> }/> - } /> } /> diff --git a/frontend/src/components/pilot/PilotActivitySection.jsx b/frontend/src/components/dashboard-widgets-legacy/ActivityOverviewWidget.jsx similarity index 94% rename from frontend/src/components/pilot/PilotActivitySection.jsx rename to frontend/src/components/dashboard-widgets-legacy/ActivityOverviewWidget.jsx index f4e23a6..a8f912a 100644 --- a/frontend/src/components/pilot/PilotActivitySection.jsx +++ b/frontend/src/components/dashboard-widgets-legacy/ActivityOverviewWidget.jsx @@ -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

) : ( - actRules.map((item, i) => ) + actRules.map((item, i) => ) )} diff --git a/frontend/src/components/pilot/PilotBodySection.jsx b/frontend/src/components/dashboard-widgets-legacy/BodyOverviewWidget.jsx similarity index 96% rename from frontend/src/components/pilot/PilotBodySection.jsx rename to frontend/src/components/dashboard-widgets-legacy/BodyOverviewWidget.jsx index 18a29f6..3ee41c0 100644 --- a/frontend/src/components/pilot/PilotBodySection.jsx +++ b/frontend/src/components/dashboard-widgets-legacy/BodyOverviewWidget.jsx @@ -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).

{rules.map((item, i) => ( - + ))} )} diff --git a/frontend/src/components/pilot/PilotRuleCard.jsx b/frontend/src/components/dashboard-widgets-legacy/DashboardRuleCard.jsx similarity index 97% rename from frontend/src/components/pilot/PilotRuleCard.jsx rename to frontend/src/components/dashboard-widgets-legacy/DashboardRuleCard.jsx index 55bcc6f..c857148 100644 --- a/frontend/src/components/pilot/PilotRuleCard.jsx +++ b/frontend/src/components/dashboard-widgets-legacy/DashboardRuleCard.jsx @@ -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 ( diff --git a/frontend/src/components/pilot/PilotKpiBoard.jsx b/frontend/src/components/dashboard-widgets-legacy/KpiBoardWidget.jsx similarity index 99% rename from frontend/src/components/pilot/PilotKpiBoard.jsx rename to frontend/src/components/dashboard-widgets-legacy/KpiBoardWidget.jsx index b98e60c..0f4bd00 100644 --- a/frontend/src/components/pilot/PilotKpiBoard.jsx +++ b/frontend/src/components/dashboard-widgets-legacy/KpiBoardWidget.jsx @@ -38,7 +38,7 @@ function buildAutoTileIds(refTiles, hasBf, hasKcal) { * @param {{ refreshTick?: number, kpiConfig?: Record }} 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() diff --git a/frontend/src/components/pilot/PilotQuickCapture.jsx b/frontend/src/components/dashboard-widgets-legacy/QuickCaptureWidget.jsx similarity index 97% rename from frontend/src/components/pilot/PilotQuickCapture.jsx rename to frontend/src/components/dashboard-widgets-legacy/QuickCaptureWidget.jsx index 2854bee..55c7899 100644 --- a/frontend/src/components/pilot/PilotQuickCapture.jsx +++ b/frontend/src/components/dashboard-widgets-legacy/QuickCaptureWidget.jsx @@ -9,7 +9,7 @@ import { api } from '../../utils/api' * @param {{ onSaved?: () => void, captureConfig?: Record }} 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 @@ -200,7 +200,7 @@ export default function PilotQuickCapture({ onSaved, captureConfig }) { {showRestingHr && (
@@ -12,7 +12,7 @@ export default function PilotWelcome() { Hallo, {activeProfile?.name || 'Nutzer'} 👋

- {dayjs().format('dddd, DD. MMMM YYYY')} · Pilot-Übersicht + {dayjs().format('dddd, DD. MMMM YYYY')} · Übersicht

) diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index c8d9d2d..3a55779 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -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(() => { diff --git a/frontend/src/pages/DashboardConfigurePage.jsx b/frontend/src/pages/DashboardConfigurePage.jsx index d8b5c55..51d2308 100644 --- a/frontend/src/pages/DashboardConfigurePage.jsx +++ b/frontend/src/pages/DashboardConfigurePage.jsx @@ -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, @@ -46,7 +46,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) diff --git a/frontend/src/pages/DashboardLabPage.jsx b/frontend/src/pages/DashboardLabPage.jsx index 736adc1..93bf4e0 100644 --- a/frontend/src/pages/DashboardLabPage.jsx +++ b/frontend/src/pages/DashboardLabPage.jsx @@ -3,7 +3,7 @@ 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 { ensureDashboardWidgetsRegistered } from '../widgetSystem/registerDashboardWidgets' import { BODY_CHART_DAYS_DEFAULT, BODY_CHART_DAYS_MAX, @@ -38,7 +38,7 @@ function catalogMetaById(catalog) { } export default function DashboardLabPage() { - ensurePilotLabWidgetsRegistered() + ensureDashboardWidgetsRegistered() const [refreshTick, setRefreshTick] = useState(0) const requestRefresh = () => setRefreshTick((t) => t + 1) @@ -198,13 +198,11 @@ export default function DashboardLabPage() {

Widget-System: Katalog, Registry, Renderer; optional pro Widget config (z. B.{' '} Körper / Aktivität: Zeitraum 7–90 Tage; KPI: Kacheln - wählen & sortieren). Layout pro Profil in der DB — - getrennt vom Produktiv-Dashboard. - Vergleich:{' '} - - Pilot-Übersicht (festes Standard-Layout) + wählen & sortieren). Layout pro Profil in der DB — dieselben Widgets wie auf der{' '} + + Produkt-Übersicht - . + , hier mit Editor und API-Fokus.

diff --git a/frontend/src/pages/PilotVizPage.jsx b/frontend/src/pages/PilotVizPage.jsx deleted file mode 100644 index a45351b..0000000 --- a/frontend/src/pages/PilotVizPage.jsx +++ /dev/null @@ -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 ( -
-
- - ← Einstellungen - -

- - Pilot: Übersicht -

-

- Konfigurierbare Ziel-Übersicht (Test). Produktives Dashboard und Verlauf unverändert. Nach Speichern von - Gewicht oder Vitalwerten werden KPIs und Körperbereich neu geladen. -

-
- - -
- ) -} diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 32cf8c4..cfd1f33 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -463,37 +463,28 @@ export default function SettingsPage() { style={{ borderStyle: 'dashed', borderColor: 'var(--border2)', background: 'var(--surface2)' }} >
- Pilot: Visualisierungs-Module + Entwickler: Dashboard-Layout (API)

- Ziel-Übersicht-Pilot: Schnelleingabe, KPIs, Körper-Chart, Aktivität. Die reguläre Übersicht konfigurierst du - unter Übersicht anpassen oben. + Experimentelles Layout-Lab mit Katalog und API (getrennt von der regulären Übersicht). Die produktive Kachelansicht + steuerst du über Übersicht anpassen oben.

-
- - Pilot öffnen - - - - Dashboard-Lab (Layout API) - -
+ + + Dashboard-Lab öffnen + {/* Auth actions */} diff --git a/frontend/src/pilot/pilotChartUtils.js b/frontend/src/widgetSystem/dashboardChartUtils.js similarity index 100% rename from frontend/src/pilot/pilotChartUtils.js rename to frontend/src/widgetSystem/dashboardChartUtils.js diff --git a/frontend/src/widgetSystem/defaultLabLayout.js b/frontend/src/widgetSystem/defaultLabLayout.js deleted file mode 100644 index d778b08..0000000 --- a/frontend/src/widgetSystem/defaultLabLayout.js +++ /dev/null @@ -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 }, - ], -} diff --git a/frontend/src/widgetSystem/registerPilotLabWidgets.js b/frontend/src/widgetSystem/registerDashboardWidgets.js similarity index 89% rename from frontend/src/widgetSystem/registerPilotLabWidgets.js rename to frontend/src/widgetSystem/registerDashboardWidgets.js index 7c7260c..68d5204 100644 --- a/frontend/src/widgetSystem/registerPilotLabWidgets.js +++ b/frontend/src/widgetSystem/registerDashboardWidgets.js @@ -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' @@ -34,18 +34,18 @@ 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 +53,7 @@ export function ensurePilotLabWidgetsRegistered() { }) registerDashboardWidget({ id: 'kpi_board', - Component: PilotKpiBoard, + Component: KpiBoardWidget, mapProps: (ctx) => ({ refreshTick: ctx.refreshTick, kpiConfig: ctx.layoutEntry?.config || {}, @@ -61,7 +61,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 +77,7 @@ export function ensurePilotLabWidgetsRegistered() { }) registerDashboardWidget({ id: 'activity_overview', - Component: PilotActivitySection, + Component: ActivityOverviewWidget, mapProps: (ctx) => ({ refreshTick: ctx.refreshTick, chartDays: normalizeBodyChartDays(ctx.layoutEntry?.config?.chart_days), @@ -193,6 +193,6 @@ export function ensurePilotLabWidgetsRegistered() { } /** @internal Nur für Tests */ -export function __resetPilotLabRegistrationForTests() { +export function __resetDashboardWidgetRegistrationForTests() { _registered = false } -- 2.43.0 From 141df021c1b7e5ed6244aa0ebe6f56f5bd9e1cdd Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 23 Apr 2026 16:18:10 +0200 Subject: [PATCH 2/5] refactor: rename Dashboard-Lab-Widgets to Dashboard-Widgets and update related documentation - Renamed references from "Dashboard-Lab-Widgets" to "Dashboard-Widgets" across documentation and codebase for consistency. - Removed the deprecated Dashboard-Lab page and integrated its functionality into the new Dashboard-Widgets layout. - Updated widget registration and configuration handling to reflect the new naming convention. - Adjusted documentation in `.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` and other related files to ensure clarity on the updated structure. - Bumped application version to reflect these changes. --- .claude/docs/README.md | 2 +- .../DASHBOARD_WIDGETS_AGENT_GUIDE.md | 20 +- .claude/rules/ARCHITECTURE.md | 6 +- CLAUDE.md | 6 +- backend/dashboard_layout_schema.py | 4 +- backend/main.py | 2 +- backend/routers/app_dashboard.py | 4 +- backend/widget_catalog.py | 2 +- frontend/src/App.jsx | 2 - .../QuickCaptureWidget.jsx | 5 +- frontend/src/pages/DashboardLabPage.jsx | 512 ------------------ frontend/src/pages/SettingsPage.jsx | 31 +- frontend/src/utils/api.js | 2 +- .../widgetSystem/QuickCaptureConfigEditor.jsx | 2 +- 14 files changed, 29 insertions(+), 571 deletions(-) delete mode 100644 frontend/src/pages/DashboardLabPage.jsx diff --git a/.claude/docs/README.md b/.claude/docs/README.md index 8c409d9..4e26d9a 100644 --- a/.claude/docs/README.md +++ b/.claude/docs/README.md @@ -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 | diff --git a/.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md b/.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md index d0c53da..dffae50 100644 --- a/.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md +++ b/.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md @@ -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). | @@ -43,7 +43,7 @@ Kontext: **Dashboard-Lab** unter geschützten Endpoints `GET/PUT /api/app/...` ( 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** – `ensureDashboardWidgetsRegistered()` in `frontend/src/widgetSystem/registerDashboardWidgets.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. +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,7 +52,7 @@ 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": ""` 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. | +| 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. | @@ -110,11 +110,11 @@ mapProps: (ctx) => ({ **Abgleich mit Chart-Zeitraum:** Für `chart_days` existiert `frontend/src/widgetSystem/bodyChartDays.js` (`BODY_CHART_DAYS_MIN/MAX`, `normalizeBodyChartDays`). Entweder in `mapProps` normalisieren (wie `body_overview`) oder rohen Wert durchreichen und in der Widget-Komponente normalisieren (wie `nutrition_detail_charts` / `TrendKcalWeightWidget`) – **beides** ist im Projekt vertreten; wichtig ist Konsistenz mit der Backend-Grenze 7–90. -### 3.4 Dashboard-Lab-Editor (`DashboardLabPage.jsx`) +### 3.4 Layout-Editor (`DashboardConfigurePage.jsx`) Ohne UI-Änderung bleibt `config` beim Nutzer `{}` – konfigurierbare Widgets brauchen **Editor-Controls**: -- **Einfaches Zahlfeld `chart_days`:** Eintrag in `CHART_DAYS_WIDGET_IDS` (Set oben in der Datei) + bestehendes Label/`aria-label`-Pattern für die Zeitraum-Zeile erweitern (siehe `body_overview`, `nutrition_detail_charts`). +- **Einfaches Zahlfeld `chart_days`:** Eintrag in `CHART_DAYS_WIDGET_IDS` (Set oben in `DashboardConfigurePage.jsx`) + bestehendes Label/`aria-label`-Pattern für die Zeitraum-Zeile erweitern (siehe `body_overview`, `nutrition_detail_charts`). - **Strukturierte Config (Listen, mehrere Booleans):** Eigenes Editor-Komponenten-File nach Vorbild `KpiBoardConfigEditor.jsx` / `QuickCaptureConfigEditor.jsx` einbinden und `setLayout` + `normalizeLayoutForEditor` wie bei den bestehenden Blöcken verwenden. Nach Speichern ruft die Seite `api.putAppDashboardLayout(layout)` auf; das Backend validiert über `DashboardLayoutPayload` → `validate_widget_entry_config`. @@ -137,7 +137,7 @@ Nach Speichern ruft die Seite `api.putAppDashboardLayout(layout)` auf; das Backe ## 5. API zum Prüfen - `GET /api/app/widgets/catalog` – Katalog inkl. `allowed` je Widget (Auth + `X-Profile-Id` wie andere App-Endpoints). -- `GET /api/app/dashboard-layout` – `layout` (effektiv, bereinigt), `custom`, `product_default_layout` (Übersichts-Standard), `lab_default_layout` (Dashboard-Lab-Standard). +- `GET /api/app/dashboard-layout` – `layout` (effektiv, bereinigt), `custom`, `product_default_layout` (Übersichts-Standard), `lab_default_layout` (Servertemplate für Editor/Reset; Feldname historisch). - `PUT /api/app/dashboard-layout` – Body `{ "version": 1, "widgets": [ ... ] }` (unerlaubte Widgets werden auf `enabled: false` gesetzt). --- @@ -160,4 +160,4 @@ Nach Speichern ruft die Seite `api.putAppDashboardLayout(layout)` auf; das Backe | HTTP | `backend/routers/app_dashboard.py` | | Registry + Render | `frontend/src/widgetSystem/dashboardWidgetRegistry.jsx` | | Dashboard-Widget-Registrierung | `frontend/src/widgetSystem/registerDashboardWidgets.js` | -| Lab-UI | `frontend/src/pages/DashboardLabPage.jsx` | +| Layout-Editor (Nutzer) | `frontend/src/pages/DashboardConfigurePage.jsx` | diff --git a/.claude/rules/ARCHITECTURE.md b/.claude/rules/ARCHITECTURE.md index d5e4e3b..df4b64b 100644 --- a/.claude/rules/ARCHITECTURE.md +++ b/.claude/rules/ARCHITECTURE.md @@ -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). diff --git a/CLAUDE.md b/CLAUDE.md index a8384c5..df89feb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`** | @@ -102,7 +102,7 @@ frontend/src/ ### 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`. +- **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) @@ -896,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 diff --git a/backend/dashboard_layout_schema.py b/backend/dashboard_layout_schema.py index 017ab32..df98d5d 100644 --- a/backend/dashboard_layout_schema.py +++ b/backend/dashboard_layout_schema.py @@ -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, diff --git a/backend/main.py b/backend/main.py index c6ee964..c94ef5d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -34,7 +34,7 @@ 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 csv_import, admin_csv_templates # Issue #21 Universal CSV Parser from routers import admin_training_parameters, admin_activity_attribute_profiles # EAV session metrics diff --git a/backend/routers/app_dashboard.py b/backend/routers/app_dashboard.py index fe8fdc5..36abf38 100644 --- a/backend/routers/app_dashboard.py +++ b/backend/routers/app_dashboard.py @@ -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") diff --git a/backend/widget_catalog.py b/backend/widget_catalog.py index fc6ee57..37849ba 100644 --- a/backend/widget_catalog.py +++ b/backend/widget_catalog.py @@ -1,5 +1,5 @@ """ -Ö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/registerDashboardWidgets.js, Funktion ensureDashboardWidgetsRegistered). diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fe9e5e1..dd42411 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -25,7 +25,6 @@ import Analysis from './pages/Analysis' import SettingsPage from './pages/SettingsPage' import SettingsShell from './layouts/SettingsShell' import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage' -import DashboardLabPage from './pages/DashboardLabPage' import DashboardConfigurePage from './pages/DashboardConfigurePage' import GuidePage from './pages/GuidePage' import AdminTierLimitsPage from './pages/AdminTierLimitsPage' @@ -270,7 +269,6 @@ function AppShell() {
}/> }/> - } /> diff --git a/frontend/src/components/dashboard-widgets-legacy/QuickCaptureWidget.jsx b/frontend/src/components/dashboard-widgets-legacy/QuickCaptureWidget.jsx index 55c7899..2fc8938 100644 --- a/frontend/src/components/dashboard-widgets-legacy/QuickCaptureWidget.jsx +++ b/frontend/src/components/dashboard-widgets-legacy/QuickCaptureWidget.jsx @@ -130,8 +130,9 @@ export default function QuickCaptureWidget({ onSaved, captureConfig }) {
Schnelleingabe (heute)

- Für dieses Widget sind keine Eingabebereiche aktiviert. Im Dashboard-Lab die Sichtbarkeit prüfen - oder Vitalwerte-Seite nutzen. + Für dieses Widget sind keine Eingabebereiche aktiviert. Unter{' '} + Übersicht anpassen die Schnelleingabe-Konfiguration prüfen oder{' '} + Vitalwerte-Seite nutzen.

) diff --git a/frontend/src/pages/DashboardLabPage.jsx b/frontend/src/pages/DashboardLabPage.jsx deleted file mode 100644 index 93bf4e0..0000000 --- a/frontend/src/pages/DashboardLabPage.jsx +++ /dev/null @@ -1,512 +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 { ensureDashboardWidgetsRegistered } from '../widgetSystem/registerDashboardWidgets' -import { - BODY_CHART_DAYS_DEFAULT, - BODY_CHART_DAYS_MAX, - BODY_CHART_DAYS_MIN, - normalizeBodyChartDays, -} from '../widgetSystem/bodyChartDays' -import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor' -import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor' -import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor' -import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor' -import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor' -import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor' -import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor' -import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor' - -/** Widgets mit optionalem config.chart_days (7–90), gleiche UX im Editor */ -const CHART_DAYS_WIDGET_IDS = new Set([ - 'body_overview', - 'body_history_viz', - 'activity_overview', - 'nutrition_detail_charts', - 'nutrition_history_viz', - 'fitness_history_viz', - 'recovery_history_viz', - 'history_overview_viz', - 'recovery_charts_panel', -]) - -function catalogMetaById(catalog) { - if (!catalog?.widgets?.length) return {} - return Object.fromEntries(catalog.widgets.map((w) => [w.id, w])) -} - -export default function DashboardLabPage() { - ensureDashboardWidgetsRegistered() - - 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 ( -
-

{err}

- -
- ) - } - - if (!layout) { - return ( -
-
-
- ) - } - - return ( -
-
- - ← Einstellungen - -

- - App-Bereich: Dashboard-Lab -

-

- Widget-System: Katalog, Registry, Renderer; optional pro Widget config (z. B.{' '} - Körper / Aktivität: Zeitraum 7–90 Tage; KPI: Kacheln - wählen & sortieren). Layout pro Profil in der DB — dieselben Widgets wie auf der{' '} - - Produkt-Übersicht - - , hier mit Editor und API-Fokus. -

-
- -
-
- Layout (v1) -
- {bundle && ( -

- Status: {bundle.custom ? 'individuell gespeichert' : 'Standard (nicht in DB)'} -

- )} - {err &&

{err}

} - {msg &&

{msg}

} -
    - {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 ( -
  • -
    - -
    - - -
    -
    - {w.id === 'quick_capture' && ( - - 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' && ( - - 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) && ( -
    - - - 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() - }} - /> -
    - )} - {w.id === 'body_history_viz' && ( - - 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' && ( - - 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' && ( - - 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' && ( - - 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' && ( - - 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 } } - }), - }) - ) - } - /> - )} -
  • - ) - })} -
-
- - - -
-
- - {layoutForPreview && ( - - )} -
- ) -} diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index cfd1f33..27d0cfe 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -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 } from 'lucide-react' import { Link } from 'react-router-dom' import { useProfile } from '../context/ProfileContext' import { useAuth } from '../context/AuthContext' @@ -458,35 +458,6 @@ export default function SettingsPage() {
-
-
- Entwickler: Dashboard-Layout (API) -
-

- Experimentelles Layout-Lab mit Katalog und API (getrennt von der regulären Übersicht). Die produktive Kachelansicht - steuerst du über Übersicht anpassen oben. -

- - - Dashboard-Lab öffnen - -
- {/* Auth actions */}
🔐 Konto
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 80fba9c..a6159f1 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -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)), diff --git a/frontend/src/widgetSystem/QuickCaptureConfigEditor.jsx b/frontend/src/widgetSystem/QuickCaptureConfigEditor.jsx index b861a6d..34b6b34 100644 --- a/frontend/src/widgetSystem/QuickCaptureConfigEditor.jsx +++ b/frontend/src/widgetSystem/QuickCaptureConfigEditor.jsx @@ -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 = [ -- 2.43.0 From 62729d0648cd09b0c4bfbe7c6c4e3f5eb887d2a9 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 29 Apr 2026 11:28:04 +0200 Subject: [PATCH 3/5] feat: add report_export widget and enhance report generation capabilities - Introduced the `report_export` widget to the dashboard, allowing users to generate structured PDF reports. - Updated widget configuration to include `report_export` in the allowed widgets and added validation for its configuration. - Enhanced the widget catalog with details for the new `report_export` entry. - Implemented API endpoints for managing report profiles and generating PDFs. - Added frontend components for configuring and displaying report settings. - Updated tests to ensure proper validation and functionality of the new report generation features. - Bumped application version to reflect the addition of the new widget and related functionalities. --- .../docs/technical/REPORT_PROFILES_AND_PDF.md | 56 +++ backend/dashboard_widget_config.py | 45 +++ backend/main.py | 2 + backend/migrations/060_report_profiles.sql | 11 + backend/report_chart_fetch.py | 139 ++++++++ backend/report_pdf_render.py | 212 ++++++++++++ backend/report_profile_schema.py | 92 +++++ backend/requirements.txt | 2 + backend/routers/reports.py | 156 +++++++++ backend/tests/test_report_profile_schema.py | 31 ++ backend/widget_catalog.py | 6 + frontend/package-lock.json | 6 +- frontend/package.json | 1 + .../dashboard-widgets/ReportExportWidget.jsx | 107 ++++++ frontend/src/pages/Dashboard.jsx | 4 +- frontend/src/pages/DashboardConfigurePage.jsx | 14 + frontend/src/pages/SettingsPage.jsx | 321 +++++++++++++++++- frontend/src/utils/api.js | 32 ++ frontend/src/utils/dashboardPdfExport.js | 69 ++++ .../widgetSystem/ReportExportConfigEditor.jsx | 66 ++++ .../widgetSystem/registerDashboardWidgets.js | 9 + .../src/widgetSystem/reportExportConfig.js | 15 + 22 files changed, 1389 insertions(+), 7 deletions(-) create mode 100644 .claude/docs/technical/REPORT_PROFILES_AND_PDF.md create mode 100644 backend/migrations/060_report_profiles.sql create mode 100644 backend/report_chart_fetch.py create mode 100644 backend/report_pdf_render.py create mode 100644 backend/report_profile_schema.py create mode 100644 backend/routers/reports.py create mode 100644 backend/tests/test_report_profile_schema.py create mode 100644 frontend/src/components/dashboard-widgets/ReportExportWidget.jsx create mode 100644 frontend/src/utils/dashboardPdfExport.js create mode 100644 frontend/src/widgetSystem/ReportExportConfigEditor.jsx create mode 100644 frontend/src/widgetSystem/reportExportConfig.js diff --git a/.claude/docs/technical/REPORT_PROFILES_AND_PDF.md b/.claude/docs/technical/REPORT_PROFILES_AND_PDF.md new file mode 100644 index 0000000..5f92df2 --- /dev/null +++ b/.claude/docs/technical/REPORT_PROFILES_AND_PDF.md @@ -0,0 +1,56 @@ +# Berichtsprofile & PDF (technisch) + +**Stand:** 2026-04-29 + +## Begriffe + +| Begriff | Bedeutung | +|--------|-----------| +| **Layout-Snapshot** | PDF aus gerasteter DOM-Übersicht (`html2canvas` + `jspdf`), optional Widget `report_export`. | +| **Strukturierter Bericht** | Profil mit Blöcken (`section`, `chart`, `ai_insight`), PDF serverseitig via Data Layer + Matplotlib + ReportLab. | + +Die beiden Wege sind bewusst getrennt, damit das Dashboard nicht die einzige „Wahrheit“ für Dokumente wird. + +## Datenbank + +- Tabelle `report_profiles` (Migration `060_report_profiles.sql`): `profile_id` PK → `profiles`, `payload` JSONB, `updated_at`. + +Ohne Zeile gilt ein **Code-Standard** (`default_report_profile_dict` in `report_profile_schema.py`). + +## API (`/api/reports`) + +| Methode | Pfad | Zweck | +|--------|------|--------| +| GET | `/catalog` | Diagramm-Katalog + Blocktypen für UI | +| GET | `/profile` | `{ stored, profile }` | +| PUT | `/profile` | Vollständiges Profil-JSON (Pydantic-validiert) | +| DELETE | `/profile` | DB-Zeile löschen → wieder Standard | +| POST | `/generate-pdf` | PDF-Download; `data_export`-Kontingent + `increment_feature_usage` | + +## Schema v1 (`report_profile_schema.py`) + +- `version`: nur `1` +- `document_title`: optional +- `blocks`: Liste mit Union: + - `section`: `title` + - `chart`: `chart_id` ∈ `ALLOWED_CHART_IDS`, `window_days` 7–365 + - `ai_insight`: optional `insight_id` (UUID, `ai_insights.id`), optional `title` + +## Diagrammdaten + +`report_chart_fetch.fetch_chart_payload` ruft dieselben Bausteine auf wie `/api/charts` (ohne HTTP). Erweiterung: Eintrag in `ALLOWED_CHART_IDS`, Fetcher in `_CHART_FETCHERS`, Zeile in `CHART_CATALOG_FOR_API`. + +## PDF-Rendering + +`report_pdf_render.build_structured_report_pdf`: ReportLab-Flowable-Kette, Diagramme als PNG aus Chart-Payload (Matplotlib, Agg-Backend). + +## Frontend + +- **Einstellungen:** Karte „PDF-Bericht (strukturiert)“ — Blöcke bearbeiten, speichern, Standard, PDF erzeugen. +- **Dashboard:** Widget bleibt optionaler **Schnappschuss**; Hinweis verweist auf Einstellungen. + +## Nächste sinnvolle Erweiterungen + +- Dashboard-Layout → Berichtsprofil **einmalig importieren** (Mapping-Tabelle Widget-ID → chart_id). +- KI: Insights-Auswahl in der UI statt manueller UUID. +- Weitere `chart_id`-Werte / multipage Feintuning (Seitenumbrüche pro Block). diff --git a/backend/dashboard_widget_config.py b/backend/dashboard_widget_config.py index 9e8a4a0..71e2077 100644 --- a/backend/dashboard_widget_config.py +++ b/backend/dashboard_widget_config.py @@ -25,6 +25,7 @@ WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({ "trend_kcal_weight", "nutrition_detail_charts", "recovery_charts_panel", + "report_export", }) _QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({ @@ -201,6 +202,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]: return _validate_recovery_history_viz_config({}) if widget_id == "history_overview_viz": return _validate_history_overview_viz_config({}) + if widget_id == "report_export": + return _validate_report_export_config({}) return {} if widget_id == "body_overview": @@ -227,6 +230,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]: return _validate_chart_days_only(raw, label="nutrition_detail_charts") if widget_id == "recovery_charts_panel": return _validate_chart_days_only(raw, label="recovery_charts_panel") + if widget_id == "report_export": + return _validate_report_export_config(raw) raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt") @@ -530,3 +535,43 @@ def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, A return {"chart_days": v} +def _validate_report_export_config(raw: dict[str, Any]) -> dict[str, Any]: + label = "report_export" + allowed = frozenset({"document_title", "subtitle", "capture_scale"}) + unknown = set(raw) - allowed + if unknown: + raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}") + out: dict[str, Any] = {"capture_scale": 2} + if "document_title" in raw: + t = raw["document_title"] + if t is not None and not isinstance(t, str): + raise ValueError(f"{label}: document_title muss Text sein") + s = (t or "").strip() + if len(s) > 120: + raise ValueError(f"{label}: document_title max. 120 Zeichen") + if s: + out["document_title"] = s + if "subtitle" in raw: + t = raw["subtitle"] + if t is not None and not isinstance(t, str): + raise ValueError(f"{label}: subtitle muss Text sein") + s = (t or "").strip() + if len(s) > 240: + raise ValueError(f"{label}: subtitle max. 240 Zeichen") + if s: + out["subtitle"] = s + if "capture_scale" in raw: + v = raw["capture_scale"] + if isinstance(v, bool) or isinstance(v, float): + if isinstance(v, float) and math.isfinite(v) and abs(v - round(v)) < 1e-9: + v = int(round(v)) + else: + raise ValueError(f"{label}: capture_scale muss ganze Zahl 1–3 sein") + if not isinstance(v, int): + raise ValueError(f"{label}: capture_scale muss ganze Zahl 1–3 sein") + if v < 1 or v > 3: + raise ValueError(f"{label}: capture_scale muss zwischen 1 und 3 liegen") + out["capture_scale"] = v + return out + + diff --git a/backend/main.py b/backend/main.py index c94ef5d..584ff80 100644 --- a/backend/main.py +++ b/backend/main.py @@ -35,6 +35,7 @@ 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-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 diff --git a/backend/migrations/060_report_profiles.sql b/backend/migrations/060_report_profiles.sql new file mode 100644 index 0000000..b4f2106 --- /dev/null +++ b/backend/migrations/060_report_profiles.sql @@ -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'; diff --git a/backend/report_chart_fetch.py b/backend/report_chart_fetch.py new file mode 100644 index 0000000..c725b54 --- /dev/null +++ b/backend/report_chart_fetch.py @@ -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, + }, +] diff --git a/backend/report_pdf_render.py b/backend/report_pdf_render.py new file mode 100644 index 0000000..3dee6ca --- /dev/null +++ b/backend/report_pdf_render.py @@ -0,0 +1,212 @@ +""" +PDF-Bericht aus ReportProfilePayload: ReportLab für Text/Layout, Matplotlib für Chart.js-ähnliche Payloads. +""" +from __future__ import annotations + +import io +import logging +from typing import Any +from xml.sax.saxutils import escape + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +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_profile_schema import ( + AiInsightBlock, + ChartBlock, + ReportProfilePayload, + SectionBlock, +) + +logger = logging.getLogger(__name__) + +_CONTENT_TRUNCATE = 12000 + + +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: + """Erzeugt PNG aus Chart.js-kompatiblem Payload (line, bar, pie).""" + 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() + + +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, 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"{block.chart_id}: {msg}", styles["Normal"])) + story.append(Spacer(1, 3 * mm)) + continue + try: + png = chart_payload_to_png(chart) + img_buf = io.BytesIO(png) + # Breite ~ volle Textbreite (~180mm auf A4 mit Standardrändern Platypus) + 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() diff --git a/backend/report_profile_schema.py b/backend/report_profile_schema.py new file mode 100644 index 0000000..406d3ca --- /dev/null +++ b/backend/report_profile_schema.py @@ -0,0 +1,92 @@ +""" +Konfigurierbarer PDF-Bericht v1: Payload-Schema (unabhängig vom Dashboard-Layout). + +Block-Typen: +- section: Überschrift +- 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 Literal, Union + +from pydantic import BaseModel, Field, model_validator + +ALLOWED_CHART_IDS: frozenset[str] = frozenset( + { + "weight_trend", + "energy_balance", + "macro_distribution", + "training_volume", + "training_type_distribution", + } +) + +_MAX_BLOCKS = 24 + + +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 ReportProfilePayload(BaseModel): + version: Literal[1] = 1 + document_title: str = Field(default="", max_length=120) + blocks: list[Union[SectionBlock, ChartBlock, AiInsightBlock]] + + @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="Körpergewicht"), + ChartBlock(chart_id="weight_trend", window_days=90), + SectionBlock(title="Energiebilanz"), + ChartBlock(chart_id="energy_balance", window_days=28), + SectionBlock(title="Trainingsvolumen"), + ChartBlock(chart_id="training_volume", window_days=84), + ], + ) + 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) + diff --git a/backend/requirements.txt b/backend/requirements.txt index 445b62d..d435df3 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/routers/reports.py b/backend/routers/reports.py new file mode 100644 index 0000000..99a8cfd --- /dev/null +++ b/backend/routers/reports.py @@ -0,0 +1,156 @@ +""" +Strukturierter PDF-Bericht (Profil v1): GET/PUT Profil, Katalog, PDF-Erzeugung. + +Trennung vom Dashboard-Layout; Daten aus data_layer wie /api/charts. +PDF-Zähler: data_export (wie andere Exporte). +""" +from __future__ import annotations + +import logging +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import Response +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 ( + ReportProfilePayload, + default_report_profile_dict, + parse_report_profile, +) + +router = APIRouter(prefix="/api/reports", tags=["reports"]) +logger = logging.getLogger(__name__) + + +@router.get("/catalog") +def get_reports_catalog(session: dict = Depends(require_auth)): + """Metadaten für UI: verfügbare Diagramme und Blocktypen.""" + return { + "catalog_version": 1, + "charts": CHART_CATALOG_FOR_API, + "block_types": [ + {"id": "section", "title": "Überschrift"}, + {"id": "chart", "title": "Diagramm"}, + {"id": "ai_insight", "title": "KI-Auswertung"}, + ], + } + + +def _fetch_payload_row(profile_id: str) -> dict | None: + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT payload FROM report_profiles WHERE profile_id = %s", (profile_id,)) + row = cur.fetchone() + if not row: + return None + p = row.get("payload") + return p if isinstance(p, dict) else 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" + + +@router.get("/profile") +def get_report_profile(session: dict = Depends(require_auth)): + pid = session["profile_id"] + raw = _fetch_payload_row(pid) + if raw is None: + return {"stored": False, "profile": default_report_profile_dict()} + try: + parse_report_profile(raw) + except Exception as e: + logger.warning("report profile invalid for %s: %s", pid, e) + return {"stored": False, "profile": default_report_profile_dict(), "previous_invalid": True} + return {"stored": True, "profile": raw} + + +@router.put("/profile") +def put_report_profile(body: dict, session: dict = Depends(require_auth)): + pid = session["profile_id"] + try: + parsed = ReportProfilePayload.model_validate(body) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + INSERT INTO report_profiles (profile_id, payload, updated_at) + VALUES (%s, %s, CURRENT_TIMESTAMP) + ON CONFLICT (profile_id) DO UPDATE SET + payload = EXCLUDED.payload, + updated_at = CURRENT_TIMESTAMP + """, + (pid, Json(parsed.to_stored_dict())), + ) + conn.commit() + return {"ok": True, "profile": parsed.to_stored_dict()} + + +@router.delete("/profile") +def delete_report_profile(session: dict = Depends(require_auth)): + """Zurück auf Code-Standard (kein DB-Eintrag).""" + pid = session["profile_id"] + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("DELETE FROM report_profiles WHERE profile_id = %s", (pid,)) + conn.commit() + return {"ok": True, "profile": default_report_profile_dict()} + + +@router.post("/generate-pdf") +def generate_structured_report_pdf(session: dict = Depends(require_auth)): + pid = session["profile_id"] + + 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 = _fetch_payload_row(pid) + try: + payload = parse_report_profile(raw) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Berichtsprofil ungültig: {e}") + + name = _profile_display_name(pid) + try: + pdf_bytes = build_structured_report_pdf(profile_id=pid, profile_name=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") + safe_name = "".join(c for c in name if c.isalnum() or c in (" ", "-", "_")).strip() or "profil" + 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}"'}, + ) diff --git a/backend/tests/test_report_profile_schema.py b/backend/tests/test_report_profile_schema.py new file mode 100644 index 0000000..4411c76 --- /dev/null +++ b/backend/tests/test_report_profile_schema.py @@ -0,0 +1,31 @@ +"""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) diff --git a/backend/widget_catalog.py b/backend/widget_catalog.py index 37849ba..5dc3986 100644 --- a/backend/widget_catalog.py +++ b/backend/widget_catalog.py @@ -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( diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6c0c00d..ecfb10e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" } diff --git a/frontend/package.json b/frontend/package.json index fd1d304..bb45e90 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/dashboard-widgets/ReportExportWidget.jsx b/frontend/src/components/dashboard-widgets/ReportExportWidget.jsx new file mode 100644 index 0000000..64f1814 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/ReportExportWidget.jsx @@ -0,0 +1,107 @@ +import { useState } from 'react' +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 ( +
+
+ + Übersicht als Bild-PDF +
+
+

+ {title} +

+

{subtitle}

+
+
+

+ Layout-Schnappschuss: Die sichtbare Übersicht wird im Browser gerastert (html2canvas). + Für einen datenbasierten Bericht unabhängig vom Dashboard nutze{' '} + Einstellungen → PDF-Bericht (strukturiert). +

+
+ {!canExport ? ( +

+ PDF-Export ist für dieses Profil nicht freigeschaltet. +

+ ) : ( + <> + {err && ( +

+ {err} +

+ )} + + + )} +
+
+
+ ) +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 3a55779..384e3a1 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -123,7 +123,9 @@ export default function Dashboard() { )} {!layoutLoading && layoutForPreview && ( - +
+ +
)}
) diff --git a/frontend/src/pages/DashboardConfigurePage.jsx b/frontend/src/pages/DashboardConfigurePage.jsx index 51d2308..923cfdf 100644 --- a/frontend/src/pages/DashboardConfigurePage.jsx +++ b/frontend/src/pages/DashboardConfigurePage.jsx @@ -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, @@ -590,6 +591,19 @@ export default function DashboardConfigurePage({ adminMode = false } = {}) { } /> )} + {w.id === 'report_export' && ( + + setLayout((L) => + normalizeLayoutForEditor({ + ...L, + widgets: L.widgets.map((x, j) => (j !== i ? x : { ...x, config: { ...next } })), + }) + ) + } + /> + )} ) })} diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 27d0cfe..fc3269d 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target, LayoutDashboard } from 'lucide-react' +import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target, LayoutDashboard, FileText, Trash2 } from 'lucide-react' import { Link } from 'react-router-dom' import { useProfile } from '../context/ProfileContext' import { useAuth } from '../context/AuthContext' @@ -23,6 +23,11 @@ export default function SettingsPage() { const [newPin, setNewPin] = useState('') const [pinMsg, setPinMsg] = useState(null) const [exportUsage, setExportUsage] = useState(null) // Phase 3: Usage badge + const [reportCatalog, setReportCatalog] = useState(null) + const [reportDraft, setReportDraft] = useState(null) + const [reportStored, setReportStored] = useState(false) + const [reportBusy, setReportBusy] = useState(false) + const [reportNote, setReportNote] = useState(null) // Load feature usage for export badges useEffect(() => { @@ -32,6 +37,84 @@ export default function SettingsPage() { }).catch(err => console.error('Failed to load usage:', err)) }, []) + useEffect(() => { + if (!activeProfile?.id) return + let cancel = false + Promise.all([api.getReportsCatalog(), api.getReportProfile()]) + .then(([cat, bundle]) => { + if (cancel) return + setReportCatalog(cat) + setReportDraft(JSON.parse(JSON.stringify(bundle.profile))) + setReportStored(!!bundle.stored) + setReportNote(null) + }) + .catch((e) => console.error('report profile load', e)) + return () => { + cancel = true + } + }, [activeProfile?.id]) + + const reportNewBlock = (kind) => { + const charts = reportCatalog?.charts || [] + const first = charts[0] + if (kind === 'section') return { type: 'section', title: 'Neue Überschrift' } + 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 handleSaveReportProfile = async () => { + if (!reportDraft) return + if (!reportDraft.blocks?.length) { + setReportNote({ type: 'err', text: 'Mindestens ein Block erforderlich.' }) + return + } + setReportBusy(true) + setReportNote(null) + try { + await api.putReportProfile(reportDraft) + setReportStored(true) + setReportNote({ type: 'ok', text: 'Berichtsprofil gespeichert.' }) + } catch (e) { + setReportNote({ type: 'err', text: e.message }) + } finally { + setReportBusy(false) + } + } + + const handleResetReportProfile = async () => { + if (!confirm('Persönliches Berichtsprofil löschen und Standard wiederherstellen?')) return + setReportBusy(true) + setReportNote(null) + try { + const bundle = await api.resetReportProfile() + setReportDraft(bundle.profile) + setReportStored(false) + setReportNote({ type: 'ok', text: 'Standard wiederhergestellt.' }) + } catch (e) { + setReportNote({ type: 'err', text: e.message }) + } finally { + setReportBusy(false) + } + } + + const handleGenerateStructuredPdf = async () => { + setReportBusy(true) + setReportNote(null) + try { + await api.generateStructuredReportPdf() + setReportNote({ type: 'ok', text: 'PDF wurde heruntergeladen.' }) + } catch (e) { + setReportNote({ type: 'err', text: e.message }) + } finally { + setReportBusy(false) + } + } + const handleLogout = async () => { if (!confirm('Ausloggen?')) return await logout() @@ -496,6 +579,242 @@ export default function SettingsPage() { + {/* Strukturierter PDF-Bericht (Profil v1) */} +
+
+ + PDF-Bericht (strukturiert) +
+

+ Eigenes Berichtsprofil: Reihenfolge, Überschriften und Diagramme — unabhängig von der + Startübersicht. Die PDF-Datei wird serverseitig aus denselben Datenquellen wie die + Chart-API erzeugt (kein Screenshot). Das unterscheidet sich vom optionalen Widget „Übersicht als + Bild-PDF“ auf der Startseite. +

+ {!canExport && ( +
+ 🔒 PDF-Bericht nutzt dasselbe Kontingent wie Datenexporte. Bitte Admin kontaktieren. +
+ )} + {reportNote && ( +
+ {reportNote.text} +
+ )} + {canExport && reportDraft && reportCatalog && ( + <> + + setReportDraft((d) => ({ ...d, document_title: e.target.value }))} + style={{ marginBottom: 14 }} + /> +
+ Blöcke {reportStored ? '' : '(Standard — noch nicht separat gespeichert)'} +
+
    + {reportDraft.blocks?.map((b, idx) => ( +
  • +
    + {b.type} + +
    + {b.type === 'section' && ( + + setReportDraft((d) => { + const blocks = d.blocks.map((x, j) => + j === idx ? { ...x, title: e.target.value } : x + ) + return { ...d, blocks } + }) + } + /> + )} + {b.type === 'chart' && ( +
    + +
    + + + setReportDraft((d) => { + const n = Number(e.target.value) + const blocks = d.blocks.map((x, j) => + j === idx ? { ...x, window_days: Number.isFinite(n) ? n : x.window_days } : x + ) + return { ...d, blocks } + }) + } + /> +
    +
    + )} + {b.type === 'ai_insight' && ( +
    + + setReportDraft((d) => { + const blocks = d.blocks.map((x, j) => + j === idx ? { ...x, title: e.target.value } : x + ) + return { ...d, blocks } + }) + } + /> + + setReportDraft((d) => { + const v = e.target.value.trim() || null + const blocks = d.blocks.map((x, j) => + j === idx ? { ...x, insight_id: v } : x + ) + return { ...d, blocks } + }) + } + /> +
    + )} +
  • + ))} +
+
+ +
+
+ + + +
+ {exportUsage && ( +
+ +
+ )} + + )} +
+ {/* Export */}
Daten exportieren
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index a6159f1..65521da 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -266,6 +266,38 @@ export const api = { window.URL.revokeObjectURL(url) }, + // Strukturierter PDF-Bericht (Profil v1, unabhängig vom Dashboard) + getReportsCatalog: () => req('/reports/catalog'), + getReportProfile: () => req('/reports/profile'), + putReportProfile: (profile) => req('/reports/profile', jput(profile)), + resetReportProfile: () => req('/reports/profile', { method: 'DELETE' }), + generateStructuredReportPdf: async () => { + const res = await fetch(`${BASE}/reports/generate-pdf`, { method: 'POST', headers: hdrs() }) + 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)), diff --git a/frontend/src/utils/dashboardPdfExport.js b/frontend/src/utils/dashboardPdfExport.js new file mode 100644 index 0000000..8b5a6f4 --- /dev/null +++ b/frontend/src/utils/dashboardPdfExport.js @@ -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) + } +} diff --git a/frontend/src/widgetSystem/ReportExportConfigEditor.jsx b/frontend/src/widgetSystem/ReportExportConfigEditor.jsx new file mode 100644 index 0000000..e6cda6e --- /dev/null +++ b/frontend/src/widgetSystem/ReportExportConfigEditor.jsx @@ -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, + * onChange: (next: Record) => void + * }} props + */ +export default function ReportExportConfigEditor({ config, onChange }) { + const n = normalizeReportExportConfig(config) + + const push = (partial) => { + const merged = normalizeReportExportConfig({ ...(config || {}), ...partial }) + onChange(buildStoredConfig(merged)) + } + + return ( +
+ + push({ document_title: e.target.value })} + /> + + push({ subtitle: e.target.value })} + /> + + +
+ ) +} diff --git a/frontend/src/widgetSystem/registerDashboardWidgets.js b/frontend/src/widgetSystem/registerDashboardWidgets.js index 68d5204..e8d2a66 100644 --- a/frontend/src/widgetSystem/registerDashboardWidgets.js +++ b/frontend/src/widgetSystem/registerDashboardWidgets.js @@ -29,7 +29,9 @@ 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 @@ -190,6 +192,13 @@ export function ensureDashboardWidgetsRegistered() { 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 */ diff --git a/frontend/src/widgetSystem/reportExportConfig.js b/frontend/src/widgetSystem/reportExportConfig.js new file mode 100644 index 0000000..7f415e2 --- /dev/null +++ b/frontend/src/widgetSystem/reportExportConfig.js @@ -0,0 +1,15 @@ +/** + * @param {Record | 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 } +} -- 2.43.0 From 3ab5dae1307a44a710ba8c94701bbc28db8facfe Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 29 Apr 2026 11:46:34 +0200 Subject: [PATCH 4/5] feat: add viz_bundle support to report generation and enhance schema - Introduced the `viz_bundle` block type to the report profile schema, allowing for the inclusion of bundled visualizations in PDF reports. - Updated the `build_structured_report_pdf` function to handle `VizBundleBlock` and append its content to the report. - Enhanced the report catalog API to include details for the new `viz_bundle` block type. - Added configuration editors for various visualization bundles in the frontend settings page. - Updated tests to validate the new `viz_bundle` functionality and ensure proper handling of report profiles. - Bumped application version to reflect these enhancements. --- backend/report_chart_plotting.py | 91 +++++ backend/report_pdf_render.py | 94 +---- backend/report_profile_schema.py | 52 ++- backend/report_viz_bundle_pdf.py | 386 ++++++++++++++++++++ backend/routers/reports.py | 14 +- backend/tests/test_report_profile_schema.py | 25 ++ frontend/src/pages/SettingsPage.jsx | 124 ++++++- 7 files changed, 682 insertions(+), 104 deletions(-) create mode 100644 backend/report_chart_plotting.py create mode 100644 backend/report_viz_bundle_pdf.py diff --git a/backend/report_chart_plotting.py b/backend/report_chart_plotting.py new file mode 100644 index 0000000..2b709de --- /dev/null +++ b/backend/report_chart_plotting.py @@ -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() diff --git a/backend/report_pdf_render.py b/backend/report_pdf_render.py index 3dee6ca..0043e54 100644 --- a/backend/report_pdf_render.py +++ b/backend/report_pdf_render.py @@ -1,5 +1,5 @@ """ -PDF-Bericht aus ReportProfilePayload: ReportLab für Text/Layout, Matplotlib für Chart.js-ähnliche Payloads. +PDF-Bericht aus ReportProfilePayload: ReportLab für Text/Layout, Matplotlib für Chart-Payloads. """ from __future__ import annotations @@ -8,10 +8,6 @@ import logging from typing import Any from xml.sax.saxutils import escape -import matplotlib - -matplotlib.use("Agg") -import matplotlib.pyplot as plt from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet from reportlab.lib.units import mm @@ -20,100 +16,21 @@ 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 _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: - """Erzeugt PNG aus Chart.js-kompatiblem Payload (line, bar, pie).""" - 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() - - def _insight_text(profile_id: str, insight_id: str | None) -> tuple[str, str]: """Returns (heading, body_text).""" if not insight_id: @@ -172,6 +89,8 @@ def build_structured_report_pdf( 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) @@ -188,7 +107,6 @@ def build_structured_report_pdf( try: png = chart_payload_to_png(chart) img_buf = io.BytesIO(png) - # Breite ~ volle Textbreite (~180mm auf A4 mit Standardrändern Platypus) iw = 170 * mm ih = 85 * mm story.append(RLImage(img_buf, width=iw, height=ih)) diff --git a/backend/report_profile_schema.py b/backend/report_profile_schema.py index 406d3ca..dc6d706 100644 --- a/backend/report_profile_schema.py +++ b/backend/report_profile_schema.py @@ -3,15 +3,18 @@ Konfigurierbarer PDF-Bericht v1: Payload-Schema (unabhängig vom Dashboard-Layou 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 Literal, Union +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", @@ -22,7 +25,17 @@ ALLOWED_CHART_IDS: frozenset[str] = frozenset( } ) -_MAX_BLOCKS = 24 +_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): @@ -48,10 +61,27 @@ class AiInsightBlock(BaseModel): 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]] + blocks: list[Union[SectionBlock, ChartBlock, AiInsightBlock, VizBundleBlock]] @model_validator(mode="after") def _blocks_limit(self) -> ReportProfilePayload: @@ -74,12 +104,16 @@ def default_report_profile_dict() -> dict: p = ReportProfilePayload( document_title="", blocks=[ - SectionBlock(title="Körpergewicht"), - ChartBlock(chart_id="weight_trend", window_days=90), - SectionBlock(title="Energiebilanz"), - ChartBlock(chart_id="energy_balance", window_days=28), - SectionBlock(title="Trainingsvolumen"), - ChartBlock(chart_id="training_volume", window_days=84), + 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() diff --git a/backend/report_viz_bundle_pdf.py b/backend/report_viz_bundle_pdf.py new file mode 100644 index 0000000..b020ff4 --- /dev/null +++ b/backend/report_viz_bundle_pdf.py @@ -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"{msg}", styles["Normal"])) + story.append(Spacer(1, 2 * mm)) + return + if caption: + story.append(Paragraph(f"{escape(caption)}", 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("Einschätzungen", 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"{cat}"] + 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("KPI-Kacheln", 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"• {cat}: {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"{escape(label)}", styles["Heading4"])) + for item in insights: + title = item.get("title") or item.get("heading") + body = item.get("body") or item.get("text") + if title: + story.append(Paragraph(escape(str(title)), styles["Normal"])) + if body: + story.append(Paragraph(escape(str(body)[:600]), styles["BodyText"])) + story.append(Spacer(1, 2 * mm)) + + +def _weight_series_payload(bundle_weight: dict[str, Any]) -> dict[str, Any] | None: + series = bundle_weight.get("series") or [] + if len(series) < 2: + return None + labels = [str(p.get("date") or "") for p in series] + datasets: list[dict[str, Any]] = [ + { + "label": "Gewicht (kg)", + "data": [safe_float(p.get("weight")) for p in series], + "borderColor": "#1D9E75", + } + ] + if any(p.get("avg7") is not None for p in series): + datasets.append( + { + "label": "Ø 7T", + "data": [safe_float(p.get("avg7")) for p in series], + "borderColor": "#378ADD", + } + ) + return {"chart_type": "line", "data": {"labels": labels, "datasets": datasets}, "metadata": {"confidence": "high"}} + + +def _line_payload_from_points( + points: list[dict[str, Any]], + x_key: str, + y_key: str, + label: str, +) -> dict[str, Any] | None: + if len(points) < 2: + return None + labels = [str(p.get(x_key) or "") for p in points] + ys = [safe_float(p.get(y_key)) for p in points] + return { + "chart_type": "line", + "data": { + "labels": labels, + "datasets": [{"label": label, "data": ys, "borderColor": "#1D9E75"}], + }, + "metadata": {"confidence": "high"}, + } + + +def _append_body_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None: + days = int(cfg.get("chart_days") or 30) + bundle = get_body_history_viz_bundle(profile_id, days) + story.append(Paragraph(escape(BUNDLE_HEADINGS["body_history_viz"]), styles["Heading2"])) + if bundle.get("confidence") == "insufficient": + story.append(Paragraph(escape(bundle.get("message") or "Keine Körperdaten"), styles["Normal"])) + story.append(Spacer(1, 4 * mm)) + return + summ = bundle.get("summary") or {} + if summ: + w = summ.get("weight_kg") + bf = summ.get("body_fat_pct") + parts = [] + if w is not None: + parts.append(f"Gewicht: {w} kg") + if bf is not None: + parts.append(f"KF%: {bf}") + if parts: + story.append(Paragraph(escape(" · ".join(parts)), styles["Normal"])) + story.append(Spacer(1, 2 * mm)) + if cfg.get("show_kpis", True): + _append_interpretation_tiles(story, styles, bundle.get("interpretation_tiles") or []) + w = bundle.get("weight") or {} + if cfg.get("show_weight_chart", True): + pl = _weight_series_payload(w) + if pl: + _add_chart_to_story(story, styles, pl, "Gewicht") + cal = bundle.get("caliper") or {} + if cfg.get("show_body_fat_chart", False): + ser = cal.get("series") or [] + pts = [{"date": p.get("date"), "y": p.get("body_fat_pct")} for p in ser if p.get("body_fat_pct") is not None] + pl = _line_payload_from_points(pts, "date", "y", "KF %") + if pl: + _add_chart_to_story(story, styles, pl, "Körperfett (Caliper)") + circ = bundle.get("circumference") or {} + if cfg.get("show_proportion_chart", False): + prop = circ.get("proportion_series") or [] + pts = [{"date": p.get("date"), "y": p.get("v_taper_cm")} for p in prop if p.get("v_taper_cm") is not None] + pl = _line_payload_from_points(pts, "date", "y", "V-Taper (cm)") + if pl: + _add_chart_to_story(story, styles, pl, "Proportion (Brust–Taille)") + if cfg.get("show_circumference_index_chart", False): + idx = circ.get("index_series") or [] + if len(idx) >= 2: + labels = [str(p.get("date") or "") for p in idx] + ds: list[dict[str, Any]] = [] + for key, lab, col in ( + ("waist_idx", "Taille-Index", "#D85A30"), + ("chest_idx", "Brust-Index", "#1D9E75"), + ("belly_idx", "Bauch-Index", "#378ADD"), + ): + ys = [safe_float(p.get(key)) for p in idx] + if any(v is not None for v in ys): + ds.append({"label": lab, "data": ys, "borderColor": col}) + if ds: + pl = {"chart_type": "line", "data": {"labels": labels, "datasets": ds}, "metadata": {"confidence": "high"}} + _add_chart_to_story(story, styles, pl, "Umfang-Indizes") + story.append(Spacer(1, 2 * mm)) + + +def _append_nutrition_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None: + days = int(cfg.get("chart_days") or 30) + bundle = get_nutrition_history_viz_bundle(profile_id, days) + story.append(Paragraph(escape(BUNDLE_HEADINGS["nutrition_history_viz"]), styles["Heading2"])) + if not bundle.get("has_nutrition_entries"): + story.append(Paragraph(escape(bundle.get("message") or "Keine Ernährungsdaten"), styles["Normal"])) + story.append(Spacer(1, 4 * mm)) + return + compact = cfg.get("kpi_detail") == "compact" + if cfg.get("show_kpis", True): + _append_kpi_tiles_fitness_nutreco(story, styles, bundle.get("kpi_tiles") or [], compact) + if cfg.get("show_heuristics", False): + h = bundle.get("nutrition_correlation_heuristics") or [] + for item in h: + t = item.get("text") or item.get("title") + if t: + story.append(Paragraph(f"• {escape(str(t))}", styles["Normal"])) + story.append(Spacer(1, 2 * mm)) + charts = bundle.get("chart_payloads") or {} + if cfg.get("show_calorie_balance_chart", False) or cfg.get("show_energy_protein_charts", False): + pl = charts.get("energy_balance") + if pl: + _add_chart_to_story(story, styles, pl, "Energiebilanz") + if cfg.get("show_energy_protein_charts", False) or cfg.get("show_protein_lean_chart", False): + pl = charts.get("protein_adequacy") + if pl: + _add_chart_to_story(story, styles, pl, "Protein-Adäquanz") + pl2 = charts.get("nutrition_adherence") + if pl2: + _add_chart_to_story(story, styles, pl2, "Ernährungs-Adherence") + if cfg.get("show_macro_distribution_pair", False) or cfg.get("show_macro_daily_bars", False): + wm = bundle.get("weekly_macro_chart") + if isinstance(wm, dict) and wm.get("chart_type"): + _add_chart_to_story(story, styles, wm, "Makros (wöchentlich)") + kw = bundle.get("kcal_vs_weight") or {} + if cfg.get("show_kcal_vs_weight", False) and kw.get("points"): + pts = kw["points"] + if pts: + pl = _line_payload_from_points(pts, "date", "kcal", "kcal") + if pl: + _add_chart_to_story(story, styles, pl, "Kalorien vs. Zeit") + story.append(Spacer(1, 2 * mm)) + + +def _append_fitness_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None: + days = int(cfg.get("chart_days") or 30) + bundle = get_fitness_dashboard_viz_bundle(profile_id, days) + story.append(Paragraph(escape(BUNDLE_HEADINGS["fitness_history_viz"]), styles["Heading2"])) + if not bundle.get("has_activity_entries"): + story.append(Paragraph(escape(bundle.get("message") or "Keine Aktivitätsdaten"), styles["Normal"])) + story.append(Spacer(1, 4 * mm)) + return + compact = cfg.get("kpi_detail") == "compact" + if cfg.get("show_kpis", True): + _append_kpi_tiles_fitness_nutreco(story, styles, bundle.get("kpi_tiles") or [], compact) + if cfg.get("show_progress_insights", False): + _append_insights_lines(story, styles, bundle.get("progress_insights") or [], "Einschätzungen") + charts = bundle.get("charts") or {} + if cfg.get("show_chart_training_volume", True) and charts.get("training_volume"): + _add_chart_to_story(story, styles, charts["training_volume"], "Trainingsvolumen") + if cfg.get("show_chart_training_type_distribution", True) and charts.get("training_type_distribution"): + _add_chart_to_story(story, styles, charts["training_type_distribution"], "Trainingsarten") + if cfg.get("show_chart_quality_sessions", False) and charts.get("quality_sessions"): + _add_chart_to_story(story, styles, charts["quality_sessions"], "Qualitätssessions") + if cfg.get("show_chart_load_monitoring", False) and charts.get("load_monitoring"): + _add_chart_to_story(story, styles, charts["load_monitoring"], "Last / ACWR") + story.append(Spacer(1, 2 * mm)) + + +def _append_recovery_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None: + days = int(cfg.get("chart_days") or 30) + bundle = get_recovery_dashboard_viz_bundle(profile_id, days) + story.append(Paragraph(escape(BUNDLE_HEADINGS["recovery_history_viz"]), styles["Heading2"])) + if not bundle.get("has_recovery_data"): + story.append(Paragraph(escape(bundle.get("message") or "Keine Erholungsdaten"), styles["Normal"])) + story.append(Spacer(1, 4 * mm)) + return + compact = cfg.get("kpi_detail") == "compact" + if cfg.get("show_kpis", True): + _append_kpi_tiles_fitness_nutreco(story, styles, bundle.get("kpi_tiles") or [], compact) + if cfg.get("show_progress_insights", False): + _append_insights_lines(story, styles, bundle.get("progress_insights") or [], "Einschätzungen") + charts = bundle.get("charts") or {} + if cfg.get("show_chart_recovery_score", True) and charts.get("recovery_score"): + _add_chart_to_story(story, styles, charts["recovery_score"], "Recovery-Score") + if cfg.get("show_chart_hrv_rhr", True) and charts.get("hrv_rhr"): + _add_chart_to_story(story, styles, charts["hrv_rhr"], "HRV / RHR") + if cfg.get("show_chart_sleep_quality", True) and charts.get("sleep_duration_quality"): + _add_chart_to_story(story, styles, charts["sleep_duration_quality"], "Schlaf Dauer & Qualität") + if cfg.get("show_chart_sleep_debt", False) and charts.get("sleep_debt"): + _add_chart_to_story(story, styles, charts["sleep_debt"], "Schlafschuld") + if cfg.get("show_vitals_extra_trends", False): + if charts.get("vital_signs_matrix"): + _add_chart_to_story(story, styles, charts["vital_signs_matrix"], "Vital-Matrix") + if charts.get("vitals_history"): + _add_chart_to_story(story, styles, charts["vitals_history"], "Vital-Trends") + story.append(Spacer(1, 2 * mm)) + + +def _append_history_overview_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None: + days = int(cfg.get("chart_days") or 30) + bundle = get_history_overview_viz_bundle(profile_id, days) + story.append(Paragraph(escape(BUNDLE_HEADINGS["history_overview_viz"]), styles["Heading2"])) + sect_keys = { + "body": cfg.get("show_section_body", True), + "nutrition": cfg.get("show_section_nutrition", True), + "fitness": cfg.get("show_section_fitness", True), + "recovery": cfg.get("show_section_recovery", True), + } + for sec in bundle.get("sections") or []: + sid = sec.get("id") + if not sect_keys.get(str(sid), True): + continue + title = escape(str(sec.get("title") or sid)) + line = escape(str(sec.get("summary_line") or "")) + story.append(Paragraph(f"{title}: {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"])) diff --git a/backend/routers/reports.py b/backend/routers/reports.py index 99a8cfd..05555e0 100644 --- a/backend/routers/reports.py +++ b/backend/routers/reports.py @@ -19,6 +19,7 @@ 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, @@ -31,12 +32,21 @@ logger = logging.getLogger(__name__) @router.get("/catalog") def get_reports_catalog(session: dict = Depends(require_auth)): """Metadaten für UI: verfügbare Diagramme und Blocktypen.""" + 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": 1, + "catalog_version": 2, "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": "chart", "title": "Diagramm"}, + {"id": "viz_bundle", "title": "Verlauf-Bundle (KPIs & Charts)"}, + {"id": "chart", "title": "Einzel-Diagramm (Legacy)"}, {"id": "ai_insight", "title": "KI-Auswertung"}, ], } diff --git a/backend/tests/test_report_profile_schema.py b/backend/tests/test_report_profile_schema.py index 4411c76..22599cb 100644 --- a/backend/tests/test_report_profile_schema.py +++ b/backend/tests/test_report_profile_schema.py @@ -29,3 +29,28 @@ def test_chart_block_unknown_id_raises(): } 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) diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index fc3269d..979d2dc 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -5,6 +5,11 @@ import { useProfile } from '../context/ProfileContext' import { useAuth } from '../context/AuthContext' import { Avatar } from './ProfileSelect' import { api } from '../utils/api' +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 FeatureUsageOverview from '../components/FeatureUsageOverview' import UsageBadge from '../components/UsageBadge' @@ -57,7 +62,10 @@ export default function SettingsPage() { const reportNewBlock = (kind) => { const charts = reportCatalog?.charts || [] const first = charts[0] + const bundles = reportCatalog?.viz_bundles || [] + const firstBundleId = bundles[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', @@ -586,10 +594,13 @@ export default function SettingsPage() { PDF-Bericht (strukturiert)

- Eigenes Berichtsprofil: Reihenfolge, Überschriften und Diagramme — unabhängig von der - Startübersicht. Die PDF-Datei wird serverseitig aus denselben Datenquellen wie die - Chart-API erzeugt (kein Screenshot). Das unterscheidet sich vom optionalen Widget „Übersicht als - Bild-PDF“ auf der Startseite. + Eigenes Berichtsprofil: Überschriften, Verlauf-Bundles (KPIs, + Einschätzungen und Diagramme wie im Bereich Verlauf) und optional einzelne Legacy-Diagramme. Gleiche + Schalter wie unter{' '} + + Übersicht anpassen + + . PDF wird serverseitig aus dem Datenlayer erzeugt — kein Screenshot der Widgets.

{!canExport && (
)} + {b.type === 'viz_bundle' && ( +
+
+ + +
+ {b.bundle_id === 'body_history_viz' && ( + + setReportDraft((d) => { + const blocks = d.blocks.map((x, j) => { + if (j !== idx) return x + if (Object.keys(next).length === 0) return { ...x, config: {} } + return { ...x, config: { ...(x.config || {}), ...next } } + }) + return { ...d, blocks } + }) + } + /> + )} + {b.bundle_id === 'nutrition_history_viz' && ( + + setReportDraft((d) => { + const blocks = d.blocks.map((x, j) => { + if (j !== idx) return x + if (Object.keys(next).length === 0) return { ...x, config: {} } + return { ...x, config: { ...(x.config || {}), ...next } } + }) + return { ...d, blocks } + }) + } + /> + )} + {b.bundle_id === 'fitness_history_viz' && ( + + setReportDraft((d) => { + const blocks = d.blocks.map((x, j) => { + if (j !== idx) return x + if (Object.keys(next).length === 0) return { ...x, config: {} } + return { ...x, config: { ...(x.config || {}), ...next } } + }) + return { ...d, blocks } + }) + } + /> + )} + {b.bundle_id === 'recovery_history_viz' && ( + + setReportDraft((d) => { + const blocks = d.blocks.map((x, j) => { + if (j !== idx) return x + if (Object.keys(next).length === 0) return { ...x, config: {} } + return { ...x, config: { ...(x.config || {}), ...next } } + }) + return { ...d, blocks } + }) + } + /> + )} + {b.bundle_id === 'history_overview_viz' && ( + + setReportDraft((d) => { + const blocks = d.blocks.map((x, j) => { + if (j !== idx) return x + if (Object.keys(next).length === 0) return { ...x, config: {} } + return { ...x, config: { ...(x.config || {}), ...next } } + }) + return { ...d, blocks } + }) + } + /> + )} +
+ )} ))} @@ -772,7 +885,8 @@ export default function SettingsPage() { > - + + -- 2.43.0 From ed2b457da3fbc7d767d6ac1da87d1cfaf2a0c4c8 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 29 Apr 2026 12:11:26 +0200 Subject: [PATCH 5/5] feat: enhance report management and PDF generation capabilities - Introduced new API endpoints for managing report definitions, including listing, creating, and updating reports. - Updated the frontend to include a dedicated section for configuring reports, enhancing user navigation and experience. - Modified existing components to link to the new report settings, ensuring seamless access to report functionalities. - Improved the report catalog API to support multiple definitions per profile and added validation for report limits. - Updated documentation and tests to reflect the new features and ensure proper functionality. --- .../061_report_definitions_multi.sql | 24 + backend/routers/reports.py | 304 +++++++-- frontend/src/App.jsx | 3 +- .../dashboard-widgets/ReportExportWidget.jsx | 9 +- frontend/src/config/settingsNav.js | 1 + frontend/src/pages/ReportConfigurePage.jsx | 629 ++++++++++++++++++ frontend/src/pages/SettingsPage.jsx | 424 +----------- frontend/src/utils/api.js | 17 +- 8 files changed, 918 insertions(+), 493 deletions(-) create mode 100644 backend/migrations/061_report_definitions_multi.sql create mode 100644 frontend/src/pages/ReportConfigurePage.jsx diff --git a/backend/migrations/061_report_definitions_multi.sql b/backend/migrations/061_report_definitions_multi.sql new file mode 100644 index 0000000..af56477 --- /dev/null +++ b/backend/migrations/061_report_definitions_multi.sql @@ -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; diff --git a/backend/routers/reports.py b/backend/routers/reports.py index 05555e0..b6a9fcd 100644 --- a/backend/routers/reports.py +++ b/backend/routers/reports.py @@ -1,16 +1,17 @@ """ -Strukturierter PDF-Bericht (Profil v1): GET/PUT Profil, Katalog, PDF-Erzeugung. +Strukturierter PDF-Bericht: mehrere Definitionen pro Profil, Katalog, PDF-Erzeugung. -Trennung vom Dashboard-Layout; Daten aus data_layer wie /api/charts. 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, Depends, HTTPException +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 @@ -28,39 +29,20 @@ from report_profile_schema import ( router = APIRouter(prefix="/api/reports", tags=["reports"]) logger = logging.getLogger(__name__) - -@router.get("/catalog") -def get_reports_catalog(session: dict = Depends(require_auth)): - """Metadaten für UI: verfügbare Diagramme und Blocktypen.""" - 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": 2, - "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"}, - ], - } +_MAX_REPORT_DEFINITIONS = 20 -def _fetch_payload_row(profile_id: str) -> dict | None: - with get_db() as conn: - cur = get_cursor(conn) - cur.execute("SELECT payload FROM report_profiles WHERE profile_id = %s", (profile_id,)) - row = cur.fetchone() - if not row: - return None - p = row.get("payload") - return p if isinstance(p, dict) else None +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: @@ -73,58 +55,231 @@ def _profile_display_name(profile_id: str) -> str: return (row.get("name") or "Profil").strip() or "Profil" -@router.get("/profile") -def get_report_profile(session: dict = Depends(require_auth)): +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"] - raw = _fetch_payload_row(pid) - if raw is None: - return {"stored": False, "profile": default_report_profile_dict()} - try: - parse_report_profile(raw) - except Exception as e: - logger.warning("report profile invalid for %s: %s", pid, e) - return {"stored": False, "profile": default_report_profile_dict(), "previous_invalid": True} - return {"stored": True, "profile": raw} - - -@router.put("/profile") -def put_report_profile(body: dict, session: dict = Depends(require_auth)): - pid = session["profile_id"] - try: - parsed = ReportProfilePayload.model_validate(body) - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) - with get_db() as conn: cur = get_cursor(conn) cur.execute( """ - INSERT INTO report_profiles (profile_id, payload, updated_at) - VALUES (%s, %s, CURRENT_TIMESTAMP) - ON CONFLICT (profile_id) DO UPDATE SET - payload = EXCLUDED.payload, - updated_at = CURRENT_TIMESTAMP + 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, Json(parsed.to_stored_dict())), + (pid,), ) - conn.commit() - return {"ok": True, "profile": parsed.to_stored_dict()} + rows = cur.fetchall() + return {"definitions": [_row_to_definition(dict(r)) for r in rows]} -@router.delete("/profile") -def delete_report_profile(session: dict = Depends(require_auth)): - """Zurück auf Code-Standard (kein DB-Eintrag).""" +@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("DELETE FROM report_profiles WHERE profile_id = %s", (pid,)) + 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() - return {"ok": True, "profile": default_report_profile_dict()} + + 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(session: dict = Depends(require_auth)): +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") @@ -143,21 +298,24 @@ def generate_structured_report_pdf(session: dict = Depends(require_auth)): ), ) - raw = _fetch_payload_row(pid) + 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}") - name = _profile_display_name(pid) + profile_name = _profile_display_name(pid) try: - pdf_bytes = build_structured_report_pdf(profile_id=pid, profile_name=name, payload=payload) + 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") - safe_name = "".join(c for c in name if c.isalnum() or c in (" ", "-", "_")).strip() or "profil" + + 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, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index dd42411..089d534 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -25,7 +25,7 @@ import Analysis from './pages/Analysis' import SettingsPage from './pages/SettingsPage' import SettingsShell from './layouts/SettingsShell' import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage' -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' @@ -241,6 +241,7 @@ function AppShell() { } /> } /> } /> + } /> }> }> diff --git a/frontend/src/components/dashboard-widgets/ReportExportWidget.jsx b/frontend/src/components/dashboard-widgets/ReportExportWidget.jsx index 64f1814..81c3538 100644 --- a/frontend/src/components/dashboard-widgets/ReportExportWidget.jsx +++ b/frontend/src/components/dashboard-widgets/ReportExportWidget.jsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { Link } from 'react-router-dom' import dayjs from 'dayjs' import { FileDown } from 'lucide-react' import { useAuth } from '../../context/AuthContext' @@ -64,8 +64,11 @@ export default function ReportExportWidget({ reportExportConfig }) {

Layout-Schnappschuss: Die sichtbare Übersicht wird im Browser gerastert (html2canvas). - Für einen datenbasierten Bericht unabhängig vom Dashboard nutze{' '} - Einstellungen → PDF-Bericht (strukturiert). + Für einen datenbasierten Bericht unabhängig vom Dashboard öffne{' '} + + Einstellungen → PDF-Berichte + + .

{!canExport ? ( diff --git a/frontend/src/config/settingsNav.js b/frontend/src/config/settingsNav.js index f03cc3f..e645f58 100644 --- a/frontend/src/config/settingsNav.js +++ b/frontend/src/config/settingsNav.js @@ -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' }, ] diff --git a/frontend/src/pages/ReportConfigurePage.jsx b/frontend/src/pages/ReportConfigurePage.jsx new file mode 100644 index 0000000..cc27067 --- /dev/null +++ b/frontend/src/pages/ReportConfigurePage.jsx @@ -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 ( +
+

Lade Katalog…

+
+ ) + } + + return ( +
+
+
+ + PDF-Berichte +
+

+ Hier legst du einen oder mehrere strukturierte PDF-Berichte an. Pro Block vom Typ + „Verlauf-Bundle“ gilt ein Zeitraum in Tagen (wie bei der Übersicht).{' '} + Technisch: Es sind dieselben{' '} + Daten-Bundles und dieselbe Konfiguration wie bei den Verlauf-Widgets — im PDF werden + sie serverseitig gerendert (nicht die React-Komponenten der Startseite). +

+ {!canExport && ( +
+ PDF ist mit dem Kontingent „Datenexport“ verknüpft. Bitte Admin kontaktieren. +
+ )} + {err && ( +
+ {err} +
+ )} + {msg && ( +
+ {msg} +
+ )} + +
+ {definitions.map((d) => ( + + ))} + + + ← Allgemein + +
+ + {canExport && !definitions.length && ( +

+ Noch kein Bericht vorhanden. Lege mit „Neuer Bericht“ einen Standard an. +

+ )} + + {canExport && selected && ( + <> + + setSelectedName(e.target.value)} + style={{ marginBottom: 14, maxWidth: 420 }} + /> + + + + patchSelectedPayload((p) => ({ + ...p, + document_title: e.target.value, + })) + } + style={{ marginBottom: 14 }} + /> + +
Blöcke
+
    + {(selected.payload?.blocks || []).map((b, idx) => ( +
  • +
    + {b.type} + +
    + {b.type === 'section' && ( + + patchSelectedPayload((p) => ({ + ...p, + blocks: (p.blocks || []).map((x, j) => + j === idx ? { ...x, title: e.target.value } : x, + ), + })) + } + /> + )} + {b.type === 'chart' && ( +
    + +
    + + { + 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, + ), + })) + }} + /> +
    +
    + )} + {b.type === 'ai_insight' && ( +
    + + patchSelectedPayload((p) => ({ + ...p, + blocks: (p.blocks || []).map((x, j) => + j === idx ? { ...x, title: e.target.value } : x, + ), + })) + } + /> + + patchSelectedPayload((p) => { + const v = e.target.value.trim() || null + return { + ...p, + blocks: (p.blocks || []).map((x, j) => + j === idx ? { ...x, insight_id: v } : x, + ), + } + }) + } + /> +
    + )} + {b.type === 'viz_bundle' && ( +
    +
    + + +
    +
    + + { + 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, + ), + })) + }} + /> +
    + {b.bundle_id === 'body_history_viz' && ( + + 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' && ( + + 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' && ( + + 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' && ( + + 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' && ( + + 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 } } + }), + })) + } + /> + )} +
    + )} +
  • + ))} +
+ +
+ +
+ +
+ + + +
+ {exportUsage && ( +
+ +
+ )} + + )} +
+
+ ) +} diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 979d2dc..d1cd3c0 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -1,15 +1,10 @@ import { useState, useEffect } from 'react' -import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target, LayoutDashboard, FileText, Trash2 } 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' import { Avatar } from './ProfileSelect' import { api } from '../utils/api' -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 FeatureUsageOverview from '../components/FeatureUsageOverview' import UsageBadge from '../components/UsageBadge' @@ -28,11 +23,6 @@ export default function SettingsPage() { const [newPin, setNewPin] = useState('') const [pinMsg, setPinMsg] = useState(null) const [exportUsage, setExportUsage] = useState(null) // Phase 3: Usage badge - const [reportCatalog, setReportCatalog] = useState(null) - const [reportDraft, setReportDraft] = useState(null) - const [reportStored, setReportStored] = useState(false) - const [reportBusy, setReportBusy] = useState(false) - const [reportNote, setReportNote] = useState(null) // Load feature usage for export badges useEffect(() => { @@ -42,87 +32,6 @@ export default function SettingsPage() { }).catch(err => console.error('Failed to load usage:', err)) }, []) - useEffect(() => { - if (!activeProfile?.id) return - let cancel = false - Promise.all([api.getReportsCatalog(), api.getReportProfile()]) - .then(([cat, bundle]) => { - if (cancel) return - setReportCatalog(cat) - setReportDraft(JSON.parse(JSON.stringify(bundle.profile))) - setReportStored(!!bundle.stored) - setReportNote(null) - }) - .catch((e) => console.error('report profile load', e)) - return () => { - cancel = true - } - }, [activeProfile?.id]) - - const reportNewBlock = (kind) => { - const charts = reportCatalog?.charts || [] - const first = charts[0] - const bundles = reportCatalog?.viz_bundles || [] - const firstBundleId = bundles[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 handleSaveReportProfile = async () => { - if (!reportDraft) return - if (!reportDraft.blocks?.length) { - setReportNote({ type: 'err', text: 'Mindestens ein Block erforderlich.' }) - return - } - setReportBusy(true) - setReportNote(null) - try { - await api.putReportProfile(reportDraft) - setReportStored(true) - setReportNote({ type: 'ok', text: 'Berichtsprofil gespeichert.' }) - } catch (e) { - setReportNote({ type: 'err', text: e.message }) - } finally { - setReportBusy(false) - } - } - - const handleResetReportProfile = async () => { - if (!confirm('Persönliches Berichtsprofil löschen und Standard wiederherstellen?')) return - setReportBusy(true) - setReportNote(null) - try { - const bundle = await api.resetReportProfile() - setReportDraft(bundle.profile) - setReportStored(false) - setReportNote({ type: 'ok', text: 'Standard wiederhergestellt.' }) - } catch (e) { - setReportNote({ type: 'err', text: e.message }) - } finally { - setReportBusy(false) - } - } - - const handleGenerateStructuredPdf = async () => { - setReportBusy(true) - setReportNote(null) - try { - await api.generateStructuredReportPdf() - setReportNote({ type: 'ok', text: 'PDF wurde heruntergeladen.' }) - } catch (e) { - setReportNote({ type: 'err', text: e.message }) - } finally { - setReportBusy(false) - } - } - const handleLogout = async () => { if (!confirm('Ausloggen?')) return await logout() @@ -587,20 +496,15 @@ export default function SettingsPage() {
- {/* Strukturierter PDF-Bericht (Profil v1) */} + {/* PDF-Berichte: eigener Tab (wie Übersicht) */}
- PDF-Bericht (strukturiert) + PDF-Berichte

- Eigenes Berichtsprofil: Überschriften, Verlauf-Bundles (KPIs, - Einschätzungen und Diagramme wie im Bereich Verlauf) und optional einzelne Legacy-Diagramme. Gleiche - Schalter wie unter{' '} - - Übersicht anpassen - - . PDF wird serverseitig aus dem Datenlayer erzeugt — kein Screenshot der Widgets. + Mehrere strukturierte Berichte, Zeiträume pro Verlauf-Bundle und PDF-Erzeugung findest du im + eigenen Bereich — analog „Übersicht“ in den Einstellungen.

{!canExport && (
- 🔒 PDF-Bericht nutzt dasselbe Kontingent wie Datenexporte. Bitte Admin kontaktieren. + PDF nutzt dasselbe Kontingent wie Datenexporte. Bitte Admin kontaktieren.
)} - {reportNote && ( -
- {reportNote.text} -
- )} - {canExport && reportDraft && reportCatalog && ( - <> - - setReportDraft((d) => ({ ...d, document_title: e.target.value }))} - style={{ marginBottom: 14 }} - /> -
- Blöcke {reportStored ? '' : '(Standard — noch nicht separat gespeichert)'} -
-
    - {reportDraft.blocks?.map((b, idx) => ( -
  • -
    - {b.type} - -
    - {b.type === 'section' && ( - - setReportDraft((d) => { - const blocks = d.blocks.map((x, j) => - j === idx ? { ...x, title: e.target.value } : x - ) - return { ...d, blocks } - }) - } - /> - )} - {b.type === 'chart' && ( -
    - -
    - - - setReportDraft((d) => { - const n = Number(e.target.value) - const blocks = d.blocks.map((x, j) => - j === idx ? { ...x, window_days: Number.isFinite(n) ? n : x.window_days } : x - ) - return { ...d, blocks } - }) - } - /> -
    -
    - )} - {b.type === 'ai_insight' && ( -
    - - setReportDraft((d) => { - const blocks = d.blocks.map((x, j) => - j === idx ? { ...x, title: e.target.value } : x - ) - return { ...d, blocks } - }) - } - /> - - setReportDraft((d) => { - const v = e.target.value.trim() || null - const blocks = d.blocks.map((x, j) => - j === idx ? { ...x, insight_id: v } : x - ) - return { ...d, blocks } - }) - } - /> -
    - )} - {b.type === 'viz_bundle' && ( -
    -
    - - -
    - {b.bundle_id === 'body_history_viz' && ( - - setReportDraft((d) => { - const blocks = d.blocks.map((x, j) => { - if (j !== idx) return x - if (Object.keys(next).length === 0) return { ...x, config: {} } - return { ...x, config: { ...(x.config || {}), ...next } } - }) - return { ...d, blocks } - }) - } - /> - )} - {b.bundle_id === 'nutrition_history_viz' && ( - - setReportDraft((d) => { - const blocks = d.blocks.map((x, j) => { - if (j !== idx) return x - if (Object.keys(next).length === 0) return { ...x, config: {} } - return { ...x, config: { ...(x.config || {}), ...next } } - }) - return { ...d, blocks } - }) - } - /> - )} - {b.bundle_id === 'fitness_history_viz' && ( - - setReportDraft((d) => { - const blocks = d.blocks.map((x, j) => { - if (j !== idx) return x - if (Object.keys(next).length === 0) return { ...x, config: {} } - return { ...x, config: { ...(x.config || {}), ...next } } - }) - return { ...d, blocks } - }) - } - /> - )} - {b.bundle_id === 'recovery_history_viz' && ( - - setReportDraft((d) => { - const blocks = d.blocks.map((x, j) => { - if (j !== idx) return x - if (Object.keys(next).length === 0) return { ...x, config: {} } - return { ...x, config: { ...(x.config || {}), ...next } } - }) - return { ...d, blocks } - }) - } - /> - )} - {b.bundle_id === 'history_overview_viz' && ( - - setReportDraft((d) => { - const blocks = d.blocks.map((x, j) => { - if (j !== idx) return x - if (Object.keys(next).length === 0) return { ...x, config: {} } - return { ...x, config: { ...(x.config || {}), ...next } } - }) - return { ...d, blocks } - }) - } - /> - )} -
    - )} -
  • - ))} -
-
- -
-
- - - -
- {exportUsage && ( -
- -
- )} - + Zu den PDF-Berichten + )}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 65521da..57a9617 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -268,11 +268,18 @@ export const api = { // Strukturierter PDF-Bericht (Profil v1, unabhängig vom Dashboard) getReportsCatalog: () => req('/reports/catalog'), - getReportProfile: () => req('/reports/profile'), - putReportProfile: (profile) => req('/reports/profile', jput(profile)), - resetReportProfile: () => req('/reports/profile', { method: 'DELETE' }), - generateStructuredReportPdf: async () => { - const res = await fetch(`${BASE}/reports/generate-pdf`, { method: 'POST', headers: hdrs() }) + 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 { -- 2.43.0