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
+
+ .
+ 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.
+
- 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.