feat: add report_export widget and enhance report generation capabilities
- Introduced the `report_export` widget to the dashboard, allowing users to generate structured PDF reports. - Updated widget configuration to include `report_export` in the allowed widgets and added validation for its configuration. - Enhanced the widget catalog with details for the new `report_export` entry. - Implemented API endpoints for managing report profiles and generating PDFs. - Added frontend components for configuring and displaying report settings. - Updated tests to ensure proper validation and functionality of the new report generation features. - Bumped application version to reflect the addition of the new widget and related functionalities.
This commit is contained in:
parent
141df021c1
commit
62729d0648
56
.claude/docs/technical/REPORT_PROFILES_AND_PDF.md
Normal file
56
.claude/docs/technical/REPORT_PROFILES_AND_PDF.md
Normal 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` 7–365
|
||||
- `ai_insight`: optional `insight_id` (UUID, `ai_insights.id`), optional `title`
|
||||
|
||||
## Diagrammdaten
|
||||
|
||||
`report_chart_fetch.fetch_chart_payload` ruft dieselben Bausteine auf wie `/api/charts` (ohne HTTP). Erweiterung: Eintrag in `ALLOWED_CHART_IDS`, Fetcher in `_CHART_FETCHERS`, Zeile in `CHART_CATALOG_FOR_API`.
|
||||
|
||||
## PDF-Rendering
|
||||
|
||||
`report_pdf_render.build_structured_report_pdf`: ReportLab-Flowable-Kette, Diagramme als PNG aus Chart-Payload (Matplotlib, Agg-Backend).
|
||||
|
||||
## Frontend
|
||||
|
||||
- **Einstellungen:** Karte „PDF-Bericht (strukturiert)“ — Blöcke bearbeiten, speichern, Standard, PDF erzeugen.
|
||||
- **Dashboard:** Widget bleibt optionaler **Schnappschuss**; Hinweis verweist auf Einstellungen.
|
||||
|
||||
## Nächste sinnvolle Erweiterungen
|
||||
|
||||
- Dashboard-Layout → Berichtsprofil **einmalig importieren** (Mapping-Tabelle Widget-ID → chart_id).
|
||||
- KI: Insights-Auswahl in der UI statt manueller UUID.
|
||||
- Weitere `chart_id`-Werte / multipage Feintuning (Seitenumbrüche pro Block).
|
||||
|
|
@ -25,6 +25,7 @@ WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({
|
|||
"trend_kcal_weight",
|
||||
"nutrition_detail_charts",
|
||||
"recovery_charts_panel",
|
||||
"report_export",
|
||||
})
|
||||
|
||||
_QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({
|
||||
|
|
@ -201,6 +202,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
|
|||
return _validate_recovery_history_viz_config({})
|
||||
if widget_id == "history_overview_viz":
|
||||
return _validate_history_overview_viz_config({})
|
||||
if widget_id == "report_export":
|
||||
return _validate_report_export_config({})
|
||||
return {}
|
||||
|
||||
if widget_id == "body_overview":
|
||||
|
|
@ -227,6 +230,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
|
|||
return _validate_chart_days_only(raw, label="nutrition_detail_charts")
|
||||
if widget_id == "recovery_charts_panel":
|
||||
return _validate_chart_days_only(raw, label="recovery_charts_panel")
|
||||
if widget_id == "report_export":
|
||||
return _validate_report_export_config(raw)
|
||||
|
||||
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
||||
|
||||
|
|
@ -530,3 +535,43 @@ def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, A
|
|||
return {"chart_days": v}
|
||||
|
||||
|
||||
def _validate_report_export_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
label = "report_export"
|
||||
allowed = frozenset({"document_title", "subtitle", "capture_scale"})
|
||||
unknown = set(raw) - allowed
|
||||
if unknown:
|
||||
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
||||
out: dict[str, Any] = {"capture_scale": 2}
|
||||
if "document_title" in raw:
|
||||
t = raw["document_title"]
|
||||
if t is not None and not isinstance(t, str):
|
||||
raise ValueError(f"{label}: document_title muss Text sein")
|
||||
s = (t or "").strip()
|
||||
if len(s) > 120:
|
||||
raise ValueError(f"{label}: document_title max. 120 Zeichen")
|
||||
if s:
|
||||
out["document_title"] = s
|
||||
if "subtitle" in raw:
|
||||
t = raw["subtitle"]
|
||||
if t is not None and not isinstance(t, str):
|
||||
raise ValueError(f"{label}: subtitle muss Text sein")
|
||||
s = (t or "").strip()
|
||||
if len(s) > 240:
|
||||
raise ValueError(f"{label}: subtitle max. 240 Zeichen")
|
||||
if s:
|
||||
out["subtitle"] = s
|
||||
if "capture_scale" in raw:
|
||||
v = raw["capture_scale"]
|
||||
if isinstance(v, bool) or isinstance(v, float):
|
||||
if isinstance(v, float) and math.isfinite(v) and abs(v - round(v)) < 1e-9:
|
||||
v = int(round(v))
|
||||
else:
|
||||
raise ValueError(f"{label}: capture_scale muss ganze Zahl 1–3 sein")
|
||||
if not isinstance(v, int):
|
||||
raise ValueError(f"{label}: capture_scale muss ganze Zahl 1–3 sein")
|
||||
if v < 1 or v > 3:
|
||||
raise ValueError(f"{label}: capture_scale muss zwischen 1 und 3 liegen")
|
||||
out["capture_scale"] = v
|
||||
return out
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from routers import workflows # Phase 2 Workflow Engine - Execution
|
|||
from routers import reference_values # Persönliche Referenzwerte (Profil)
|
||||
from routers import admin_reference_value_types # Admin: Referenzwert-Typen
|
||||
from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Layout + Widget-Katalog
|
||||
from routers import reports # Strukturierter PDF-Bericht (Profil v1)
|
||||
from routers import csv_import, admin_csv_templates # Issue #21 Universal CSV Parser
|
||||
from routers import admin_training_parameters, admin_activity_attribute_profiles # EAV session metrics
|
||||
|
||||
|
|
@ -127,6 +128,7 @@ app.include_router(workflows.router) # /api/workflows/* (Phase 2 Exec
|
|||
app.include_router(reference_values.router) # /api/reference-value-types, /api/profile-reference-values
|
||||
app.include_router(admin_reference_value_types.router) # /api/admin/reference-value-types
|
||||
app.include_router(app_dashboard.router) # /api/app/dashboard-layout
|
||||
app.include_router(reports.router) # /api/reports/* (Berichtsprofil + PDF)
|
||||
app.include_router(csv_import.router) # /api/csv/* (Issue #21)
|
||||
app.include_router(admin_csv_templates.router) # /api/admin/csv-templates/* (Issue #21)
|
||||
app.include_router(admin_training_parameters.router) # /api/admin/training-parameters
|
||||
|
|
|
|||
11
backend/migrations/060_report_profiles.sql
Normal file
11
backend/migrations/060_report_profiles.sql
Normal 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';
|
||||
139
backend/report_chart_fetch.py
Normal file
139
backend/report_chart_fetch.py
Normal 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,
|
||||
},
|
||||
]
|
||||
212
backend/report_pdf_render.py
Normal file
212
backend/report_pdf_render.py
Normal 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()
|
||||
92
backend/report_profile_schema.py
Normal file
92
backend/report_profile_schema.py
Normal 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)
|
||||
|
||||
|
|
@ -10,3 +10,5 @@ slowapi==0.1.9
|
|||
psycopg2-binary==2.9.9
|
||||
python-dateutil==2.9.0
|
||||
tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows
|
||||
matplotlib==3.8.4
|
||||
reportlab==4.2.0
|
||||
|
|
|
|||
156
backend/routers/reports.py
Normal file
156
backend/routers/reports.py
Normal 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}"'},
|
||||
)
|
||||
31
backend/tests/test_report_profile_schema.py
Normal file
31
backend/tests/test_report_profile_schema.py
Normal 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)
|
||||
|
|
@ -149,6 +149,12 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
|||
"description": "Pipeline starten + Gesamt-Insight; Feature ai_pipeline",
|
||||
"requires_feature": "ai_pipeline",
|
||||
},
|
||||
{
|
||||
"id": "report_export",
|
||||
"title": "Übersicht als Bild-PDF",
|
||||
"description": "Raster-PDF der Startübersicht (html2canvas); für strukturierten Datenbericht siehe Einstellungen. Optional document_title, subtitle, capture_scale; data_export",
|
||||
"requires_feature": "data_export",
|
||||
},
|
||||
]
|
||||
|
||||
DEFAULT_LAB_WIDGET_IDS: frozenset[str] = frozenset(
|
||||
|
|
|
|||
6
frontend/package-lock.json
generated
6
frontend/package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
|||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.11",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^2.5.1",
|
||||
"jspdf-autotable": "^3.8.2",
|
||||
"lucide-react": "^0.383.0",
|
||||
|
|
@ -3147,7 +3148,6 @@
|
|||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
|
|
@ -3418,7 +3418,6 @@
|
|||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
|
|
@ -4454,7 +4453,6 @@
|
|||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
|
|
@ -6320,7 +6318,6 @@
|
|||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
|
|
@ -6591,7 +6588,6 @@
|
|||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.11",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^2.5.1",
|
||||
"jspdf-autotable": "^3.8.2",
|
||||
"lucide-react": "^0.383.0",
|
||||
|
|
|
|||
107
frontend/src/components/dashboard-widgets/ReportExportWidget.jsx
Normal file
107
frontend/src/components/dashboard-widgets/ReportExportWidget.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -123,7 +123,9 @@ export default function Dashboard() {
|
|||
)}
|
||||
|
||||
{!layoutLoading && layoutForPreview && (
|
||||
<div id="dashboard-pdf-capture-root">
|
||||
<WidgetRenderer layout={layoutForPreview} refreshTick={refreshTick} requestRefresh={requestRefresh} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryViz
|
|||
import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor'
|
||||
import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor'
|
||||
import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor'
|
||||
import ReportExportConfigEditor from '../widgetSystem/ReportExportConfigEditor'
|
||||
import {
|
||||
moveWidget,
|
||||
moveWidgetToIndex,
|
||||
|
|
@ -590,6 +591,19 @@ export default function DashboardConfigurePage({ adminMode = false } = {}) {
|
|||
}
|
||||
/>
|
||||
)}
|
||||
{w.id === 'report_export' && (
|
||||
<ReportExportConfigEditor
|
||||
config={w.config || {}}
|
||||
onChange={(next) =>
|
||||
setLayout((L) =>
|
||||
normalizeLayoutForEditor({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) => (j !== i ? x : { ...x, config: { ...next } })),
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target, LayoutDashboard } from 'lucide-react'
|
||||
import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target, LayoutDashboard, FileText, Trash2 } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useProfile } from '../context/ProfileContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
|
@ -23,6 +23,11 @@ export default function SettingsPage() {
|
|||
const [newPin, setNewPin] = useState('')
|
||||
const [pinMsg, setPinMsg] = useState(null)
|
||||
const [exportUsage, setExportUsage] = useState(null) // Phase 3: Usage badge
|
||||
const [reportCatalog, setReportCatalog] = useState(null)
|
||||
const [reportDraft, setReportDraft] = useState(null)
|
||||
const [reportStored, setReportStored] = useState(false)
|
||||
const [reportBusy, setReportBusy] = useState(false)
|
||||
const [reportNote, setReportNote] = useState(null)
|
||||
|
||||
// Load feature usage for export badges
|
||||
useEffect(() => {
|
||||
|
|
@ -32,6 +37,84 @@ export default function SettingsPage() {
|
|||
}).catch(err => console.error('Failed to load usage:', err))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeProfile?.id) return
|
||||
let cancel = false
|
||||
Promise.all([api.getReportsCatalog(), api.getReportProfile()])
|
||||
.then(([cat, bundle]) => {
|
||||
if (cancel) return
|
||||
setReportCatalog(cat)
|
||||
setReportDraft(JSON.parse(JSON.stringify(bundle.profile)))
|
||||
setReportStored(!!bundle.stored)
|
||||
setReportNote(null)
|
||||
})
|
||||
.catch((e) => console.error('report profile load', e))
|
||||
return () => {
|
||||
cancel = true
|
||||
}
|
||||
}, [activeProfile?.id])
|
||||
|
||||
const reportNewBlock = (kind) => {
|
||||
const charts = reportCatalog?.charts || []
|
||||
const first = charts[0]
|
||||
if (kind === 'section') return { type: 'section', title: 'Neue Überschrift' }
|
||||
if (kind === 'chart')
|
||||
return {
|
||||
type: 'chart',
|
||||
chart_id: first?.id || 'weight_trend',
|
||||
window_days: first?.default_window_days || 28,
|
||||
}
|
||||
return { type: 'ai_insight', title: '', insight_id: null }
|
||||
}
|
||||
|
||||
const handleSaveReportProfile = async () => {
|
||||
if (!reportDraft) return
|
||||
if (!reportDraft.blocks?.length) {
|
||||
setReportNote({ type: 'err', text: 'Mindestens ein Block erforderlich.' })
|
||||
return
|
||||
}
|
||||
setReportBusy(true)
|
||||
setReportNote(null)
|
||||
try {
|
||||
await api.putReportProfile(reportDraft)
|
||||
setReportStored(true)
|
||||
setReportNote({ type: 'ok', text: 'Berichtsprofil gespeichert.' })
|
||||
} catch (e) {
|
||||
setReportNote({ type: 'err', text: e.message })
|
||||
} finally {
|
||||
setReportBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetReportProfile = async () => {
|
||||
if (!confirm('Persönliches Berichtsprofil löschen und Standard wiederherstellen?')) return
|
||||
setReportBusy(true)
|
||||
setReportNote(null)
|
||||
try {
|
||||
const bundle = await api.resetReportProfile()
|
||||
setReportDraft(bundle.profile)
|
||||
setReportStored(false)
|
||||
setReportNote({ type: 'ok', text: 'Standard wiederhergestellt.' })
|
||||
} catch (e) {
|
||||
setReportNote({ type: 'err', text: e.message })
|
||||
} finally {
|
||||
setReportBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateStructuredPdf = async () => {
|
||||
setReportBusy(true)
|
||||
setReportNote(null)
|
||||
try {
|
||||
await api.generateStructuredReportPdf()
|
||||
setReportNote({ type: 'ok', text: 'PDF wurde heruntergeladen.' })
|
||||
} catch (e) {
|
||||
setReportNote({ type: 'err', text: e.message })
|
||||
} finally {
|
||||
setReportBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (!confirm('Ausloggen?')) return
|
||||
await logout()
|
||||
|
|
@ -496,6 +579,242 @@ export default function SettingsPage() {
|
|||
<FeatureUsageOverview />
|
||||
</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 */}
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Daten exportieren</div>
|
||||
|
|
|
|||
|
|
@ -266,6 +266,38 @@ export const api = {
|
|||
window.URL.revokeObjectURL(url)
|
||||
},
|
||||
|
||||
// Strukturierter PDF-Bericht (Profil v1, unabhängig vom Dashboard)
|
||||
getReportsCatalog: () => req('/reports/catalog'),
|
||||
getReportProfile: () => req('/reports/profile'),
|
||||
putReportProfile: (profile) => req('/reports/profile', jput(profile)),
|
||||
resetReportProfile: () => req('/reports/profile', { method: 'DELETE' }),
|
||||
generateStructuredReportPdf: async () => {
|
||||
const res = await fetch(`${BASE}/reports/generate-pdf`, { method: 'POST', headers: hdrs() })
|
||||
if (!res.ok) {
|
||||
let msg = `HTTP ${res.status}`
|
||||
try {
|
||||
const d = await res.json()
|
||||
msg = formatFastApiDetail(d.detail, msg)
|
||||
} catch {
|
||||
const t = await res.text()
|
||||
if (t) msg = t
|
||||
}
|
||||
throw new Error(msg)
|
||||
}
|
||||
const cd = res.headers.get('Content-Disposition') || ''
|
||||
const m = /filename="([^"]+)"/.exec(cd)
|
||||
const filename = m ? m[1] : `mitai-bericht-${new Date().toISOString().split('T')[0]}.pdf`
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
},
|
||||
|
||||
// Admin
|
||||
adminListProfiles: () => req('/admin/profiles'),
|
||||
adminCreateProfile: (d) => req('/admin/profiles',json(d)),
|
||||
|
|
|
|||
69
frontend/src/utils/dashboardPdfExport.js
Normal file
69
frontend/src/utils/dashboardPdfExport.js
Normal 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)
|
||||
}
|
||||
}
|
||||
66
frontend/src/widgetSystem/ReportExportConfigEditor.jsx
Normal file
66
frontend/src/widgetSystem/ReportExportConfigEditor.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -29,7 +29,9 @@ import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotos
|
|||
import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget'
|
||||
import GoalsFocusTeaserWidget from '../components/dashboard-widgets/GoalsFocusTeaserWidget'
|
||||
import AiPipelineInsightWidget from '../components/dashboard-widgets/AiPipelineInsightWidget'
|
||||
import ReportExportWidget from '../components/dashboard-widgets/ReportExportWidget'
|
||||
import { normalizeBodyChartDays } from './bodyChartDays'
|
||||
import { normalizeReportExportConfig } from './reportExportConfig'
|
||||
import { registerDashboardWidget } from './dashboardWidgetRegistry'
|
||||
|
||||
let _registered = false
|
||||
|
|
@ -190,6 +192,13 @@ export function ensureDashboardWidgetsRegistered() {
|
|||
Component: AiPipelineInsightWidget,
|
||||
mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }),
|
||||
})
|
||||
registerDashboardWidget({
|
||||
id: 'report_export',
|
||||
Component: ReportExportWidget,
|
||||
mapProps: (ctx) => ({
|
||||
reportExportConfig: normalizeReportExportConfig(ctx.layoutEntry?.config),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/** @internal Nur für Tests */
|
||||
|
|
|
|||
15
frontend/src/widgetSystem/reportExportConfig.js
Normal file
15
frontend/src/widgetSystem/reportExportConfig.js
Normal 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 }
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user