feat: add report_export widget and enhance report generation capabilities
All checks were successful
Deploy Development / deploy (push) Successful in 1m4s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 19s

- Introduced the `report_export` widget to the dashboard, allowing users to generate structured PDF reports.
- Updated widget configuration to include `report_export` in the allowed widgets and added validation for its configuration.
- Enhanced the widget catalog with details for the new `report_export` entry.
- Implemented API endpoints for managing report profiles and generating PDFs.
- Added frontend components for configuring and displaying report settings.
- Updated tests to ensure proper validation and functionality of the new report generation features.
- Bumped application version to reflect the addition of the new widget and related functionalities.
This commit is contained in:
Lars 2026-04-29 11:28:04 +02:00
parent 141df021c1
commit 62729d0648
22 changed files with 1389 additions and 7 deletions

View File

@ -0,0 +1,56 @@
# Berichtsprofile & PDF (technisch)
**Stand:** 2026-04-29
## Begriffe
| Begriff | Bedeutung |
|--------|-----------|
| **Layout-Snapshot** | PDF aus gerasteter DOM-Übersicht (`html2canvas` + `jspdf`), optional Widget `report_export`. |
| **Strukturierter Bericht** | Profil mit Blöcken (`section`, `chart`, `ai_insight`), PDF serverseitig via Data Layer + Matplotlib + ReportLab. |
Die beiden Wege sind bewusst getrennt, damit das Dashboard nicht die einzige „Wahrheit“ für Dokumente wird.
## Datenbank
- Tabelle `report_profiles` (Migration `060_report_profiles.sql`): `profile_id` PK → `profiles`, `payload` JSONB, `updated_at`.
Ohne Zeile gilt ein **Code-Standard** (`default_report_profile_dict` in `report_profile_schema.py`).
## API (`/api/reports`)
| Methode | Pfad | Zweck |
|--------|------|--------|
| GET | `/catalog` | Diagramm-Katalog + Blocktypen für UI |
| GET | `/profile` | `{ stored, profile }` |
| PUT | `/profile` | Vollständiges Profil-JSON (Pydantic-validiert) |
| DELETE | `/profile` | DB-Zeile löschen → wieder Standard |
| POST | `/generate-pdf` | PDF-Download; `data_export`-Kontingent + `increment_feature_usage` |
## Schema v1 (`report_profile_schema.py`)
- `version`: nur `1`
- `document_title`: optional
- `blocks`: Liste mit Union:
- `section`: `title`
- `chart`: `chart_id``ALLOWED_CHART_IDS`, `window_days` 7365
- `ai_insight`: optional `insight_id` (UUID, `ai_insights.id`), optional `title`
## Diagrammdaten
`report_chart_fetch.fetch_chart_payload` ruft dieselben Bausteine auf wie `/api/charts` (ohne HTTP). Erweiterung: Eintrag in `ALLOWED_CHART_IDS`, Fetcher in `_CHART_FETCHERS`, Zeile in `CHART_CATALOG_FOR_API`.
## PDF-Rendering
`report_pdf_render.build_structured_report_pdf`: ReportLab-Flowable-Kette, Diagramme als PNG aus Chart-Payload (Matplotlib, Agg-Backend).
## Frontend
- **Einstellungen:** Karte „PDF-Bericht (strukturiert)“ — Blöcke bearbeiten, speichern, Standard, PDF erzeugen.
- **Dashboard:** Widget bleibt optionaler **Schnappschuss**; Hinweis verweist auf Einstellungen.
## Nächste sinnvolle Erweiterungen
- Dashboard-Layout → Berichtsprofil **einmalig importieren** (Mapping-Tabelle Widget-ID → chart_id).
- KI: Insights-Auswahl in der UI statt manueller UUID.
- Weitere `chart_id`-Werte / multipage Feintuning (Seitenumbrüche pro Block).

View File

@ -25,6 +25,7 @@ WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({
"trend_kcal_weight", "trend_kcal_weight",
"nutrition_detail_charts", "nutrition_detail_charts",
"recovery_charts_panel", "recovery_charts_panel",
"report_export",
}) })
_QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({ _QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({
@ -201,6 +202,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
return _validate_recovery_history_viz_config({}) return _validate_recovery_history_viz_config({})
if widget_id == "history_overview_viz": if widget_id == "history_overview_viz":
return _validate_history_overview_viz_config({}) return _validate_history_overview_viz_config({})
if widget_id == "report_export":
return _validate_report_export_config({})
return {} return {}
if widget_id == "body_overview": if widget_id == "body_overview":
@ -227,6 +230,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
return _validate_chart_days_only(raw, label="nutrition_detail_charts") return _validate_chart_days_only(raw, label="nutrition_detail_charts")
if widget_id == "recovery_charts_panel": if widget_id == "recovery_charts_panel":
return _validate_chart_days_only(raw, label="recovery_charts_panel") return _validate_chart_days_only(raw, label="recovery_charts_panel")
if widget_id == "report_export":
return _validate_report_export_config(raw)
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt") raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
@ -530,3 +535,43 @@ def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, A
return {"chart_days": v} return {"chart_days": v}
def _validate_report_export_config(raw: dict[str, Any]) -> dict[str, Any]:
label = "report_export"
allowed = frozenset({"document_title", "subtitle", "capture_scale"})
unknown = set(raw) - allowed
if unknown:
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
out: dict[str, Any] = {"capture_scale": 2}
if "document_title" in raw:
t = raw["document_title"]
if t is not None and not isinstance(t, str):
raise ValueError(f"{label}: document_title muss Text sein")
s = (t or "").strip()
if len(s) > 120:
raise ValueError(f"{label}: document_title max. 120 Zeichen")
if s:
out["document_title"] = s
if "subtitle" in raw:
t = raw["subtitle"]
if t is not None and not isinstance(t, str):
raise ValueError(f"{label}: subtitle muss Text sein")
s = (t or "").strip()
if len(s) > 240:
raise ValueError(f"{label}: subtitle max. 240 Zeichen")
if s:
out["subtitle"] = s
if "capture_scale" in raw:
v = raw["capture_scale"]
if isinstance(v, bool) or isinstance(v, float):
if isinstance(v, float) and math.isfinite(v) and abs(v - round(v)) < 1e-9:
v = int(round(v))
else:
raise ValueError(f"{label}: capture_scale muss ganze Zahl 13 sein")
if not isinstance(v, int):
raise ValueError(f"{label}: capture_scale muss ganze Zahl 13 sein")
if v < 1 or v > 3:
raise ValueError(f"{label}: capture_scale muss zwischen 1 und 3 liegen")
out["capture_scale"] = v
return out

View File

@ -35,6 +35,7 @@ from routers import workflows # Phase 2 Workflow Engine - Execution
from routers import reference_values # Persönliche Referenzwerte (Profil) from routers import reference_values # Persönliche Referenzwerte (Profil)
from routers import admin_reference_value_types # Admin: Referenzwert-Typen from routers import admin_reference_value_types # Admin: Referenzwert-Typen
from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Layout + Widget-Katalog from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Layout + Widget-Katalog
from routers import reports # Strukturierter PDF-Bericht (Profil v1)
from routers import csv_import, admin_csv_templates # Issue #21 Universal CSV Parser from routers import csv_import, admin_csv_templates # Issue #21 Universal CSV Parser
from routers import admin_training_parameters, admin_activity_attribute_profiles # EAV session metrics from routers import admin_training_parameters, admin_activity_attribute_profiles # EAV session metrics
@ -127,6 +128,7 @@ app.include_router(workflows.router) # /api/workflows/* (Phase 2 Exec
app.include_router(reference_values.router) # /api/reference-value-types, /api/profile-reference-values app.include_router(reference_values.router) # /api/reference-value-types, /api/profile-reference-values
app.include_router(admin_reference_value_types.router) # /api/admin/reference-value-types app.include_router(admin_reference_value_types.router) # /api/admin/reference-value-types
app.include_router(app_dashboard.router) # /api/app/dashboard-layout app.include_router(app_dashboard.router) # /api/app/dashboard-layout
app.include_router(reports.router) # /api/reports/* (Berichtsprofil + PDF)
app.include_router(csv_import.router) # /api/csv/* (Issue #21) app.include_router(csv_import.router) # /api/csv/* (Issue #21)
app.include_router(admin_csv_templates.router) # /api/admin/csv-templates/* (Issue #21) app.include_router(admin_csv_templates.router) # /api/admin/csv-templates/* (Issue #21)
app.include_router(admin_training_parameters.router) # /api/admin/training-parameters app.include_router(admin_training_parameters.router) # /api/admin/training-parameters

View File

@ -0,0 +1,11 @@
-- Migration 060: Strukturierter Bericht (Profil JSON pro Nutzerprofil, unabhängig vom Dashboard-Layout)
CREATE TABLE IF NOT EXISTS report_profiles (
profile_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_report_profiles_updated ON report_profiles(updated_at);
COMMENT ON TABLE report_profiles IS 'Konfigurierbarer PDF-Bericht v1 (Blöcke: section, chart, ai_insight); Rendering serverseitig aus Datenlayer';

View File

@ -0,0 +1,139 @@
"""
Chart-Daten für Berichts-PDF: dieselbe Logik wie /api/charts/* (Data Layer), ohne HTTP.
"""
from __future__ import annotations
from typing import Any, Callable
from data_layer.activity_metrics import (
build_training_type_distribution_chart_payload,
build_training_volume_chart_payload,
)
from data_layer.body_metrics import get_weight_trend_data
from data_layer.nutrition_chart_payloads import build_energy_balance_chart_payload
from data_layer.nutrition_metrics import get_nutrition_average_data
from data_layer.utils import serialize_dates
def _weight_trend_payload(profile_id: str, days: int) -> dict[str, Any]:
d = min(max(days, 7), 365)
trend_data = get_weight_trend_data(profile_id, d)
if trend_data["confidence"] == "insufficient":
return {
"chart_type": "line",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": "Nicht genug Daten für Trend-Analyse",
},
}
series = trend_data.get("series") or []
labels = [
pt["date"].isoformat() if hasattr(pt["date"], "isoformat") else str(pt["date"]) for pt in series
]
values = [pt["weight"] for pt in series]
return {
"chart_type": "line",
"data": {
"labels": labels,
"datasets": [
{
"label": "Gewicht",
"data": values,
"borderColor": "#1D9E75",
"backgroundColor": "rgba(29, 158, 117, 0.1)",
"borderWidth": 2,
"tension": 0.4,
"fill": True,
"pointRadius": 2,
}
],
},
"metadata": serialize_dates(
{
"confidence": trend_data["confidence"],
"data_points": trend_data["data_points"],
"first_value": trend_data["first_value"],
"last_value": trend_data["last_value"],
"delta": trend_data["delta"],
"direction": trend_data["direction"],
}
),
}
def _macro_distribution_payload(profile_id: str, days: int) -> dict[str, Any]:
d = min(max(days, 7), 90)
macro_data = get_nutrition_average_data(profile_id, d)
if macro_data["confidence"] == "insufficient":
return {
"chart_type": "pie",
"data": {"labels": [], "datasets": []},
"metadata": {"confidence": "insufficient", "message": "Keine Ernährungsdaten vorhanden"},
}
protein_kcal = macro_data["protein_avg"] * 4
carbs_kcal = macro_data["carbs_avg"] * 4
fat_kcal = macro_data["fat_avg"] * 9
total_kcal = protein_kcal + carbs_kcal + fat_kcal
if total_kcal == 0:
return {
"chart_type": "pie",
"data": {"labels": [], "datasets": []},
"metadata": {"confidence": "insufficient", "message": "Keine Makronährstoff-Daten"},
}
protein_pct = protein_kcal / total_kcal * 100
carbs_pct = carbs_kcal / total_kcal * 100
fat_pct = fat_kcal / total_kcal * 100
return {
"chart_type": "pie",
"data": {
"labels": ["Protein", "Kohlenhydrate", "Fett"],
"datasets": [
{
"data": [round(protein_pct, 1), round(carbs_pct, 1), round(fat_pct, 1)],
"backgroundColor": ["#1D9E75", "#F59E0B", "#EF4444"],
"borderWidth": 2,
"borderColor": "#fff",
}
],
},
"metadata": {"confidence": macro_data.get("confidence", "high")},
}
def _training_volume_payload(profile_id: str, window_days: int) -> dict[str, Any]:
w = max(4, min(52, window_days // 7))
return build_training_volume_chart_payload(profile_id, w)
_CHART_FETCHERS: dict[str, Callable[[str, int], dict[str, Any]]] = {
"weight_trend": _weight_trend_payload,
"energy_balance": lambda pid, d: build_energy_balance_chart_payload(pid, min(max(d, 7), 90)),
"macro_distribution": _macro_distribution_payload,
"training_volume": _training_volume_payload,
"training_type_distribution": lambda pid, d: build_training_type_distribution_chart_payload(
pid, min(max(d, 7), 90)
),
}
def fetch_chart_payload(chart_id: str, profile_id: str, window_days: int) -> dict[str, Any]:
fn = _CHART_FETCHERS.get(chart_id)
if not fn:
raise ValueError(f"Unbekanntes chart_id: {chart_id}")
return fn(profile_id, window_days)
CHART_CATALOG_FOR_API: list[dict[str, Any]] = [
{"id": "weight_trend", "title": "Gewichtstrend", "default_window_days": 90, "window_max": 365},
{"id": "energy_balance", "title": "Energiebilanz", "default_window_days": 28, "window_max": 90},
{"id": "macro_distribution", "title": "Makroverteilung (Ø)", "default_window_days": 28, "window_max": 90},
{"id": "training_volume", "title": "Trainingsvolumen (Wochen)", "default_window_days": 84, "window_max": 365},
{
"id": "training_type_distribution",
"title": "Trainingsart-Verteilung",
"default_window_days": 28,
"window_max": 90,
},
]

View File

@ -0,0 +1,212 @@
"""
PDF-Bericht aus ReportProfilePayload: ReportLab für Text/Layout, Matplotlib für Chart.js-ähnliche Payloads.
"""
from __future__ import annotations
import io
import logging
from typing import Any
from xml.sax.saxutils import escape
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import Image as RLImage
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
from db import get_cursor, get_db
from report_chart_fetch import fetch_chart_payload
from report_profile_schema import (
AiInsightBlock,
ChartBlock,
ReportProfilePayload,
SectionBlock,
)
logger = logging.getLogger(__name__)
_CONTENT_TRUNCATE = 12000
def _color_to_rgb(hex_or_rgba: str) -> tuple[float, float, float]:
s = (hex_or_rgba or "#333333").strip()
if s.startswith("#") and len(s) >= 7:
try:
r = int(s[1:3], 16) / 255.0
g = int(s[3:5], 16) / 255.0
b = int(s[5:7], 16) / 255.0
return (r, g, b)
except ValueError:
pass
return (0.12, 0.62, 0.46)
def chart_payload_to_png(payload: dict[str, Any], fig_width_in: float = 6.2, fig_height_in: float = 3.4) -> bytes:
"""Erzeugt PNG aus Chart.js-kompatiblem Payload (line, bar, pie)."""
chart_type = payload.get("chart_type") or "line"
data = payload.get("data") or {}
labels = data.get("labels") or []
datasets = data.get("datasets") or []
fig, ax = plt.subplots(figsize=(fig_width_in, fig_height_in), dpi=120)
ax.set_facecolor("#fafaf9")
fig.patch.set_facecolor("#ffffff")
if chart_type == "pie" and datasets:
ds0 = datasets[0]
values = ds0.get("data") or []
colors = ds0.get("backgroundColor") or ["#1D9E75", "#378ADD", "#D85A30"]
if labels and values and len(labels) == len(values):
ax.pie(values, labels=labels, autopct="%1.0f%%", colors=colors[: len(values)], startangle=90)
ax.axis("equal")
else:
ax.text(0.5, 0.5, "Keine Daten", ha="center", va="center", transform=ax.transAxes)
elif chart_type in ("line", "bar", "scatter") and datasets:
x = range(len(labels)) if labels else []
for i, ds in enumerate(datasets):
y = ds.get("data") or []
if not y:
continue
lab = ds.get("label") or f"Serie {i + 1}"
col = _color_to_rgb(str(ds.get("borderColor") or ds.get("backgroundColor") or "#1D9E75"))
if chart_type == "bar":
yv = y[: len(labels)] if labels else y
bg = ds.get("backgroundColor")
if isinstance(bg, list):
cols = [_color_to_rgb(str(c)) for c in bg[: len(yv)]]
else:
cols = [_color_to_rgb(str(bg or "#1D9E75"))] * len(yv)
ax.bar(list(range(len(yv))), yv, label=lab, color=cols[: len(yv)], alpha=0.88)
else:
ax.plot(
list(x)[: len(y)],
y,
label=lab,
color=col,
linewidth=1.6,
marker="o",
markersize=2,
)
if labels and chart_type != "bar":
step = max(1, len(labels) // 8)
ax.set_xticks(list(x)[::step])
ax.set_xticklabels([labels[j] for j in range(0, len(labels), step)], rotation=25, fontsize=7)
elif labels and chart_type == "bar":
ax.set_xticks(list(x))
ax.set_xticklabels(labels, rotation=30, fontsize=7)
ax.legend(loc="upper right", fontsize=7)
ax.grid(True, alpha=0.25)
ax.set_xmargin(0.02)
else:
ax.text(0.5, 0.5, "Diagrammtyp nicht unterstützt oder leer", ha="center", va="center", transform=ax.transAxes)
fig.tight_layout()
buf = io.BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", facecolor=fig.get_facecolor())
plt.close(fig)
buf.seek(0)
return buf.read()
def _insight_text(profile_id: str, insight_id: str | None) -> tuple[str, str]:
"""Returns (heading, body_text)."""
if not insight_id:
return (
"KI-Auswertung",
"(Noch keine Auswahl — in einer späteren Version kannst du hier eine gespeicherte KI-Analyse "
"verknüpfen.)",
)
try:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT scope, content, created FROM ai_insights WHERE id = %s AND profile_id = %s",
(insight_id, profile_id),
)
row = cur.fetchone()
if not row:
return ("KI-Auswertung", "Eintrag nicht gefunden oder keine Berechtigung.")
scope = row.get("scope") or "Analyse"
content = row.get("content") or ""
if len(content) > _CONTENT_TRUNCATE:
content = content[:_CONTENT_TRUNCATE] + "\n\n[… gekürzt …]"
created = row.get("created")
sub = f"{scope}" + (f" · {created}" if created else "")
return (sub, content)
except Exception as e:
logger.warning("report pdf insight load failed: %s", e)
return ("KI-Auswertung", "Fehler beim Laden des Eintrags.")
def build_structured_report_pdf(
*,
profile_id: str,
profile_name: str,
payload: ReportProfilePayload,
) -> bytes:
"""Vollständiges PDF als Bytes (A4)."""
buf = io.BytesIO()
doc = SimpleDocTemplate(
buf,
pagesize=A4,
leftMargin=14 * mm,
rightMargin=14 * mm,
topMargin=16 * mm,
bottomMargin=16 * mm,
)
styles = getSampleStyleSheet()
story: list[Any] = []
title = (payload.document_title or "").strip() or f"{profile_name} Bericht"
story.append(Paragraph(escape(title), styles["Title"]))
story.append(Spacer(1, 6 * mm))
for block in payload.blocks:
if isinstance(block, SectionBlock):
story.append(Spacer(1, 4 * mm))
story.append(Paragraph(escape(block.title), styles["Heading2"]))
story.append(Spacer(1, 2 * mm))
elif isinstance(block, ChartBlock):
try:
chart = fetch_chart_payload(block.chart_id, profile_id, block.window_days)
except Exception as e:
logger.warning("chart fetch %s: %s", block.chart_id, e)
story.append(Paragraph(f"Diagramm {block.chart_id}: Fehler bei Daten.", styles["Normal"]))
continue
meta = chart.get("metadata") or {}
if meta.get("confidence") == "insufficient":
msg = meta.get("message") or "Nicht genug Daten"
story.append(Paragraph(f"<i>{block.chart_id}</i>: {msg}", styles["Normal"]))
story.append(Spacer(1, 3 * mm))
continue
try:
png = chart_payload_to_png(chart)
img_buf = io.BytesIO(png)
# Breite ~ volle Textbreite (~180mm auf A4 mit Standardrändern Platypus)
iw = 170 * mm
ih = 85 * mm
story.append(RLImage(img_buf, width=iw, height=ih))
except Exception as e:
logger.warning("chart render %s: %s", block.chart_id, e)
story.append(Paragraph(f"Diagramm {block.chart_id}: Darstellung fehlgeschlagen.", styles["Normal"]))
story.append(Spacer(1, 4 * mm))
elif isinstance(block, AiInsightBlock):
heading, body = _insight_text(profile_id, block.insight_id)
if block.title.strip():
story.append(Paragraph(escape(block.title), styles["Heading3"]))
else:
story.append(Paragraph(escape(heading), styles["Heading3"]))
for para in body.split("\n\n"):
p = (para or "").strip()
if p:
story.append(Paragraph(escape(p), styles["BodyText"]))
story.append(Spacer(1, 4 * mm))
doc.build(story)
return buf.getvalue()

View File

@ -0,0 +1,92 @@
"""
Konfigurierbarer PDF-Bericht v1: Payload-Schema (unabhängig vom Dashboard-Layout).
Block-Typen:
- section: Überschrift
- chart: diagramm via report_chart_fetch (chart_id + window_days)
- ai_insight: optional insight_id (UUID), sonst Platzhalter für spätere Auswahl
"""
from __future__ import annotations
from typing import Literal, Union
from pydantic import BaseModel, Field, model_validator
ALLOWED_CHART_IDS: frozenset[str] = frozenset(
{
"weight_trend",
"energy_balance",
"macro_distribution",
"training_volume",
"training_type_distribution",
}
)
_MAX_BLOCKS = 24
class SectionBlock(BaseModel):
type: Literal["section"] = "section"
title: str = Field(min_length=1, max_length=200)
class ChartBlock(BaseModel):
type: Literal["chart"] = "chart"
chart_id: str = Field(min_length=1, max_length=64)
window_days: int = Field(default=28, ge=7, le=365)
@model_validator(mode="after")
def _chart_known(self) -> ChartBlock:
if self.chart_id not in ALLOWED_CHART_IDS:
raise ValueError(f"Unbekanntes chart_id: {self.chart_id!r} (erlaubt: {sorted(ALLOWED_CHART_IDS)})")
return self
class AiInsightBlock(BaseModel):
type: Literal["ai_insight"] = "ai_insight"
title: str = Field(default="", max_length=200)
insight_id: str | None = Field(default=None, max_length=48)
class ReportProfilePayload(BaseModel):
version: Literal[1] = 1
document_title: str = Field(default="", max_length=120)
blocks: list[Union[SectionBlock, ChartBlock, AiInsightBlock]]
@model_validator(mode="after")
def _blocks_limit(self) -> ReportProfilePayload:
if len(self.blocks) > _MAX_BLOCKS:
raise ValueError(f"Maximal {_MAX_BLOCKS} Blöcke erlaubt")
if not self.blocks:
raise ValueError("Mindestens ein Block erforderlich")
return self
def to_stored_dict(self) -> dict:
return {
"version": self.version,
"document_title": self.document_title,
"blocks": [b.model_dump(mode="json") for b in self.blocks],
}
def default_report_profile_dict() -> dict:
"""Standard-Bericht beim ersten Zugriff (ohne DB-Zeile)."""
p = ReportProfilePayload(
document_title="",
blocks=[
SectionBlock(title="Körpergewicht"),
ChartBlock(chart_id="weight_trend", window_days=90),
SectionBlock(title="Energiebilanz"),
ChartBlock(chart_id="energy_balance", window_days=28),
SectionBlock(title="Trainingsvolumen"),
ChartBlock(chart_id="training_volume", window_days=84),
],
)
return p.to_stored_dict()
def parse_report_profile(raw: dict | None) -> ReportProfilePayload:
if raw is None or raw == {}:
return ReportProfilePayload.model_validate(default_report_profile_dict())
return ReportProfilePayload.model_validate(raw)

View File

@ -10,3 +10,5 @@ slowapi==0.1.9
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
python-dateutil==2.9.0 python-dateutil==2.9.0
tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows
matplotlib==3.8.4
reportlab==4.2.0

156
backend/routers/reports.py Normal file
View File

@ -0,0 +1,156 @@
"""
Strukturierter PDF-Bericht (Profil v1): GET/PUT Profil, Katalog, PDF-Erzeugung.
Trennung vom Dashboard-Layout; Daten aus data_layer wie /api/charts.
PDF-Zähler: data_export (wie andere Exporte).
"""
from __future__ import annotations
import logging
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from psycopg2.extras import Json
from auth import check_feature_access, increment_feature_usage, require_auth
from db import get_cursor, get_db
from feature_logger import log_feature_usage
from report_chart_fetch import CHART_CATALOG_FOR_API
from report_pdf_render import build_structured_report_pdf
from report_profile_schema import (
ReportProfilePayload,
default_report_profile_dict,
parse_report_profile,
)
router = APIRouter(prefix="/api/reports", tags=["reports"])
logger = logging.getLogger(__name__)
@router.get("/catalog")
def get_reports_catalog(session: dict = Depends(require_auth)):
"""Metadaten für UI: verfügbare Diagramme und Blocktypen."""
return {
"catalog_version": 1,
"charts": CHART_CATALOG_FOR_API,
"block_types": [
{"id": "section", "title": "Überschrift"},
{"id": "chart", "title": "Diagramm"},
{"id": "ai_insight", "title": "KI-Auswertung"},
],
}
def _fetch_payload_row(profile_id: str) -> dict | None:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT payload FROM report_profiles WHERE profile_id = %s", (profile_id,))
row = cur.fetchone()
if not row:
return None
p = row.get("payload")
return p if isinstance(p, dict) else None
def _profile_display_name(profile_id: str) -> str:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT name FROM profiles WHERE id = %s", (profile_id,))
row = cur.fetchone()
if not row:
return "Profil"
return (row.get("name") or "Profil").strip() or "Profil"
@router.get("/profile")
def get_report_profile(session: dict = Depends(require_auth)):
pid = session["profile_id"]
raw = _fetch_payload_row(pid)
if raw is None:
return {"stored": False, "profile": default_report_profile_dict()}
try:
parse_report_profile(raw)
except Exception as e:
logger.warning("report profile invalid for %s: %s", pid, e)
return {"stored": False, "profile": default_report_profile_dict(), "previous_invalid": True}
return {"stored": True, "profile": raw}
@router.put("/profile")
def put_report_profile(body: dict, session: dict = Depends(require_auth)):
pid = session["profile_id"]
try:
parsed = ReportProfilePayload.model_validate(body)
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
INSERT INTO report_profiles (profile_id, payload, updated_at)
VALUES (%s, %s, CURRENT_TIMESTAMP)
ON CONFLICT (profile_id) DO UPDATE SET
payload = EXCLUDED.payload,
updated_at = CURRENT_TIMESTAMP
""",
(pid, Json(parsed.to_stored_dict())),
)
conn.commit()
return {"ok": True, "profile": parsed.to_stored_dict()}
@router.delete("/profile")
def delete_report_profile(session: dict = Depends(require_auth)):
"""Zurück auf Code-Standard (kein DB-Eintrag)."""
pid = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("DELETE FROM report_profiles WHERE profile_id = %s", (pid,))
conn.commit()
return {"ok": True, "profile": default_report_profile_dict()}
@router.post("/generate-pdf")
def generate_structured_report_pdf(session: dict = Depends(require_auth)):
pid = session["profile_id"]
access = check_feature_access(pid, "data_export")
log_feature_usage(pid, "data_export", access, "report_generate_pdf")
if not access["allowed"]:
logger.warning(
"[FEATURE-LIMIT] report pdf blocked: %s used=%s limit=%s",
pid,
access.get("used"),
access.get("limit"),
)
raise HTTPException(
status_code=403,
detail=(
"Limit erreicht: Daten-Export nicht möglich "
f"({access.get('used')}/{access.get('limit')})."
),
)
raw = _fetch_payload_row(pid)
try:
payload = parse_report_profile(raw)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Berichtsprofil ungültig: {e}")
name = _profile_display_name(pid)
try:
pdf_bytes = build_structured_report_pdf(profile_id=pid, profile_name=name, payload=payload)
except Exception as e:
logger.exception("report pdf build failed")
raise HTTPException(status_code=500, detail=f"PDF-Erzeugung fehlgeschlagen: {e}")
increment_feature_usage(pid, "data_export")
safe_name = "".join(c for c in name if c.isalnum() or c in (" ", "-", "_")).strip() or "profil"
fn = f"mitai-bericht-{safe_name.replace(' ', '-')}-{datetime.now().strftime('%Y-%m-%d')}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{fn}"'},
)

View File

@ -0,0 +1,31 @@
"""Berichtsprofil-Schema: Defaults und Validierung."""
from report_profile_schema import (
ReportProfilePayload,
default_report_profile_dict,
parse_report_profile,
)
def test_default_profile_roundtrip():
d = default_report_profile_dict()
p = ReportProfilePayload.model_validate(d)
assert p.version == 1
assert len(p.blocks) >= 3
def test_parse_empty_uses_default():
p = parse_report_profile({})
assert len(p.blocks) >= 1
def test_chart_block_unknown_id_raises():
import pytest
raw = {
"version": 1,
"document_title": "",
"blocks": [{"type": "chart", "chart_id": "not_a_chart", "window_days": 28}],
}
with pytest.raises(Exception):
ReportProfilePayload.model_validate(raw)

View File

@ -149,6 +149,12 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
"description": "Pipeline starten + Gesamt-Insight; Feature ai_pipeline", "description": "Pipeline starten + Gesamt-Insight; Feature ai_pipeline",
"requires_feature": "ai_pipeline", "requires_feature": "ai_pipeline",
}, },
{
"id": "report_export",
"title": "Übersicht als Bild-PDF",
"description": "Raster-PDF der Startübersicht (html2canvas); für strukturierten Datenbericht siehe Einstellungen. Optional document_title, subtitle, capture_scale; data_export",
"requires_feature": "data_export",
},
] ]
DEFAULT_LAB_WIDGET_IDS: frozenset[str] = frozenset( DEFAULT_LAB_WIDGET_IDS: frozenset[str] = frozenset(

View File

@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"html2canvas": "^1.4.1",
"jspdf": "^2.5.1", "jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2", "jspdf-autotable": "^3.8.2",
"lucide-react": "^0.383.0", "lucide-react": "^0.383.0",
@ -3147,7 +3148,6 @@
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">= 0.6.0" "node": ">= 0.6.0"
} }
@ -3418,7 +3418,6 @@
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"utrie": "^1.0.2" "utrie": "^1.0.2"
} }
@ -4454,7 +4453,6 @@
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"css-line-break": "^2.1.0", "css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3" "text-segmentation": "^1.0.3"
@ -6320,7 +6318,6 @@
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"utrie": "^1.0.2" "utrie": "^1.0.2"
} }
@ -6591,7 +6588,6 @@
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT", "license": "MIT",
"optional": true,
"dependencies": { "dependencies": {
"base64-arraybuffer": "^1.0.2" "base64-arraybuffer": "^1.0.2"
} }

View File

@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"dayjs": "^1.11.11", "dayjs": "^1.11.11",
"html2canvas": "^1.4.1",
"jspdf": "^2.5.1", "jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2", "jspdf-autotable": "^3.8.2",
"lucide-react": "^0.383.0", "lucide-react": "^0.383.0",

View File

@ -0,0 +1,107 @@
import { useState } from 'react'
import dayjs from 'dayjs'
import { FileDown } from 'lucide-react'
import { useAuth } from '../../context/AuthContext'
import { useProfile } from '../../context/ProfileContext'
import { exportDashboardToPdf } from '../../utils/dashboardPdfExport'
/**
* @param {{ reportExportConfig: { document_title: string, subtitle: string, capture_scale: number } }} props
*/
export default function ReportExportWidget({ reportExportConfig }) {
const { canExport } = useAuth()
const { activeProfile } = useProfile()
const [busy, setBusy] = useState(false)
const [err, setErr] = useState(null)
const profileName = activeProfile?.name?.trim() || 'Profil'
const title =
reportExportConfig.document_title || `${profileName} Übersicht`
const subtitle =
reportExportConfig.subtitle ||
`Erstellt am ${dayjs().format('DD.MM.YYYY HH:mm')} · Mitai Jinkendo`
const runExport = async () => {
setErr(null)
setBusy(true)
try {
const slug = (reportExportConfig.document_title || profileName).replace(/\s+/g, '-').slice(0, 80)
await exportDashboardToPdf({
scale: reportExportConfig.capture_scale,
filenameBase: `bericht-${slug}-${dayjs().format('YYYY-MM-DD')}`,
})
} catch (e) {
setErr(e?.message || 'PDF-Export fehlgeschlagen.')
} finally {
setBusy(false)
}
}
return (
<div className="card" style={{ marginBottom: 16 }}>
<div
className="card-title"
style={{ display: 'flex', alignItems: 'center', gap: 8 }}
data-dashboard-pdf-exclude="true"
>
<FileDown size={18} color="var(--accent)" aria-hidden />
Übersicht als Bild-PDF
</div>
<div style={{ marginTop: 12 }}>
<h2
style={{
fontSize: 17,
fontWeight: 650,
margin: '0 0 6px',
color: 'var(--text1)',
lineHeight: 1.35,
}}
>
{title}
</h2>
<p style={{ margin: 0, fontSize: 13, color: 'var(--text2)', lineHeight: 1.5 }}>{subtitle}</p>
</div>
<div data-dashboard-pdf-exclude="true">
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 14, lineHeight: 1.55 }}>
<strong>Layout-Schnappschuss:</strong> Die sichtbare Übersicht wird im Browser gerastert (html2canvas).
Für einen <strong>datenbasierten Bericht</strong> unabhängig vom Dashboard nutze{' '}
<strong>Einstellungen PDF-Bericht (strukturiert)</strong>.
</p>
<div style={{ marginTop: 14 }}>
{!canExport ? (
<p style={{ fontSize: 13, color: '#D85A30', margin: 0 }}>
PDF-Export ist für dieses Profil nicht freigeschaltet.
</p>
) : (
<>
{err && (
<p style={{ fontSize: 13, color: '#D85A30', margin: '0 0 10px' }} role="alert">
{err}
</p>
)}
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={runExport}
style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}
>
{busy ? (
<>
<span className="spinner" style={{ width: 18, height: 18 }} aria-hidden />
PDF wird erzeugt
</>
) : (
<>
<FileDown size={18} />
PDF-Schnappschuss herunterladen
</>
)}
</button>
</>
)}
</div>
</div>
</div>
)
}

View File

@ -123,7 +123,9 @@ export default function Dashboard() {
)} )}
{!layoutLoading && layoutForPreview && ( {!layoutLoading && layoutForPreview && (
<WidgetRenderer layout={layoutForPreview} refreshTick={refreshTick} requestRefresh={requestRefresh} /> <div id="dashboard-pdf-capture-root">
<WidgetRenderer layout={layoutForPreview} refreshTick={refreshTick} requestRefresh={requestRefresh} />
</div>
)} )}
</div> </div>
) )

View File

@ -16,6 +16,7 @@ import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryViz
import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor' import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor'
import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor' import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor'
import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor' import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor'
import ReportExportConfigEditor from '../widgetSystem/ReportExportConfigEditor'
import { import {
moveWidget, moveWidget,
moveWidgetToIndex, moveWidgetToIndex,
@ -590,6 +591,19 @@ export default function DashboardConfigurePage({ adminMode = false } = {}) {
} }
/> />
)} )}
{w.id === 'report_export' && (
<ReportExportConfigEditor
config={w.config || {}}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => (j !== i ? x : { ...x, config: { ...next } })),
})
)
}
/>
)}
</li> </li>
) )
})} })}

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target, LayoutDashboard } from 'lucide-react' import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target, LayoutDashboard, FileText, Trash2 } from 'lucide-react'
import { Link } from 'react-router-dom' import { 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'
@ -23,6 +23,11 @@ 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(() => {
@ -32,6 +37,84 @@ 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]
if (kind === 'section') return { type: 'section', title: 'Neue Überschrift' }
if (kind === 'chart')
return {
type: 'chart',
chart_id: first?.id || 'weight_trend',
window_days: first?.default_window_days || 28,
}
return { type: 'ai_insight', title: '', insight_id: null }
}
const handleSaveReportProfile = async () => {
if (!reportDraft) return
if (!reportDraft.blocks?.length) {
setReportNote({ type: 'err', text: 'Mindestens ein Block erforderlich.' })
return
}
setReportBusy(true)
setReportNote(null)
try {
await api.putReportProfile(reportDraft)
setReportStored(true)
setReportNote({ type: 'ok', text: 'Berichtsprofil gespeichert.' })
} catch (e) {
setReportNote({ type: 'err', text: e.message })
} finally {
setReportBusy(false)
}
}
const handleResetReportProfile = async () => {
if (!confirm('Persönliches Berichtsprofil löschen und Standard wiederherstellen?')) return
setReportBusy(true)
setReportNote(null)
try {
const bundle = await api.resetReportProfile()
setReportDraft(bundle.profile)
setReportStored(false)
setReportNote({ type: 'ok', text: 'Standard wiederhergestellt.' })
} catch (e) {
setReportNote({ type: 'err', text: e.message })
} finally {
setReportBusy(false)
}
}
const handleGenerateStructuredPdf = async () => {
setReportBusy(true)
setReportNote(null)
try {
await api.generateStructuredReportPdf()
setReportNote({ type: 'ok', text: 'PDF wurde heruntergeladen.' })
} catch (e) {
setReportNote({ type: 'err', text: e.message })
} finally {
setReportBusy(false)
}
}
const handleLogout = async () => { const handleLogout = async () => {
if (!confirm('Ausloggen?')) return if (!confirm('Ausloggen?')) return
await logout() await logout()
@ -496,6 +579,242 @@ export default function SettingsPage() {
<FeatureUsageOverview /> <FeatureUsageOverview />
</div> </div>
{/* Strukturierter PDF-Bericht (Profil v1) */}
<div className="card section-gap">
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<FileText size={18} color="var(--accent)" />
PDF-Bericht (strukturiert)
</div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.65 }}>
<strong>Eigenes Berichtsprofil:</strong> Reihenfolge, Überschriften und Diagramme unabhängig von der
Startübersicht. Die PDF-Datei wird <strong>serverseitig</strong> aus denselben Datenquellen wie die
Chart-API erzeugt (kein Screenshot). Das unterscheidet sich vom optionalen Widget Übersicht als
Bild-PDF auf der Startseite.
</p>
{!canExport && (
<div
style={{
padding: '10px 12px',
background: '#FCEBEB',
borderRadius: 8,
fontSize: 13,
color: '#D85A30',
marginBottom: 12,
}}
>
🔒 PDF-Bericht nutzt dasselbe Kontingent wie Datenexporte. Bitte Admin kontaktieren.
</div>
)}
{reportNote && (
<div
style={{
padding: '10px 12px',
borderRadius: 8,
fontSize: 13,
marginBottom: 12,
background: reportNote.type === 'ok' ? '#E1F5EE' : '#FCEBEB',
color: reportNote.type === 'ok' ? 'var(--accent)' : '#D85A30',
}}
>
{reportNote.text}
</div>
)}
{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>
)}
</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="chart">Diagramm</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>
{/* Export */} {/* Export */}
<div className="card section-gap"> <div className="card section-gap">
<div className="card-title">Daten exportieren</div> <div className="card-title">Daten exportieren</div>

View File

@ -266,6 +266,38 @@ export const api = {
window.URL.revokeObjectURL(url) window.URL.revokeObjectURL(url)
}, },
// Strukturierter PDF-Bericht (Profil v1, unabhängig vom Dashboard)
getReportsCatalog: () => req('/reports/catalog'),
getReportProfile: () => req('/reports/profile'),
putReportProfile: (profile) => req('/reports/profile', jput(profile)),
resetReportProfile: () => req('/reports/profile', { method: 'DELETE' }),
generateStructuredReportPdf: async () => {
const res = await fetch(`${BASE}/reports/generate-pdf`, { method: 'POST', headers: hdrs() })
if (!res.ok) {
let msg = `HTTP ${res.status}`
try {
const d = await res.json()
msg = formatFastApiDetail(d.detail, msg)
} catch {
const t = await res.text()
if (t) msg = t
}
throw new Error(msg)
}
const cd = res.headers.get('Content-Disposition') || ''
const m = /filename="([^"]+)"/.exec(cd)
const filename = m ? m[1] : `mitai-bericht-${new Date().toISOString().split('T')[0]}.pdf`
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
},
// Admin // Admin
adminListProfiles: () => req('/admin/profiles'), adminListProfiles: () => req('/admin/profiles'),
adminCreateProfile: (d) => req('/admin/profiles',json(d)), adminCreateProfile: (d) => req('/admin/profiles',json(d)),

View File

@ -0,0 +1,69 @@
export const DASHBOARD_PDF_CAPTURE_ROOT_ID = 'dashboard-pdf-capture-root'
/**
* @param {{ scale?: number, filenameBase?: string }} [opts]
*/
export async function exportDashboardToPdf(opts = {}) {
const scale = opts.scale ?? 2
const filenameBase = opts.filenameBase ?? 'mitai-uebersicht'
const [{ default: html2canvas }, { jsPDF }] = await Promise.all([
import('html2canvas'),
import('jspdf'),
])
const el = document.getElementById(DASHBOARD_PDF_CAPTURE_ROOT_ID)
if (!el) throw new Error('Dashboard-Inhalt nicht gefunden (interner Fehler).')
const prevScroll = window.scrollY
window.scrollTo(0, 0)
await new Promise((r) => requestAnimationFrame(r))
await new Promise((r) => requestAnimationFrame(r))
await new Promise((r) => setTimeout(r, 320))
try {
const canvas = await html2canvas(el, {
scale,
useCORS: true,
allowTaint: true,
logging: false,
backgroundColor: '#ffffff',
ignoreElements: (node) => node?.getAttribute?.('data-dashboard-pdf-exclude') === 'true',
scrollX: 0,
scrollY: 0,
width: el.scrollWidth,
height: el.scrollHeight,
windowWidth: el.scrollWidth,
windowHeight: el.scrollHeight,
})
const imgData = canvas.toDataURL('image/png', 1.0)
const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4', compress: true })
const pageW = pdf.internal.pageSize.getWidth()
const pageH = pdf.internal.pageSize.getHeight()
const margin = 8
const innerW = pageW - 2 * margin
const innerH = pageH - 2 * margin
const imgW = innerW
const imgH = (canvas.height * imgW) / canvas.width
let position = margin
pdf.addImage(imgData, 'PNG', margin, position, imgW, imgH, undefined, 'FAST')
let heightLeft = imgH - innerH
while (heightLeft > 0) {
position = margin - (imgH - heightLeft)
pdf.addPage()
pdf.addImage(imgData, 'PNG', margin, position, imgW, imgH, undefined, 'FAST')
heightLeft -= innerH
}
const safe = String(filenameBase)
.replace(/[\\/:*?"<>|]+/g, '')
.trim()
pdf.save(`${safe || 'bericht'}.pdf`)
} finally {
window.scrollTo(0, prevScroll)
}
}

View File

@ -0,0 +1,66 @@
import { normalizeReportExportConfig } from './reportExportConfig'
function buildStoredConfig(n) {
const out = {}
if (n.document_title) out.document_title = n.document_title
if (n.subtitle) out.subtitle = n.subtitle
if (n.capture_scale !== 2) out.capture_scale = n.capture_scale
return out
}
/**
* @param {{
* config: Record<string, unknown>,
* onChange: (next: Record<string, unknown>) => void
* }} props
*/
export default function ReportExportConfigEditor({ config, onChange }) {
const n = normalizeReportExportConfig(config)
const push = (partial) => {
const merged = normalizeReportExportConfig({ ...(config || {}), ...partial })
onChange(buildStoredConfig(merged))
}
return (
<div style={{ marginTop: 12, marginLeft: 28, maxWidth: 440 }}>
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 4 }}>
Dokumenttitel (optional, max. 120 Zeichen)
</label>
<input
type="text"
className="form-input"
maxLength={120}
placeholder="Leer = Profilname + „Übersicht“"
value={n.document_title}
onChange={(e) => push({ document_title: e.target.value })}
/>
<label
style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginTop: 10, marginBottom: 4 }}
>
Untertitel (optional, max. 240 Zeichen)
</label>
<input
type="text"
className="form-input"
maxLength={240}
placeholder="Leer = Datum/Uhrzeit"
value={n.subtitle}
onChange={(e) => push({ subtitle: e.target.value })}
/>
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginTop: 10, marginBottom: 4 }}>
PDF-Auflösung (1 = schneller/kleiner, 3 = schärfere Grafiken)
</label>
<select
className="form-input"
style={{ maxWidth: 120 }}
value={String(n.capture_scale)}
onChange={(e) => push({ capture_scale: Number(e.target.value) })}
>
<option value="1">1×</option>
<option value="2">2× (Standard)</option>
<option value="3">3×</option>
</select>
</div>
)
}

View File

@ -29,7 +29,9 @@ import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotos
import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget' import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget'
import GoalsFocusTeaserWidget from '../components/dashboard-widgets/GoalsFocusTeaserWidget' import GoalsFocusTeaserWidget from '../components/dashboard-widgets/GoalsFocusTeaserWidget'
import AiPipelineInsightWidget from '../components/dashboard-widgets/AiPipelineInsightWidget' import AiPipelineInsightWidget from '../components/dashboard-widgets/AiPipelineInsightWidget'
import ReportExportWidget from '../components/dashboard-widgets/ReportExportWidget'
import { normalizeBodyChartDays } from './bodyChartDays' import { normalizeBodyChartDays } from './bodyChartDays'
import { normalizeReportExportConfig } from './reportExportConfig'
import { registerDashboardWidget } from './dashboardWidgetRegistry' import { registerDashboardWidget } from './dashboardWidgetRegistry'
let _registered = false let _registered = false
@ -190,6 +192,13 @@ export function ensureDashboardWidgetsRegistered() {
Component: AiPipelineInsightWidget, Component: AiPipelineInsightWidget,
mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }), mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }),
}) })
registerDashboardWidget({
id: 'report_export',
Component: ReportExportWidget,
mapProps: (ctx) => ({
reportExportConfig: normalizeReportExportConfig(ctx.layoutEntry?.config),
}),
})
} }
/** @internal Nur für Tests */ /** @internal Nur für Tests */

View File

@ -0,0 +1,15 @@
/**
* @param {Record<string, unknown> | null | undefined} raw
* @returns {{ document_title: string, subtitle: string, capture_scale: number }}
*/
export function normalizeReportExportConfig(raw) {
const c = raw && typeof raw === 'object' ? raw : {}
let capture_scale = 2
if (c.capture_scale != null && c.capture_scale !== '') {
const n = Number(c.capture_scale)
if (Number.isFinite(n)) capture_scale = Math.min(3, Math.max(1, Math.round(n)))
}
const dt = typeof c.document_title === 'string' ? c.document_title.trim().slice(0, 120) : ''
const st = typeof c.subtitle === 'string' ? c.subtitle.trim().slice(0, 240) : ''
return { document_title: dt, subtitle: st, capture_scale }
}