feat: enhance report management and PDF generation capabilities
All checks were successful
Deploy Development / deploy (push) Successful in 1m5s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 20s

- 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.
This commit is contained in:
Lars 2026-04-29 12:11:26 +02:00
parent 3ab5dae130
commit ed2b457da3
8 changed files with 918 additions and 493 deletions

View File

@ -0,0 +1,24 @@
-- Migration 061: Mehrere benannte PDF-Berichte pro Nutzerprofil; Daten von report_profiles übernehmen.
CREATE TABLE IF NOT EXISTS report_definitions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Bericht',
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
sort_order INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_report_definitions_profile_sort
ON report_definitions (profile_id, sort_order);
COMMENT ON TABLE report_definitions IS 'Mehrere strukturierte PDF-Berichte pro Profil (payload = ReportProfilePayload v1)';
INSERT INTO report_definitions (profile_id, name, payload, sort_order)
SELECT rp.profile_id, 'Standard', rp.payload, 0
FROM report_profiles rp
WHERE NOT EXISTS (
SELECT 1 FROM report_definitions rd WHERE rd.profile_id = rp.profile_id
);
DROP TABLE IF EXISTS report_profiles;

View File

@ -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). PDF-Zähler: data_export (wie andere Exporte).
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import datetime 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 fastapi.responses import Response
from pydantic import BaseModel, Field
from psycopg2.extras import Json from psycopg2.extras import Json
from auth import check_feature_access, increment_feature_usage, require_auth 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"]) router = APIRouter(prefix="/api/reports", tags=["reports"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_MAX_REPORT_DEFINITIONS = 20
@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"},
],
}
def _fetch_payload_row(profile_id: str) -> dict | None: class CreateReportDefinitionBody(BaseModel):
with get_db() as conn: name: str = Field(default="Neuer Bericht", min_length=1, max_length=120)
cur = get_cursor(conn)
cur.execute("SELECT payload FROM report_profiles WHERE profile_id = %s", (profile_id,))
row = cur.fetchone() class UpdateReportDefinitionBody(BaseModel):
if not row: name: str | None = Field(default=None, min_length=1, max_length=120)
return None payload: dict | None = None
p = row.get("payload")
return p if isinstance(p, dict) else None
class GeneratePdfRequest(BaseModel):
definition_id: UUID | None = None
def _profile_display_name(profile_id: str) -> str: 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" return (row.get("name") or "Profil").strip() or "Profil"
@router.get("/profile") def _row_to_definition(row: dict) -> dict:
def get_report_profile(session: dict = Depends(require_auth)): 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"] 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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( cur.execute(
""" """
INSERT INTO report_profiles (profile_id, payload, updated_at) SELECT id, name, sort_order, updated_at, payload
VALUES (%s, %s, CURRENT_TIMESTAMP) FROM report_definitions
ON CONFLICT (profile_id) DO UPDATE SET WHERE profile_id = %s
payload = EXCLUDED.payload, ORDER BY sort_order ASC, name ASC, updated_at DESC
updated_at = CURRENT_TIMESTAMP
""", """,
(pid, Json(parsed.to_stored_dict())), (pid,),
) )
conn.commit() rows = cur.fetchall()
return {"ok": True, "profile": parsed.to_stored_dict()} return {"definitions": [_row_to_definition(dict(r)) for r in rows]}
@router.delete("/profile") @router.post("/definitions")
def delete_report_profile(session: dict = Depends(require_auth)): def create_report_definition(
"""Zurück auf Code-Standard (kein DB-Eintrag).""" body: CreateReportDefinitionBody | None = Body(default=None),
session: dict = Depends(require_auth),
):
req = body or CreateReportDefinitionBody()
pid = session["profile_id"] pid = session["profile_id"]
with get_db() as conn: with get_db() as conn:
cur = get_cursor(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() 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") @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"] pid = session["profile_id"]
req = body or GeneratePdfRequest()
access = check_feature_access(pid, "data_export") access = check_feature_access(pid, "data_export")
log_feature_usage(pid, "data_export", access, "report_generate_pdf") 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: try:
payload = parse_report_profile(raw) payload = parse_report_profile(raw)
except Exception as e: except Exception as e:
raise HTTPException(status_code=400, detail=f"Berichtsprofil ungültig: {e}") raise HTTPException(status_code=400, detail=f"Berichtsprofil ungültig: {e}")
name = _profile_display_name(pid) profile_name = _profile_display_name(pid)
try: 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: except Exception as e:
logger.exception("report pdf build failed") logger.exception("report pdf build failed")
raise HTTPException(status_code=500, detail=f"PDF-Erzeugung fehlgeschlagen: {e}") raise HTTPException(status_code=500, detail=f"PDF-Erzeugung fehlgeschlagen: {e}")
increment_feature_usage(pid, "data_export") 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" fn = f"mitai-bericht-{safe_name.replace(' ', '-')}-{datetime.now().strftime('%Y-%m-%d')}.pdf"
return Response( return Response(
content=pdf_bytes, content=pdf_bytes,

View File

@ -25,7 +25,7 @@ import Analysis from './pages/Analysis'
import SettingsPage from './pages/SettingsPage' import SettingsPage from './pages/SettingsPage'
import SettingsShell from './layouts/SettingsShell' import SettingsShell from './layouts/SettingsShell'
import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage' import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage'
import DashboardConfigurePage from './pages/DashboardConfigurePage' import ReportConfigurePage from './pages/ReportConfigurePage'
import GuidePage from './pages/GuidePage' import GuidePage from './pages/GuidePage'
import AdminTierLimitsPage from './pages/AdminTierLimitsPage' import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
import AdminFeaturesPage from './pages/AdminFeaturesPage' import AdminFeaturesPage from './pages/AdminFeaturesPage'
@ -241,6 +241,7 @@ function AppShell() {
<Route index element={<SettingsPage />} /> <Route index element={<SettingsPage />} />
<Route path="reference-values" element={<ProfileReferenceValuesPage />} /> <Route path="reference-values" element={<ProfileReferenceValuesPage />} />
<Route path="dashboard-layout" element={<DashboardConfigurePage />} /> <Route path="dashboard-layout" element={<DashboardConfigurePage />} />
<Route path="reports" element={<ReportConfigurePage />} />
</Route> </Route>
<Route element={<RequireAdmin />}> <Route element={<RequireAdmin />}>
<Route path="admin" element={<AdminShell />}> <Route path="admin" element={<AdminShell />}>

View File

@ -1,4 +1,4 @@
import { useState } from 'react' import { Link } from 'react-router-dom'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { FileDown } from 'lucide-react' import { FileDown } from 'lucide-react'
import { useAuth } from '../../context/AuthContext' import { useAuth } from '../../context/AuthContext'
@ -64,8 +64,11 @@ export default function ReportExportWidget({ reportExportConfig }) {
<div data-dashboard-pdf-exclude="true"> <div data-dashboard-pdf-exclude="true">
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 14, lineHeight: 1.55 }}> <p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 14, lineHeight: 1.55 }}>
<strong>Layout-Schnappschuss:</strong> Die sichtbare Übersicht wird im Browser gerastert (html2canvas). <strong>Layout-Schnappschuss:</strong> Die sichtbare Übersicht wird im Browser gerastert (html2canvas).
Für einen <strong>datenbasierten Bericht</strong> unabhängig vom Dashboard nutze{' '} Für einen <strong>datenbasierten Bericht</strong> unabhängig vom Dashboard öffne{' '}
<strong>Einstellungen PDF-Bericht (strukturiert)</strong>. <Link to="/settings/reports" style={{ color: 'var(--accent)', fontWeight: 600 }}>
Einstellungen PDF-Berichte
</Link>
.
</p> </p>
<div style={{ marginTop: 14 }}> <div style={{ marginTop: 14 }}>
{!canExport ? ( {!canExport ? (

View File

@ -6,5 +6,6 @@
export const SETTINGS_SHELL_NAV_ITEMS = [ export const SETTINGS_SHELL_NAV_ITEMS = [
{ id: 'general', label: 'Allgemein', to: '/settings', end: true }, { id: 'general', label: 'Allgemein', to: '/settings', end: true },
{ id: 'dashboard-layout', label: 'Übersicht', to: '/settings/dashboard-layout' }, { 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' }, { id: 'reference-values', label: 'Referenzwerte', to: '/settings/reference-values' },
] ]

View File

@ -0,0 +1,629 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Download, FileText, Plus, Save, Trash2 } from 'lucide-react'
import { api, formatFastApiDetail } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { useProfile } from '../context/ProfileContext'
import UsageBadge from '../components/UsageBadge'
import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor'
import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor'
import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor'
import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor'
import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor'
import {
BODY_CHART_DAYS_DEFAULT,
BODY_CHART_DAYS_MAX,
BODY_CHART_DAYS_MIN,
normalizeBodyChartDays,
} from '../widgetSystem/bodyChartDays'
const VIZ_BUNDLES_FALLBACK = [
{ id: 'body_history_viz', title: 'Körper (Verlauf-Bundle)' },
{ id: 'nutrition_history_viz', title: 'Ernährung (Verlauf-Bundle)' },
{ id: 'fitness_history_viz', title: 'Fitness (Verlauf-Bundle)' },
{ id: 'recovery_history_viz', title: 'Erholung (Verlauf-Bundle)' },
{ id: 'history_overview_viz', title: 'Gesamtübersicht (Korrelationen)' },
]
export default function ReportConfigurePage() {
const { canExport } = useAuth()
const { activeProfile } = useProfile()
const [exportUsage, setExportUsage] = useState(null)
const [catalog, setCatalog] = useState(null)
const [definitions, setDefinitions] = useState([])
const [selectedId, setSelectedId] = useState(null)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState(null)
const [err, setErr] = useState(null)
const chartDaysMin = catalog?.chart_days?.min ?? BODY_CHART_DAYS_MIN
const chartDaysMax = catalog?.chart_days?.max ?? BODY_CHART_DAYS_MAX
const vizBundles = catalog?.viz_bundles?.length ? catalog.viz_bundles : VIZ_BUNDLES_FALLBACK
const selected = definitions.find((d) => d.id === selectedId) || null
const load = useCallback(async () => {
setErr(null)
try {
const [cat, bundle] = await Promise.all([api.getReportsCatalog(), api.listReportDefinitions()])
setCatalog(cat)
setDefinitions(bundle.definitions || [])
} catch (e) {
setErr(formatFastApiDetail(null, e.message))
}
}, [])
useEffect(() => {
api.getFeatureUsage().then((features) => {
const exportFeature = features.find((f) => f.feature_id === 'data_export')
setExportUsage(exportFeature)
}).catch(() => {})
}, [])
useEffect(() => {
if (!activeProfile?.id) return
load()
}, [activeProfile?.id, load])
useEffect(() => {
if (!definitions.length) {
setSelectedId(null)
return
}
if (!selectedId || !definitions.some((d) => d.id === selectedId)) {
setSelectedId(definitions[0].id)
}
}, [definitions, selectedId])
const patchSelectedPayload = useCallback((fn) => {
setDefinitions((defs) =>
defs.map((d) => {
if (d.id !== selectedId) return d
const nextPayload = fn(d.payload || { version: 1, document_title: '', blocks: [] })
return { ...d, payload: nextPayload }
}),
)
}, [selectedId])
const reportNewBlock = (kind) => {
const charts = catalog?.charts || []
const first = charts[0]
const firstBundleId = vizBundles[0]?.id || 'body_history_viz'
if (kind === 'section') return { type: 'section', title: 'Neue Überschrift' }
if (kind === 'viz_bundle') return { type: 'viz_bundle', bundle_id: firstBundleId, config: {} }
if (kind === 'chart')
return {
type: 'chart',
chart_id: first?.id || 'weight_trend',
window_days: first?.default_window_days || 28,
}
return { type: 'ai_insight', title: '', insight_id: null }
}
const handleCreateDefinition = async () => {
setBusy(true)
setErr(null)
setMsg(null)
try {
const r = await api.createReportDefinition({
name: `Bericht ${definitions.length + 1}`,
})
const d = r.definition
setDefinitions((x) => [...x, d])
setSelectedId(d.id)
setMsg('Neuer Bericht angelegt. Inhalt speichern nicht vergessen, wenn du Änderungen machst.')
} catch (e) {
setErr(formatFastApiDetail(null, e.message))
} finally {
setBusy(false)
}
}
const handleSave = async () => {
if (!selected) return
if (!selected.payload?.blocks?.length) {
setErr('Mindestens ein Block erforderlich.')
return
}
setBusy(true)
setErr(null)
setMsg(null)
try {
await api.updateReportDefinition(selected.id, {
name: selected.name?.trim() || 'Bericht',
payload: selected.payload,
})
setMsg('Gespeichert.')
await load()
} catch (e) {
setErr(formatFastApiDetail(null, e.message))
} finally {
setBusy(false)
}
}
const handleDeleteDefinition = async () => {
if (!selected) return
if (!confirm(`Bericht „${selected.name}“ wirklich löschen?`)) return
setBusy(true)
setErr(null)
setMsg(null)
try {
await api.deleteReportDefinition(selected.id)
setDefinitions((defs) => defs.filter((d) => d.id !== selected.id))
setSelectedId(null)
setMsg('Bericht gelöscht.')
} catch (e) {
setErr(formatFastApiDetail(null, e.message))
} finally {
setBusy(false)
}
}
const handleGeneratePdf = async () => {
if (!selected) return
setBusy(true)
setErr(null)
setMsg(null)
try {
await api.generateStructuredReportPdf(selected.id)
setMsg('PDF wurde heruntergeladen.')
} catch (e) {
setErr(formatFastApiDetail(null, e.message))
} finally {
setBusy(false)
}
}
const setSelectedName = (name) => {
setDefinitions((defs) => defs.map((d) => (d.id === selectedId ? { ...d, name } : d)))
}
const vizBundleChartDays = (config) =>
normalizeBodyChartDays(config?.chart_days ?? BODY_CHART_DAYS_DEFAULT)
if (!catalog) {
return (
<div className="card section-gap">
<p style={{ color: 'var(--text2)' }}>Lade Katalog</p>
</div>
)
}
return (
<div>
<div className="card section-gap">
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<FileText size={18} color="var(--accent)" />
PDF-Berichte
</div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.65 }}>
Hier legst du <strong>einen oder mehrere strukturierte PDF-Berichte</strong> an. Pro Block vom Typ
Verlauf-Bundle gilt ein <strong>Zeitraum in Tagen</strong> (wie bei der Übersicht).{' '}
<strong>Technisch:</strong> Es sind dieselben{' '}
<strong>Daten-Bundles und dieselbe Konfiguration</strong> wie bei den Verlauf-Widgets im PDF werden
sie serverseitig gerendert (nicht die React-Komponenten der Startseite).
</p>
{!canExport && (
<div
style={{
padding: '10px 12px',
background: '#FCEBEB',
borderRadius: 8,
fontSize: 13,
color: '#D85A30',
marginBottom: 12,
}}
>
PDF ist mit dem Kontingent Datenexport verknüpft. Bitte Admin kontaktieren.
</div>
)}
{err && (
<div
style={{
padding: '10px 12px',
borderRadius: 8,
fontSize: 13,
marginBottom: 12,
background: '#FCEBEB',
color: '#D85A30',
}}
>
{err}
</div>
)}
{msg && (
<div
style={{
padding: '10px 12px',
borderRadius: 8,
fontSize: 13,
marginBottom: 12,
background: '#E1F5EE',
color: 'var(--accent)',
}}
>
{msg}
</div>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', marginBottom: 14 }}>
{definitions.map((d) => (
<button
key={d.id}
type="button"
className={d.id === selectedId ? 'btn btn-primary' : 'btn btn-secondary'}
onClick={() => setSelectedId(d.id)}
>
{d.name || 'Bericht'}
</button>
))}
<button
type="button"
className="btn btn-secondary"
disabled={busy || !canExport || definitions.length >= 20}
onClick={handleCreateDefinition}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
>
<Plus size={16} />
Neuer Bericht
</button>
<Link to="/settings" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
Allgemein
</Link>
</div>
{canExport && !definitions.length && (
<p style={{ fontSize: 14, marginBottom: 12 }}>
Noch kein Bericht vorhanden. Lege mit Neuer Bericht einen Standard an.
</p>
)}
{canExport && selected && (
<>
<label className="form-label" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
Name des Berichts (intern)
</label>
<input
type="text"
className="form-input"
maxLength={120}
value={selected.name || ''}
onChange={(e) => setSelectedName(e.target.value)}
style={{ marginBottom: 14, maxWidth: 420 }}
/>
<label className="form-label" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
Dokumenttitel im PDF (optional)
</label>
<input
type="text"
className="form-input"
maxLength={120}
placeholder="Leer = Profilname + „Bericht“"
value={selected.payload?.document_title || ''}
onChange={(e) =>
patchSelectedPayload((p) => ({
...p,
document_title: e.target.value,
}))
}
style={{ marginBottom: 14 }}
/>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text2)', marginBottom: 8 }}>Blöcke</div>
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 12px' }}>
{(selected.payload?.blocks || []).map((b, idx) => (
<li
key={idx}
style={{
border: '1px solid var(--border)',
borderRadius: 10,
padding: 12,
marginBottom: 10,
background: 'var(--surface2)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
<span style={{ fontSize: 11, color: 'var(--text3)', textTransform: 'uppercase' }}>{b.type}</span>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '4px 8px' }}
aria-label="Block entfernen"
onClick={() =>
patchSelectedPayload((p) => ({
...p,
blocks: (p.blocks || []).filter((_, j) => j !== idx),
}))
}
>
<Trash2 size={16} />
</button>
</div>
{b.type === 'section' && (
<input
type="text"
className="form-input"
style={{ marginTop: 8 }}
value={b.title || ''}
onChange={(e) =>
patchSelectedPayload((p) => ({
...p,
blocks: (p.blocks || []).map((x, j) =>
j === idx ? { ...x, title: e.target.value } : x,
),
}))
}
/>
)}
{b.type === 'chart' && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
<select
className="form-input"
value={b.chart_id}
onChange={(e) =>
patchSelectedPayload((p) => ({
...p,
blocks: (p.blocks || []).map((x, j) =>
j === idx ? { ...x, chart_id: e.target.value } : x,
),
}))
}
>
{catalog.charts?.map((c) => (
<option key={c.id} value={c.id}>
{c.title}
</option>
))}
</select>
<div>
<label style={{ fontSize: 11, color: 'var(--text3)' }}>Zeitraum (Tage)</label>
<input
type="number"
className="form-input"
min={7}
max={365}
value={b.window_days}
onChange={(e) => {
const n = Number(e.target.value)
patchSelectedPayload((p) => ({
...p,
blocks: (p.blocks || []).map((x, j) =>
j === idx
? { ...x, window_days: Number.isFinite(n) ? n : x.window_days }
: x,
),
}))
}}
/>
</div>
</div>
)}
{b.type === 'ai_insight' && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
<input
type="text"
className="form-input"
placeholder="Optional: Überschrift"
value={b.title || ''}
onChange={(e) =>
patchSelectedPayload((p) => ({
...p,
blocks: (p.blocks || []).map((x, j) =>
j === idx ? { ...x, title: e.target.value } : x,
),
}))
}
/>
<input
type="text"
className="form-input"
placeholder="Optional: Insight-UUID"
value={b.insight_id || ''}
onChange={(e) =>
patchSelectedPayload((p) => {
const v = e.target.value.trim() || null
return {
...p,
blocks: (p.blocks || []).map((x, j) =>
j === idx ? { ...x, insight_id: v } : x,
),
}
})
}
/>
</div>
)}
{b.type === 'viz_bundle' && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div>
<label style={{ fontSize: 11, color: 'var(--text3)', display: 'block', marginBottom: 4 }}>
Verlauf-Bundle
</label>
<select
className="form-input"
value={b.bundle_id}
onChange={(e) =>
patchSelectedPayload((p) => ({
...p,
blocks: (p.blocks || []).map((x, j) =>
j === idx ? { ...x, bundle_id: e.target.value, config: {} } : x,
),
}))
}
>
{vizBundles.map((vb) => (
<option key={vb.id} value={vb.id}>
{vb.title}
</option>
))}
</select>
</div>
<div>
<label style={{ fontSize: 11, color: 'var(--text3)', display: 'block', marginBottom: 4 }}>
Zeitraum für dieses Bundle (Tage): {chartDaysMin}{chartDaysMax}
</label>
<input
type="number"
className="form-input"
style={{ maxWidth: 160 }}
min={chartDaysMin}
max={chartDaysMax}
value={vizBundleChartDays(b.config)}
onChange={(e) => {
const days = normalizeBodyChartDays(e.target.value)
patchSelectedPayload((p) => ({
...p,
blocks: (p.blocks || []).map((x, j) =>
j === idx ? { ...x, config: { ...x.config, chart_days: days } } : x,
),
}))
}}
/>
</div>
{b.bundle_id === 'body_history_viz' && (
<BodyHistoryVizConfigEditor
config={b.config || {}}
onChange={(next) =>
patchSelectedPayload((p) => ({
...p,
blocks: (p.blocks || []).map((x, j) => {
if (j !== idx) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
}))
}
/>
)}
{b.bundle_id === 'nutrition_history_viz' && (
<NutritionHistoryVizConfigEditor
config={b.config || {}}
onChange={(next) =>
patchSelectedPayload((p) => ({
...p,
blocks: (p.blocks || []).map((x, j) => {
if (j !== idx) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
}))
}
/>
)}
{b.bundle_id === 'fitness_history_viz' && (
<FitnessHistoryVizConfigEditor
config={b.config || {}}
onChange={(next) =>
patchSelectedPayload((p) => ({
...p,
blocks: (p.blocks || []).map((x, j) => {
if (j !== idx) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
}))
}
/>
)}
{b.bundle_id === 'recovery_history_viz' && (
<RecoveryHistoryVizConfigEditor
config={b.config || {}}
onChange={(next) =>
patchSelectedPayload((p) => ({
...p,
blocks: (p.blocks || []).map((x, j) => {
if (j !== idx) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
}))
}
/>
)}
{b.bundle_id === 'history_overview_viz' && (
<HistoryOverviewVizConfigEditor
config={b.config || {}}
onChange={(next) =>
patchSelectedPayload((p) => ({
...p,
blocks: (p.blocks || []).map((x, j) => {
if (j !== idx) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
}))
}
/>
)}
</div>
)}
</li>
))}
</ul>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
<select
className="form-input"
style={{ maxWidth: 280 }}
defaultValue=""
onChange={(e) => {
const v = e.target.value
if (!v) return
patchSelectedPayload((p) => ({
...p,
blocks: [...(p.blocks || []), reportNewBlock(v)],
}))
e.target.value = ''
}}
>
<option value="">+ Block hinzufügen</option>
<option value="section">Überschrift</option>
<option value="viz_bundle">Verlauf-Bundle (KPIs &amp; Diagramme)</option>
<option value="chart">Einzel-Diagramm (Legacy)</option>
<option value="ai_insight">KI-Auswertung</option>
</select>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={handleSave}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
>
<Save size={16} />
Speichern
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={handleDeleteDefinition}
>
Diesen Bericht löschen
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={handleGeneratePdf}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
>
<Download size={16} />
PDF erzeugen
</button>
</div>
{exportUsage && (
<div style={{ marginTop: 10 }}>
<UsageBadge {...exportUsage} />
</div>
)}
</>
)}
</div>
</div>
)
}

View File

@ -1,15 +1,10 @@
import { useState, useEffect } from 'react' 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 { Link } from 'react-router-dom'
import { useProfile } from '../context/ProfileContext' import { useProfile } from '../context/ProfileContext'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { Avatar } from './ProfileSelect' import { Avatar } from './ProfileSelect'
import { api } from '../utils/api' 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 FeatureUsageOverview from '../components/FeatureUsageOverview'
import UsageBadge from '../components/UsageBadge' import UsageBadge from '../components/UsageBadge'
@ -28,11 +23,6 @@ export default function SettingsPage() {
const [newPin, setNewPin] = useState('') const [newPin, setNewPin] = useState('')
const [pinMsg, setPinMsg] = useState(null) const [pinMsg, setPinMsg] = useState(null)
const [exportUsage, setExportUsage] = useState(null) // Phase 3: Usage badge 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 // Load feature usage for export badges
useEffect(() => { useEffect(() => {
@ -42,87 +32,6 @@ export default function SettingsPage() {
}).catch(err => console.error('Failed to load usage:', err)) }).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 () => { const handleLogout = async () => {
if (!confirm('Ausloggen?')) return if (!confirm('Ausloggen?')) return
await logout() await logout()
@ -587,20 +496,15 @@ export default function SettingsPage() {
<FeatureUsageOverview /> <FeatureUsageOverview />
</div> </div>
{/* Strukturierter PDF-Bericht (Profil v1) */} {/* PDF-Berichte: eigener Tab (wie Übersicht) */}
<div className="card section-gap"> <div className="card section-gap">
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<FileText size={18} color="var(--accent)" /> <FileText size={18} color="var(--accent)" />
PDF-Bericht (strukturiert) PDF-Berichte
</div> </div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.65 }}> <p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.65 }}>
<strong>Eigenes Berichtsprofil:</strong> Überschriften, <strong>Verlauf-Bundles</strong> (KPIs, Mehrere strukturierte Berichte, Zeiträume pro Verlauf-Bundle und PDF-Erzeugung findest du im
Einschätzungen und Diagramme wie im Bereich Verlauf) und optional einzelne Legacy-Diagramme. Gleiche eigenen Bereich analog Übersicht in den Einstellungen.
Schalter wie unter{' '}
<Link to="/settings/dashboard-layout" style={{ color: 'var(--accent)' }}>
Übersicht anpassen
</Link>
. PDF wird serverseitig aus dem Datenlayer erzeugt kein Screenshot der Widgets.
</p> </p>
{!canExport && ( {!canExport && (
<div <div
@ -613,319 +517,17 @@ export default function SettingsPage() {
marginBottom: 12, marginBottom: 12,
}} }}
> >
🔒 PDF-Bericht nutzt dasselbe Kontingent wie Datenexporte. Bitte Admin kontaktieren. PDF nutzt dasselbe Kontingent wie Datenexporte. Bitte Admin kontaktieren.
</div> </div>
)} )}
{reportNote && ( {canExport && (
<div <Link
style={{ to="/settings/reports"
padding: '10px 12px', className="btn btn-primary btn-full"
borderRadius: 8, style={{ textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box' }}
fontSize: 13,
marginBottom: 12,
background: reportNote.type === 'ok' ? '#E1F5EE' : '#FCEBEB',
color: reportNote.type === 'ok' ? 'var(--accent)' : '#D85A30',
}}
> >
{reportNote.text} Zu den PDF-Berichten
</div> </Link>
)}
{canExport && reportDraft && reportCatalog && (
<>
<label className="form-label" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
Dokumenttitel (optional)
</label>
<input
type="text"
className="form-input"
maxLength={120}
placeholder="Leer = Profilname + Bericht"
value={reportDraft.document_title || ''}
onChange={(e) => setReportDraft((d) => ({ ...d, document_title: e.target.value }))}
style={{ marginBottom: 14 }}
/>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text2)', marginBottom: 8 }}>
Blöcke {reportStored ? '' : '(Standard — noch nicht separat gespeichert)'}
</div>
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 12px' }}>
{reportDraft.blocks?.map((b, idx) => (
<li
key={idx}
style={{
border: '1px solid var(--border)',
borderRadius: 10,
padding: 12,
marginBottom: 10,
background: 'var(--surface2)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
<span style={{ fontSize: 11, color: 'var(--text3)', textTransform: 'uppercase' }}>{b.type}</span>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '4px 8px' }}
aria-label="Block entfernen"
onClick={() =>
setReportDraft((d) => ({
...d,
blocks: d.blocks.filter((_, j) => j !== idx),
}))
}
>
<Trash2 size={16} />
</button>
</div>
{b.type === 'section' && (
<input
type="text"
className="form-input"
style={{ marginTop: 8 }}
value={b.title || ''}
onChange={(e) =>
setReportDraft((d) => {
const blocks = d.blocks.map((x, j) =>
j === idx ? { ...x, title: e.target.value } : x
)
return { ...d, blocks }
})
}
/>
)}
{b.type === 'chart' && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
<select
className="form-input"
value={b.chart_id}
onChange={(e) =>
setReportDraft((d) => {
const blocks = d.blocks.map((x, j) =>
j === idx ? { ...x, chart_id: e.target.value } : x
)
return { ...d, blocks }
})
}
>
{reportCatalog.charts?.map((c) => (
<option key={c.id} value={c.id}>
{c.title}
</option>
))}
</select>
<div>
<label style={{ fontSize: 11, color: 'var(--text3)' }}>Zeitraum (Tage)</label>
<input
type="number"
className="form-input"
min={7}
max={365}
value={b.window_days}
onChange={(e) =>
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 }
})
}
/>
</div>
</div>
)}
{b.type === 'ai_insight' && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
<input
type="text"
className="form-input"
placeholder="Optional: Überschrift"
value={b.title || ''}
onChange={(e) =>
setReportDraft((d) => {
const blocks = d.blocks.map((x, j) =>
j === idx ? { ...x, title: e.target.value } : x
)
return { ...d, blocks }
})
}
/>
<input
type="text"
className="form-input"
placeholder="Optional: Insight-UUID (aus KI-Verlauf)"
value={b.insight_id || ''}
onChange={(e) =>
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 }
})
}
/>
</div>
)}
{b.type === 'viz_bundle' && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div>
<label style={{ fontSize: 11, color: 'var(--text3)', display: 'block', marginBottom: 4 }}>
Verlauf-Bundle
</label>
<select
className="form-input"
value={b.bundle_id}
onChange={(e) =>
setReportDraft((d) => {
const blocks = d.blocks.map((x, j) =>
j === idx ? { ...x, bundle_id: e.target.value, config: {} } : x
)
return { ...d, blocks }
})
}
>
{(reportCatalog.viz_bundles || []).map((vb) => (
<option key={vb.id} value={vb.id}>
{vb.title}
</option>
))}
</select>
</div>
{b.bundle_id === 'body_history_viz' && (
<BodyHistoryVizConfigEditor
config={b.config || {}}
onChange={(next) =>
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' && (
<NutritionHistoryVizConfigEditor
config={b.config || {}}
onChange={(next) =>
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' && (
<FitnessHistoryVizConfigEditor
config={b.config || {}}
onChange={(next) =>
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' && (
<RecoveryHistoryVizConfigEditor
config={b.config || {}}
onChange={(next) =>
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' && (
<HistoryOverviewVizConfigEditor
config={b.config || {}}
onChange={(next) =>
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 }
})
}
/>
)}
</div>
)}
</li>
))}
</ul>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
<select
className="form-input"
style={{ maxWidth: 220 }}
defaultValue=""
onChange={(e) => {
const v = e.target.value
if (!v) return
setReportDraft((d) => ({ ...d, blocks: [...(d.blocks || []), reportNewBlock(v)] }))
e.target.value = ''
}}
>
<option value="">+ Block hinzufügen</option>
<option value="section">Überschrift</option>
<option value="viz_bundle">Verlauf-Bundle (KPIs &amp; Diagramme)</option>
<option value="chart">Einzel-Diagramm (Legacy)</option>
<option value="ai_insight">KI-Auswertung</option>
</select>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<button
type="button"
className="btn btn-primary"
disabled={reportBusy}
onClick={handleSaveReportProfile}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
>
<Save size={16} />
Bericht speichern
</button>
<button
type="button"
className="btn btn-secondary"
disabled={reportBusy}
onClick={handleResetReportProfile}
>
Standard wiederherstellen
</button>
<button
type="button"
className="btn btn-secondary"
disabled={reportBusy}
onClick={handleGenerateStructuredPdf}
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
>
<Download size={16} />
PDF erzeugen
</button>
</div>
{exportUsage && (
<div style={{ marginTop: 10 }}>
<UsageBadge {...exportUsage} />
</div>
)}
</>
)} )}
</div> </div>

View File

@ -268,11 +268,18 @@ export const api = {
// Strukturierter PDF-Bericht (Profil v1, unabhängig vom Dashboard) // Strukturierter PDF-Bericht (Profil v1, unabhängig vom Dashboard)
getReportsCatalog: () => req('/reports/catalog'), getReportsCatalog: () => req('/reports/catalog'),
getReportProfile: () => req('/reports/profile'), listReportDefinitions: () => req('/reports/definitions'),
putReportProfile: (profile) => req('/reports/profile', jput(profile)), createReportDefinition: (body) => req('/reports/definitions', json(body ?? {})),
resetReportProfile: () => req('/reports/profile', { method: 'DELETE' }), updateReportDefinition: (id, body) =>
generateStructuredReportPdf: async () => { req(`/reports/definitions/${encodeURIComponent(id)}`, jput(body)),
const res = await fetch(`${BASE}/reports/generate-pdf`, { method: 'POST', headers: hdrs() }) 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) { if (!res.ok) {
let msg = `HTTP ${res.status}` let msg = `HTTP ${res.status}`
try { try {