From ed2b457da3fbc7d767d6ac1da87d1cfaf2a0c4c8 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 29 Apr 2026 12:11:26 +0200 Subject: [PATCH] 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 {