From 62729d0648cd09b0c4bfbe7c6c4e3f5eb887d2a9 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 29 Apr 2026 11:28:04 +0200 Subject: [PATCH] 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. --- .../docs/technical/REPORT_PROFILES_AND_PDF.md | 56 +++ backend/dashboard_widget_config.py | 45 +++ backend/main.py | 2 + backend/migrations/060_report_profiles.sql | 11 + backend/report_chart_fetch.py | 139 ++++++++ backend/report_pdf_render.py | 212 ++++++++++++ backend/report_profile_schema.py | 92 +++++ backend/requirements.txt | 2 + backend/routers/reports.py | 156 +++++++++ backend/tests/test_report_profile_schema.py | 31 ++ backend/widget_catalog.py | 6 + frontend/package-lock.json | 6 +- frontend/package.json | 1 + .../dashboard-widgets/ReportExportWidget.jsx | 107 ++++++ frontend/src/pages/Dashboard.jsx | 4 +- frontend/src/pages/DashboardConfigurePage.jsx | 14 + frontend/src/pages/SettingsPage.jsx | 321 +++++++++++++++++- frontend/src/utils/api.js | 32 ++ frontend/src/utils/dashboardPdfExport.js | 69 ++++ .../widgetSystem/ReportExportConfigEditor.jsx | 66 ++++ .../widgetSystem/registerDashboardWidgets.js | 9 + .../src/widgetSystem/reportExportConfig.js | 15 + 22 files changed, 1389 insertions(+), 7 deletions(-) create mode 100644 .claude/docs/technical/REPORT_PROFILES_AND_PDF.md create mode 100644 backend/migrations/060_report_profiles.sql create mode 100644 backend/report_chart_fetch.py create mode 100644 backend/report_pdf_render.py create mode 100644 backend/report_profile_schema.py create mode 100644 backend/routers/reports.py create mode 100644 backend/tests/test_report_profile_schema.py create mode 100644 frontend/src/components/dashboard-widgets/ReportExportWidget.jsx create mode 100644 frontend/src/utils/dashboardPdfExport.js create mode 100644 frontend/src/widgetSystem/ReportExportConfigEditor.jsx create mode 100644 frontend/src/widgetSystem/reportExportConfig.js diff --git a/.claude/docs/technical/REPORT_PROFILES_AND_PDF.md b/.claude/docs/technical/REPORT_PROFILES_AND_PDF.md new file mode 100644 index 0000000..5f92df2 --- /dev/null +++ b/.claude/docs/technical/REPORT_PROFILES_AND_PDF.md @@ -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). diff --git a/backend/dashboard_widget_config.py b/backend/dashboard_widget_config.py index 9e8a4a0..71e2077 100644 --- a/backend/dashboard_widget_config.py +++ b/backend/dashboard_widget_config.py @@ -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 + + diff --git a/backend/main.py b/backend/main.py index c94ef5d..584ff80 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 diff --git a/backend/migrations/060_report_profiles.sql b/backend/migrations/060_report_profiles.sql new file mode 100644 index 0000000..b4f2106 --- /dev/null +++ b/backend/migrations/060_report_profiles.sql @@ -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'; diff --git a/backend/report_chart_fetch.py b/backend/report_chart_fetch.py new file mode 100644 index 0000000..c725b54 --- /dev/null +++ b/backend/report_chart_fetch.py @@ -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, + }, +] diff --git a/backend/report_pdf_render.py b/backend/report_pdf_render.py new file mode 100644 index 0000000..3dee6ca --- /dev/null +++ b/backend/report_pdf_render.py @@ -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"{block.chart_id}: {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() diff --git a/backend/report_profile_schema.py b/backend/report_profile_schema.py new file mode 100644 index 0000000..406d3ca --- /dev/null +++ b/backend/report_profile_schema.py @@ -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) + diff --git a/backend/requirements.txt b/backend/requirements.txt index 445b62d..d435df3 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/routers/reports.py b/backend/routers/reports.py new file mode 100644 index 0000000..99a8cfd --- /dev/null +++ b/backend/routers/reports.py @@ -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}"'}, + ) diff --git a/backend/tests/test_report_profile_schema.py b/backend/tests/test_report_profile_schema.py new file mode 100644 index 0000000..4411c76 --- /dev/null +++ b/backend/tests/test_report_profile_schema.py @@ -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) diff --git a/backend/widget_catalog.py b/backend/widget_catalog.py index 37849ba..5dc3986 100644 --- a/backend/widget_catalog.py +++ b/backend/widget_catalog.py @@ -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( diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6c0c00d..ecfb10e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" } diff --git a/frontend/package.json b/frontend/package.json index fd1d304..bb45e90 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/dashboard-widgets/ReportExportWidget.jsx b/frontend/src/components/dashboard-widgets/ReportExportWidget.jsx new file mode 100644 index 0000000..64f1814 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/ReportExportWidget.jsx @@ -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 ( +
+
+ + Übersicht als Bild-PDF +
+
+

+ {title} +

+

{subtitle}

+
+
+

+ Layout-Schnappschuss: Die sichtbare Übersicht wird im Browser gerastert (html2canvas). + Für einen datenbasierten Bericht unabhängig vom Dashboard nutze{' '} + Einstellungen → PDF-Bericht (strukturiert). +

+
+ {!canExport ? ( +

+ PDF-Export ist für dieses Profil nicht freigeschaltet. +

+ ) : ( + <> + {err && ( +

+ {err} +

+ )} + + + )} +
+
+
+ ) +} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 3a55779..384e3a1 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -123,7 +123,9 @@ export default function Dashboard() { )} {!layoutLoading && layoutForPreview && ( - +
+ +
)} ) diff --git a/frontend/src/pages/DashboardConfigurePage.jsx b/frontend/src/pages/DashboardConfigurePage.jsx index 51d2308..923cfdf 100644 --- a/frontend/src/pages/DashboardConfigurePage.jsx +++ b/frontend/src/pages/DashboardConfigurePage.jsx @@ -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' && ( + + setLayout((L) => + normalizeLayoutForEditor({ + ...L, + widgets: L.widgets.map((x, j) => (j !== i ? x : { ...x, config: { ...next } })), + }) + ) + } + /> + )} ) })} diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 27d0cfe..fc3269d 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -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() { + {/* Strukturierter PDF-Bericht (Profil v1) */} +
+
+ + PDF-Bericht (strukturiert) +
+

+ Eigenes Berichtsprofil: Reihenfolge, Überschriften und Diagramme — unabhängig von der + Startübersicht. Die PDF-Datei wird serverseitig aus denselben Datenquellen wie die + Chart-API erzeugt (kein Screenshot). Das unterscheidet sich vom optionalen Widget „Übersicht als + Bild-PDF“ auf der Startseite. +

+ {!canExport && ( +
+ 🔒 PDF-Bericht nutzt dasselbe Kontingent wie Datenexporte. Bitte Admin kontaktieren. +
+ )} + {reportNote && ( +
+ {reportNote.text} +
+ )} + {canExport && reportDraft && reportCatalog && ( + <> + + setReportDraft((d) => ({ ...d, document_title: e.target.value }))} + style={{ marginBottom: 14 }} + /> +
+ Blöcke {reportStored ? '' : '(Standard — noch nicht separat gespeichert)'} +
+
    + {reportDraft.blocks?.map((b, idx) => ( +
  • +
    + {b.type} + +
    + {b.type === 'section' && ( + + setReportDraft((d) => { + const blocks = d.blocks.map((x, j) => + j === idx ? { ...x, title: e.target.value } : x + ) + return { ...d, blocks } + }) + } + /> + )} + {b.type === 'chart' && ( +
    + +
    + + + setReportDraft((d) => { + const n = Number(e.target.value) + const blocks = d.blocks.map((x, j) => + j === idx ? { ...x, window_days: Number.isFinite(n) ? n : x.window_days } : x + ) + return { ...d, blocks } + }) + } + /> +
    +
    + )} + {b.type === 'ai_insight' && ( +
    + + setReportDraft((d) => { + const blocks = d.blocks.map((x, j) => + j === idx ? { ...x, title: e.target.value } : x + ) + return { ...d, blocks } + }) + } + /> + + setReportDraft((d) => { + const v = e.target.value.trim() || null + const blocks = d.blocks.map((x, j) => + j === idx ? { ...x, insight_id: v } : x + ) + return { ...d, blocks } + }) + } + /> +
    + )} +
  • + ))} +
+
+ +
+
+ + + +
+ {exportUsage && ( +
+ +
+ )} + + )} +
+ {/* Export */}
Daten exportieren
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index a6159f1..65521da 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -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)), diff --git a/frontend/src/utils/dashboardPdfExport.js b/frontend/src/utils/dashboardPdfExport.js new file mode 100644 index 0000000..8b5a6f4 --- /dev/null +++ b/frontend/src/utils/dashboardPdfExport.js @@ -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) + } +} diff --git a/frontend/src/widgetSystem/ReportExportConfigEditor.jsx b/frontend/src/widgetSystem/ReportExportConfigEditor.jsx new file mode 100644 index 0000000..e6cda6e --- /dev/null +++ b/frontend/src/widgetSystem/ReportExportConfigEditor.jsx @@ -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, + * onChange: (next: Record) => void + * }} props + */ +export default function ReportExportConfigEditor({ config, onChange }) { + const n = normalizeReportExportConfig(config) + + const push = (partial) => { + const merged = normalizeReportExportConfig({ ...(config || {}), ...partial }) + onChange(buildStoredConfig(merged)) + } + + return ( +
+ + push({ document_title: e.target.value })} + /> + + push({ subtitle: e.target.value })} + /> + + +
+ ) +} diff --git a/frontend/src/widgetSystem/registerDashboardWidgets.js b/frontend/src/widgetSystem/registerDashboardWidgets.js index 68d5204..e8d2a66 100644 --- a/frontend/src/widgetSystem/registerDashboardWidgets.js +++ b/frontend/src/widgetSystem/registerDashboardWidgets.js @@ -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 */ diff --git a/frontend/src/widgetSystem/reportExportConfig.js b/frontend/src/widgetSystem/reportExportConfig.js new file mode 100644 index 0000000..7f415e2 --- /dev/null +++ b/frontend/src/widgetSystem/reportExportConfig.js @@ -0,0 +1,15 @@ +/** + * @param {Record | 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 } +}