feat: enhance report management and PDF generation capabilities
- Introduced new API endpoints for managing report definitions, including listing, creating, and updating reports. - Updated the frontend to include a dedicated section for configuring reports, enhancing user navigation and experience. - Modified existing components to link to the new report settings, ensuring seamless access to report functionalities. - Improved the report catalog API to support multiple definitions per profile and added validation for report limits. - Updated documentation and tests to reflect the new features and ensure proper functionality.
This commit is contained in:
parent
3ab5dae130
commit
ed2b457da3
24
backend/migrations/061_report_definitions_multi.sql
Normal file
24
backend/migrations/061_report_definitions_multi.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
-- Migration 061: Mehrere benannte PDF-Berichte pro Nutzerprofil; Daten von report_profiles übernehmen.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS report_definitions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL DEFAULT 'Bericht',
|
||||||
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_report_definitions_profile_sort
|
||||||
|
ON report_definitions (profile_id, sort_order);
|
||||||
|
|
||||||
|
COMMENT ON TABLE report_definitions IS 'Mehrere strukturierte PDF-Berichte pro Profil (payload = ReportProfilePayload v1)';
|
||||||
|
|
||||||
|
INSERT INTO report_definitions (profile_id, name, payload, sort_order)
|
||||||
|
SELECT rp.profile_id, 'Standard', rp.payload, 0
|
||||||
|
FROM report_profiles rp
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM report_definitions rd WHERE rd.profile_id = rp.profile_id
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS report_profiles;
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
"""
|
"""
|
||||||
Strukturierter PDF-Bericht (Profil v1): GET/PUT Profil, Katalog, PDF-Erzeugung.
|
Strukturierter PDF-Bericht: mehrere Definitionen pro Profil, Katalog, PDF-Erzeugung.
|
||||||
|
|
||||||
Trennung vom Dashboard-Layout; Daten aus data_layer wie /api/charts.
|
|
||||||
PDF-Zähler: data_export (wie andere Exporte).
|
PDF-Zähler: data_export (wie andere Exporte).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from psycopg2.extras import Json
|
from psycopg2.extras import Json
|
||||||
|
|
||||||
from auth import check_feature_access, increment_feature_usage, require_auth
|
from auth import check_feature_access, increment_feature_usage, require_auth
|
||||||
|
|
@ -28,39 +29,20 @@ from report_profile_schema import (
|
||||||
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
router = APIRouter(prefix="/api/reports", tags=["reports"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_MAX_REPORT_DEFINITIONS = 20
|
||||||
@router.get("/catalog")
|
|
||||||
def get_reports_catalog(session: dict = Depends(require_auth)):
|
|
||||||
"""Metadaten für UI: verfügbare Diagramme und Blocktypen."""
|
|
||||||
viz_titles = {
|
|
||||||
"body_history_viz": "Körper (Verlauf-Bundle)",
|
|
||||||
"nutrition_history_viz": "Ernährung (Verlauf-Bundle)",
|
|
||||||
"fitness_history_viz": "Fitness (Verlauf-Bundle)",
|
|
||||||
"recovery_history_viz": "Erholung (Verlauf-Bundle)",
|
|
||||||
"history_overview_viz": "Gesamtübersicht (Korrelationen)",
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"catalog_version": 2,
|
|
||||||
"charts": CHART_CATALOG_FOR_API,
|
|
||||||
"viz_bundles": [{"id": bid, "title": viz_titles.get(bid, bid)} for bid in sorted(ALLOWED_VIZ_BUNDLE_IDS)],
|
|
||||||
"block_types": [
|
|
||||||
{"id": "section", "title": "Überschrift"},
|
|
||||||
{"id": "viz_bundle", "title": "Verlauf-Bundle (KPIs & Charts)"},
|
|
||||||
{"id": "chart", "title": "Einzel-Diagramm (Legacy)"},
|
|
||||||
{"id": "ai_insight", "title": "KI-Auswertung"},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_payload_row(profile_id: str) -> dict | None:
|
class CreateReportDefinitionBody(BaseModel):
|
||||||
with get_db() as conn:
|
name: str = Field(default="Neuer Bericht", min_length=1, max_length=120)
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute("SELECT payload FROM report_profiles WHERE profile_id = %s", (profile_id,))
|
|
||||||
row = cur.fetchone()
|
class UpdateReportDefinitionBody(BaseModel):
|
||||||
if not row:
|
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||||
return None
|
payload: dict | None = None
|
||||||
p = row.get("payload")
|
|
||||||
return p if isinstance(p, dict) else None
|
|
||||||
|
class GeneratePdfRequest(BaseModel):
|
||||||
|
definition_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
def _profile_display_name(profile_id: str) -> str:
|
def _profile_display_name(profile_id: str) -> str:
|
||||||
|
|
@ -73,58 +55,231 @@ def _profile_display_name(profile_id: str) -> str:
|
||||||
return (row.get("name") or "Profil").strip() or "Profil"
|
return (row.get("name") or "Profil").strip() or "Profil"
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profile")
|
def _row_to_definition(row: dict) -> dict:
|
||||||
def get_report_profile(session: dict = Depends(require_auth)):
|
pl = row.get("payload")
|
||||||
pid = session["profile_id"]
|
if not isinstance(pl, dict):
|
||||||
raw = _fetch_payload_row(pid)
|
pl = {}
|
||||||
if raw is None:
|
return {
|
||||||
return {"stored": False, "profile": default_report_profile_dict()}
|
"id": str(row["id"]),
|
||||||
try:
|
"name": (row.get("name") or "Bericht").strip() or "Bericht",
|
||||||
parse_report_profile(raw)
|
"sort_order": int(row.get("sort_order") or 0),
|
||||||
except Exception as e:
|
"updated_at": row.get("updated_at").isoformat() if row.get("updated_at") else None,
|
||||||
logger.warning("report profile invalid for %s: %s", pid, e)
|
"payload": pl,
|
||||||
return {"stored": False, "profile": default_report_profile_dict(), "previous_invalid": True}
|
}
|
||||||
return {"stored": True, "profile": raw}
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/profile")
|
@router.get("/catalog")
|
||||||
def put_report_profile(body: dict, session: dict = Depends(require_auth)):
|
def get_reports_catalog(session: dict = Depends(require_auth)):
|
||||||
|
"""Metadaten für UI: verfügbare Diagramme, Bundles, Zeitraumgrenzen."""
|
||||||
|
viz_titles = {
|
||||||
|
"body_history_viz": "Körper (Verlauf-Bundle)",
|
||||||
|
"nutrition_history_viz": "Ernährung (Verlauf-Bundle)",
|
||||||
|
"fitness_history_viz": "Fitness (Verlauf-Bundle)",
|
||||||
|
"recovery_history_viz": "Erholung (Verlauf-Bundle)",
|
||||||
|
"history_overview_viz": "Gesamtübersicht (Korrelationen)",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"catalog_version": 3,
|
||||||
|
"chart_days": {"min": 7, "max": 90},
|
||||||
|
"charts": CHART_CATALOG_FOR_API,
|
||||||
|
"viz_bundles": [{"id": bid, "title": viz_titles.get(bid, bid)} for bid in sorted(ALLOWED_VIZ_BUNDLE_IDS)],
|
||||||
|
"block_types": [
|
||||||
|
{"id": "section", "title": "Überschrift"},
|
||||||
|
{"id": "viz_bundle", "title": "Verlauf-Bundle (KPIs & Charts)"},
|
||||||
|
{"id": "chart", "title": "Einzel-Diagramm (Legacy)"},
|
||||||
|
{"id": "ai_insight", "title": "KI-Auswertung"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/definitions")
|
||||||
|
def list_report_definitions(session: dict = Depends(require_auth)):
|
||||||
pid = session["profile_id"]
|
pid = session["profile_id"]
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name, sort_order, updated_at, payload
|
||||||
|
FROM report_definitions
|
||||||
|
WHERE profile_id = %s
|
||||||
|
ORDER BY sort_order ASC, name ASC, updated_at DESC
|
||||||
|
""",
|
||||||
|
(pid,),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return {"definitions": [_row_to_definition(dict(r)) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/definitions")
|
||||||
|
def create_report_definition(
|
||||||
|
body: CreateReportDefinitionBody | None = Body(default=None),
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
req = body or CreateReportDefinitionBody()
|
||||||
|
pid = session["profile_id"]
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) AS n FROM report_definitions WHERE profile_id = %s",
|
||||||
|
(pid,),
|
||||||
|
)
|
||||||
|
n = int((cur.fetchone() or {}).get("n") or 0)
|
||||||
|
if n >= _MAX_REPORT_DEFINITIONS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Maximal {_MAX_REPORT_DEFINITIONS} Berichte erlaubt.",
|
||||||
|
)
|
||||||
|
|
||||||
|
payload_dict = default_report_profile_dict()
|
||||||
|
payload_dict["document_title"] = ""
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_so FROM report_definitions WHERE profile_id = %s",
|
||||||
|
(pid,),
|
||||||
|
)
|
||||||
|
next_so = int((cur.fetchone() or {}).get("next_so") or 0)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO report_definitions (profile_id, name, payload, sort_order, updated_at)
|
||||||
|
VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||||
|
RETURNING id, name, sort_order, updated_at, payload
|
||||||
|
""",
|
||||||
|
(pid, req.name.strip(), Json(payload_dict), next_so),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=500, detail="Bericht konnte nicht angelegt werden.")
|
||||||
|
return {"definition": _row_to_definition(dict(row))}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/definitions/{definition_id}")
|
||||||
|
def update_report_definition(
|
||||||
|
definition_id: UUID,
|
||||||
|
body: UpdateReportDefinitionBody,
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
pid = session["profile_id"]
|
||||||
|
name = body.name.strip() if body.name else None
|
||||||
|
parsed_payload: ReportProfilePayload | None = None
|
||||||
|
if body.payload is not None:
|
||||||
try:
|
try:
|
||||||
parsed = ReportProfilePayload.model_validate(body)
|
parsed_payload = ReportProfilePayload.model_validate(body.payload)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
if parsed_payload is not None and name is not None:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO report_profiles (profile_id, payload, updated_at)
|
UPDATE report_definitions
|
||||||
VALUES (%s, %s, CURRENT_TIMESTAMP)
|
SET name = %s, payload = %s, updated_at = CURRENT_TIMESTAMP
|
||||||
ON CONFLICT (profile_id) DO UPDATE SET
|
WHERE id = %s AND profile_id = %s
|
||||||
payload = EXCLUDED.payload,
|
RETURNING id, name, sort_order, updated_at, payload
|
||||||
updated_at = CURRENT_TIMESTAMP
|
|
||||||
""",
|
""",
|
||||||
(pid, Json(parsed.to_stored_dict())),
|
(name, Json(parsed_payload.to_stored_dict()), str(definition_id), pid),
|
||||||
)
|
)
|
||||||
|
elif parsed_payload is not None:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE report_definitions
|
||||||
|
SET payload = %s, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s AND profile_id = %s
|
||||||
|
RETURNING id, name, sort_order, updated_at, payload
|
||||||
|
""",
|
||||||
|
(Json(parsed_payload.to_stored_dict()), str(definition_id), pid),
|
||||||
|
)
|
||||||
|
elif name is not None:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE report_definitions
|
||||||
|
SET name = %s, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = %s AND profile_id = %s
|
||||||
|
RETURNING id, name, sort_order, updated_at, payload
|
||||||
|
""",
|
||||||
|
(name, str(definition_id), pid),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="Nichts zu aktualisieren (name oder payload fehlt).")
|
||||||
|
|
||||||
|
row = cur.fetchone()
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"ok": True, "profile": parsed.to_stored_dict()}
|
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Bericht nicht gefunden.")
|
||||||
|
return {"definition": _row_to_definition(dict(row))}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/profile")
|
@router.delete("/definitions/{definition_id}")
|
||||||
def delete_report_profile(session: dict = Depends(require_auth)):
|
def delete_report_definition(definition_id: UUID, session: dict = Depends(require_auth)):
|
||||||
"""Zurück auf Code-Standard (kein DB-Eintrag)."""
|
|
||||||
pid = session["profile_id"]
|
pid = session["profile_id"]
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute("DELETE FROM report_profiles WHERE profile_id = %s", (pid,))
|
cur.execute(
|
||||||
|
"DELETE FROM report_definitions WHERE id = %s AND profile_id = %s RETURNING id",
|
||||||
|
(str(definition_id), pid),
|
||||||
|
)
|
||||||
|
deleted = cur.fetchone()
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"ok": True, "profile": default_report_profile_dict()}
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Bericht nicht gefunden.")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_definition_payload(profile_id: str, definition_id: UUID | None) -> tuple[dict, str]:
|
||||||
|
"""Returns (raw_payload_dict, report_label_for_filename)."""
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
if definition_id is not None:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT payload, name FROM report_definitions
|
||||||
|
WHERE id = %s AND profile_id = %s
|
||||||
|
""",
|
||||||
|
(str(definition_id), profile_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Bericht nicht gefunden.")
|
||||||
|
pl = row.get("payload")
|
||||||
|
label = (row.get("name") or "Bericht").strip() or "Bericht"
|
||||||
|
if not isinstance(pl, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="Ungültige Berichtsdaten.")
|
||||||
|
return pl, label
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT payload, name FROM report_definitions
|
||||||
|
WHERE profile_id = %s
|
||||||
|
ORDER BY sort_order ASC, name ASC, updated_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(profile_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Kein Bericht angelegt — bitte unter Einstellungen › PDF-Berichte einen Bericht erstellen.",
|
||||||
|
)
|
||||||
|
pl = row.get("payload")
|
||||||
|
label = (row.get("name") or "Bericht").strip() or "Bericht"
|
||||||
|
if not isinstance(pl, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="Ungültige Berichtsdaten.")
|
||||||
|
return pl, label
|
||||||
|
|
||||||
|
|
||||||
@router.post("/generate-pdf")
|
@router.post("/generate-pdf")
|
||||||
def generate_structured_report_pdf(session: dict = Depends(require_auth)):
|
def generate_structured_report_pdf(
|
||||||
|
body: GeneratePdfRequest | None = Body(default=None),
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
pid = session["profile_id"]
|
pid = session["profile_id"]
|
||||||
|
req = body or GeneratePdfRequest()
|
||||||
|
|
||||||
access = check_feature_access(pid, "data_export")
|
access = check_feature_access(pid, "data_export")
|
||||||
log_feature_usage(pid, "data_export", access, "report_generate_pdf")
|
log_feature_usage(pid, "data_export", access, "report_generate_pdf")
|
||||||
|
|
@ -143,21 +298,24 @@ def generate_structured_report_pdf(session: dict = Depends(require_auth)):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
raw = _fetch_payload_row(pid)
|
raw, report_label = _fetch_definition_payload(pid, req.definition_id)
|
||||||
try:
|
try:
|
||||||
payload = parse_report_profile(raw)
|
payload = parse_report_profile(raw)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=f"Berichtsprofil ungültig: {e}")
|
raise HTTPException(status_code=400, detail=f"Berichtsprofil ungültig: {e}")
|
||||||
|
|
||||||
name = _profile_display_name(pid)
|
profile_name = _profile_display_name(pid)
|
||||||
try:
|
try:
|
||||||
pdf_bytes = build_structured_report_pdf(profile_id=pid, profile_name=name, payload=payload)
|
pdf_bytes = build_structured_report_pdf(profile_id=pid, profile_name=profile_name, payload=payload)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("report pdf build failed")
|
logger.exception("report pdf build failed")
|
||||||
raise HTTPException(status_code=500, detail=f"PDF-Erzeugung fehlgeschlagen: {e}")
|
raise HTTPException(status_code=500, detail=f"PDF-Erzeugung fehlgeschlagen: {e}")
|
||||||
|
|
||||||
increment_feature_usage(pid, "data_export")
|
increment_feature_usage(pid, "data_export")
|
||||||
safe_name = "".join(c for c in name if c.isalnum() or c in (" ", "-", "_")).strip() or "profil"
|
|
||||||
|
doc_title = (payload.document_title or "").strip()
|
||||||
|
base_label = doc_title or report_label
|
||||||
|
safe_name = "".join(c for c in base_label if c.isalnum() or c in (" ", "-", "_")).strip() or "bericht"
|
||||||
fn = f"mitai-bericht-{safe_name.replace(' ', '-')}-{datetime.now().strftime('%Y-%m-%d')}.pdf"
|
fn = f"mitai-bericht-{safe_name.replace(' ', '-')}-{datetime.now().strftime('%Y-%m-%d')}.pdf"
|
||||||
return Response(
|
return Response(
|
||||||
content=pdf_bytes,
|
content=pdf_bytes,
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import Analysis from './pages/Analysis'
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
import SettingsShell from './layouts/SettingsShell'
|
import SettingsShell from './layouts/SettingsShell'
|
||||||
import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage'
|
import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage'
|
||||||
import DashboardConfigurePage from './pages/DashboardConfigurePage'
|
import ReportConfigurePage from './pages/ReportConfigurePage'
|
||||||
import GuidePage from './pages/GuidePage'
|
import GuidePage from './pages/GuidePage'
|
||||||
import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
|
import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
|
||||||
import AdminFeaturesPage from './pages/AdminFeaturesPage'
|
import AdminFeaturesPage from './pages/AdminFeaturesPage'
|
||||||
|
|
@ -241,6 +241,7 @@ function AppShell() {
|
||||||
<Route index element={<SettingsPage />} />
|
<Route index element={<SettingsPage />} />
|
||||||
<Route path="reference-values" element={<ProfileReferenceValuesPage />} />
|
<Route path="reference-values" element={<ProfileReferenceValuesPage />} />
|
||||||
<Route path="dashboard-layout" element={<DashboardConfigurePage />} />
|
<Route path="dashboard-layout" element={<DashboardConfigurePage />} />
|
||||||
|
<Route path="reports" element={<ReportConfigurePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<RequireAdmin />}>
|
<Route element={<RequireAdmin />}>
|
||||||
<Route path="admin" element={<AdminShell />}>
|
<Route path="admin" element={<AdminShell />}>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from 'react'
|
import { Link } from 'react-router-dom'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { FileDown } from 'lucide-react'
|
import { FileDown } from 'lucide-react'
|
||||||
import { useAuth } from '../../context/AuthContext'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
|
@ -64,8 +64,11 @@ export default function ReportExportWidget({ reportExportConfig }) {
|
||||||
<div data-dashboard-pdf-exclude="true">
|
<div data-dashboard-pdf-exclude="true">
|
||||||
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 14, lineHeight: 1.55 }}>
|
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 14, lineHeight: 1.55 }}>
|
||||||
<strong>Layout-Schnappschuss:</strong> Die sichtbare Übersicht wird im Browser gerastert (html2canvas).
|
<strong>Layout-Schnappschuss:</strong> Die sichtbare Übersicht wird im Browser gerastert (html2canvas).
|
||||||
Für einen <strong>datenbasierten Bericht</strong> unabhängig vom Dashboard nutze{' '}
|
Für einen <strong>datenbasierten Bericht</strong> unabhängig vom Dashboard öffne{' '}
|
||||||
<strong>Einstellungen → PDF-Bericht (strukturiert)</strong>.
|
<Link to="/settings/reports" style={{ color: 'var(--accent)', fontWeight: 600 }}>
|
||||||
|
Einstellungen → PDF-Berichte
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
</p>
|
</p>
|
||||||
<div style={{ marginTop: 14 }}>
|
<div style={{ marginTop: 14 }}>
|
||||||
{!canExport ? (
|
{!canExport ? (
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,6 @@
|
||||||
export const SETTINGS_SHELL_NAV_ITEMS = [
|
export const SETTINGS_SHELL_NAV_ITEMS = [
|
||||||
{ id: 'general', label: 'Allgemein', to: '/settings', end: true },
|
{ id: 'general', label: 'Allgemein', to: '/settings', end: true },
|
||||||
{ id: 'dashboard-layout', label: 'Übersicht', to: '/settings/dashboard-layout' },
|
{ id: 'dashboard-layout', label: 'Übersicht', to: '/settings/dashboard-layout' },
|
||||||
|
{ id: 'reports', label: 'PDF-Berichte', to: '/settings/reports' },
|
||||||
{ id: 'reference-values', label: 'Referenzwerte', to: '/settings/reference-values' },
|
{ id: 'reference-values', label: 'Referenzwerte', to: '/settings/reference-values' },
|
||||||
]
|
]
|
||||||
|
|
|
||||||
629
frontend/src/pages/ReportConfigurePage.jsx
Normal file
629
frontend/src/pages/ReportConfigurePage.jsx
Normal file
|
|
@ -0,0 +1,629 @@
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { Download, FileText, Plus, Save, Trash2 } from 'lucide-react'
|
||||||
|
import { api, formatFastApiDetail } from '../utils/api'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { useProfile } from '../context/ProfileContext'
|
||||||
|
import UsageBadge from '../components/UsageBadge'
|
||||||
|
import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor'
|
||||||
|
import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor'
|
||||||
|
import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor'
|
||||||
|
import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor'
|
||||||
|
import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor'
|
||||||
|
import {
|
||||||
|
BODY_CHART_DAYS_DEFAULT,
|
||||||
|
BODY_CHART_DAYS_MAX,
|
||||||
|
BODY_CHART_DAYS_MIN,
|
||||||
|
normalizeBodyChartDays,
|
||||||
|
} from '../widgetSystem/bodyChartDays'
|
||||||
|
|
||||||
|
const VIZ_BUNDLES_FALLBACK = [
|
||||||
|
{ id: 'body_history_viz', title: 'Körper (Verlauf-Bundle)' },
|
||||||
|
{ id: 'nutrition_history_viz', title: 'Ernährung (Verlauf-Bundle)' },
|
||||||
|
{ id: 'fitness_history_viz', title: 'Fitness (Verlauf-Bundle)' },
|
||||||
|
{ id: 'recovery_history_viz', title: 'Erholung (Verlauf-Bundle)' },
|
||||||
|
{ id: 'history_overview_viz', title: 'Gesamtübersicht (Korrelationen)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function ReportConfigurePage() {
|
||||||
|
const { canExport } = useAuth()
|
||||||
|
const { activeProfile } = useProfile()
|
||||||
|
const [exportUsage, setExportUsage] = useState(null)
|
||||||
|
const [catalog, setCatalog] = useState(null)
|
||||||
|
const [definitions, setDefinitions] = useState([])
|
||||||
|
const [selectedId, setSelectedId] = useState(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState(null)
|
||||||
|
const [err, setErr] = useState(null)
|
||||||
|
|
||||||
|
const chartDaysMin = catalog?.chart_days?.min ?? BODY_CHART_DAYS_MIN
|
||||||
|
const chartDaysMax = catalog?.chart_days?.max ?? BODY_CHART_DAYS_MAX
|
||||||
|
const vizBundles = catalog?.viz_bundles?.length ? catalog.viz_bundles : VIZ_BUNDLES_FALLBACK
|
||||||
|
|
||||||
|
const selected = definitions.find((d) => d.id === selectedId) || null
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setErr(null)
|
||||||
|
try {
|
||||||
|
const [cat, bundle] = await Promise.all([api.getReportsCatalog(), api.listReportDefinitions()])
|
||||||
|
setCatalog(cat)
|
||||||
|
setDefinitions(bundle.definitions || [])
|
||||||
|
} catch (e) {
|
||||||
|
setErr(formatFastApiDetail(null, e.message))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getFeatureUsage().then((features) => {
|
||||||
|
const exportFeature = features.find((f) => f.feature_id === 'data_export')
|
||||||
|
setExportUsage(exportFeature)
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeProfile?.id) return
|
||||||
|
load()
|
||||||
|
}, [activeProfile?.id, load])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!definitions.length) {
|
||||||
|
setSelectedId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!selectedId || !definitions.some((d) => d.id === selectedId)) {
|
||||||
|
setSelectedId(definitions[0].id)
|
||||||
|
}
|
||||||
|
}, [definitions, selectedId])
|
||||||
|
|
||||||
|
const patchSelectedPayload = useCallback((fn) => {
|
||||||
|
setDefinitions((defs) =>
|
||||||
|
defs.map((d) => {
|
||||||
|
if (d.id !== selectedId) return d
|
||||||
|
const nextPayload = fn(d.payload || { version: 1, document_title: '', blocks: [] })
|
||||||
|
return { ...d, payload: nextPayload }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, [selectedId])
|
||||||
|
|
||||||
|
const reportNewBlock = (kind) => {
|
||||||
|
const charts = catalog?.charts || []
|
||||||
|
const first = charts[0]
|
||||||
|
const firstBundleId = vizBundles[0]?.id || 'body_history_viz'
|
||||||
|
if (kind === 'section') return { type: 'section', title: 'Neue Überschrift' }
|
||||||
|
if (kind === 'viz_bundle') return { type: 'viz_bundle', bundle_id: firstBundleId, config: {} }
|
||||||
|
if (kind === 'chart')
|
||||||
|
return {
|
||||||
|
type: 'chart',
|
||||||
|
chart_id: first?.id || 'weight_trend',
|
||||||
|
window_days: first?.default_window_days || 28,
|
||||||
|
}
|
||||||
|
return { type: 'ai_insight', title: '', insight_id: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateDefinition = async () => {
|
||||||
|
setBusy(true)
|
||||||
|
setErr(null)
|
||||||
|
setMsg(null)
|
||||||
|
try {
|
||||||
|
const r = await api.createReportDefinition({
|
||||||
|
name: `Bericht ${definitions.length + 1}`,
|
||||||
|
})
|
||||||
|
const d = r.definition
|
||||||
|
setDefinitions((x) => [...x, d])
|
||||||
|
setSelectedId(d.id)
|
||||||
|
setMsg('Neuer Bericht angelegt. Inhalt speichern nicht vergessen, wenn du Änderungen machst.')
|
||||||
|
} catch (e) {
|
||||||
|
setErr(formatFastApiDetail(null, e.message))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!selected) return
|
||||||
|
if (!selected.payload?.blocks?.length) {
|
||||||
|
setErr('Mindestens ein Block erforderlich.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setErr(null)
|
||||||
|
setMsg(null)
|
||||||
|
try {
|
||||||
|
await api.updateReportDefinition(selected.id, {
|
||||||
|
name: selected.name?.trim() || 'Bericht',
|
||||||
|
payload: selected.payload,
|
||||||
|
})
|
||||||
|
setMsg('Gespeichert.')
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
setErr(formatFastApiDetail(null, e.message))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteDefinition = async () => {
|
||||||
|
if (!selected) return
|
||||||
|
if (!confirm(`Bericht „${selected.name}“ wirklich löschen?`)) return
|
||||||
|
setBusy(true)
|
||||||
|
setErr(null)
|
||||||
|
setMsg(null)
|
||||||
|
try {
|
||||||
|
await api.deleteReportDefinition(selected.id)
|
||||||
|
setDefinitions((defs) => defs.filter((d) => d.id !== selected.id))
|
||||||
|
setSelectedId(null)
|
||||||
|
setMsg('Bericht gelöscht.')
|
||||||
|
} catch (e) {
|
||||||
|
setErr(formatFastApiDetail(null, e.message))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGeneratePdf = async () => {
|
||||||
|
if (!selected) return
|
||||||
|
setBusy(true)
|
||||||
|
setErr(null)
|
||||||
|
setMsg(null)
|
||||||
|
try {
|
||||||
|
await api.generateStructuredReportPdf(selected.id)
|
||||||
|
setMsg('PDF wurde heruntergeladen.')
|
||||||
|
} catch (e) {
|
||||||
|
setErr(formatFastApiDetail(null, e.message))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSelectedName = (name) => {
|
||||||
|
setDefinitions((defs) => defs.map((d) => (d.id === selectedId ? { ...d, name } : d)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const vizBundleChartDays = (config) =>
|
||||||
|
normalizeBodyChartDays(config?.chart_days ?? BODY_CHART_DAYS_DEFAULT)
|
||||||
|
|
||||||
|
if (!catalog) {
|
||||||
|
return (
|
||||||
|
<div className="card section-gap">
|
||||||
|
<p style={{ color: 'var(--text2)' }}>Lade Katalog…</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<FileText size={18} color="var(--accent)" />
|
||||||
|
PDF-Berichte
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.65 }}>
|
||||||
|
Hier legst du <strong>einen oder mehrere strukturierte PDF-Berichte</strong> an. Pro Block vom Typ
|
||||||
|
„Verlauf-Bundle“ gilt ein <strong>Zeitraum in Tagen</strong> (wie bei der Übersicht).{' '}
|
||||||
|
<strong>Technisch:</strong> Es sind dieselben{' '}
|
||||||
|
<strong>Daten-Bundles und dieselbe Konfiguration</strong> wie bei den Verlauf-Widgets — im PDF werden
|
||||||
|
sie serverseitig gerendert (nicht die React-Komponenten der Startseite).
|
||||||
|
</p>
|
||||||
|
{!canExport && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
background: '#FCEBEB',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#D85A30',
|
||||||
|
marginBottom: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
PDF ist mit dem Kontingent „Datenexport“ verknüpft. Bitte Admin kontaktieren.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{err && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
marginBottom: 12,
|
||||||
|
background: '#FCEBEB',
|
||||||
|
color: '#D85A30',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{err}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msg && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
marginBottom: 12,
|
||||||
|
background: '#E1F5EE',
|
||||||
|
color: 'var(--accent)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{msg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', marginBottom: 14 }}>
|
||||||
|
{definitions.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d.id}
|
||||||
|
type="button"
|
||||||
|
className={d.id === selectedId ? 'btn btn-primary' : 'btn btn-secondary'}
|
||||||
|
onClick={() => setSelectedId(d.id)}
|
||||||
|
>
|
||||||
|
{d.name || 'Bericht'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={busy || !canExport || definitions.length >= 20}
|
||||||
|
onClick={handleCreateDefinition}
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
Neuer Bericht
|
||||||
|
</button>
|
||||||
|
<Link to="/settings" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
|
||||||
|
← Allgemein
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canExport && !definitions.length && (
|
||||||
|
<p style={{ fontSize: 14, marginBottom: 12 }}>
|
||||||
|
Noch kein Bericht vorhanden. Lege mit „Neuer Bericht“ einen Standard an.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canExport && selected && (
|
||||||
|
<>
|
||||||
|
<label className="form-label" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||||
|
Name des Berichts (intern)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
maxLength={120}
|
||||||
|
value={selected.name || ''}
|
||||||
|
onChange={(e) => setSelectedName(e.target.value)}
|
||||||
|
style={{ marginBottom: 14, maxWidth: 420 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="form-label" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||||
|
Dokumenttitel im PDF (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
maxLength={120}
|
||||||
|
placeholder="Leer = Profilname + „Bericht“"
|
||||||
|
value={selected.payload?.document_title || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchSelectedPayload((p) => ({
|
||||||
|
...p,
|
||||||
|
document_title: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
style={{ marginBottom: 14 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text2)', marginBottom: 8 }}>Blöcke</div>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 12px' }}>
|
||||||
|
{(selected.payload?.blocks || []).map((b, idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 10,
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text3)', textTransform: 'uppercase' }}>{b.type}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '4px 8px' }}
|
||||||
|
aria-label="Block entfernen"
|
||||||
|
onClick={() =>
|
||||||
|
patchSelectedPayload((p) => ({
|
||||||
|
...p,
|
||||||
|
blocks: (p.blocks || []).filter((_, j) => j !== idx),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{b.type === 'section' && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
value={b.title || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchSelectedPayload((p) => ({
|
||||||
|
...p,
|
||||||
|
blocks: (p.blocks || []).map((x, j) =>
|
||||||
|
j === idx ? { ...x, title: e.target.value } : x,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{b.type === 'chart' && (
|
||||||
|
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={b.chart_id}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchSelectedPayload((p) => ({
|
||||||
|
...p,
|
||||||
|
blocks: (p.blocks || []).map((x, j) =>
|
||||||
|
j === idx ? { ...x, chart_id: e.target.value } : x,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{catalog.charts?.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 11, color: 'var(--text3)' }}>Zeitraum (Tage)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
min={7}
|
||||||
|
max={365}
|
||||||
|
value={b.window_days}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = Number(e.target.value)
|
||||||
|
patchSelectedPayload((p) => ({
|
||||||
|
...p,
|
||||||
|
blocks: (p.blocks || []).map((x, j) =>
|
||||||
|
j === idx
|
||||||
|
? { ...x, window_days: Number.isFinite(n) ? n : x.window_days }
|
||||||
|
: x,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{b.type === 'ai_insight' && (
|
||||||
|
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Optional: Überschrift"
|
||||||
|
value={b.title || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchSelectedPayload((p) => ({
|
||||||
|
...p,
|
||||||
|
blocks: (p.blocks || []).map((x, j) =>
|
||||||
|
j === idx ? { ...x, title: e.target.value } : x,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Optional: Insight-UUID"
|
||||||
|
value={b.insight_id || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchSelectedPayload((p) => {
|
||||||
|
const v = e.target.value.trim() || null
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
blocks: (p.blocks || []).map((x, j) =>
|
||||||
|
j === idx ? { ...x, insight_id: v } : x,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{b.type === 'viz_bundle' && (
|
||||||
|
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 11, color: 'var(--text3)', display: 'block', marginBottom: 4 }}>
|
||||||
|
Verlauf-Bundle
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={b.bundle_id}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchSelectedPayload((p) => ({
|
||||||
|
...p,
|
||||||
|
blocks: (p.blocks || []).map((x, j) =>
|
||||||
|
j === idx ? { ...x, bundle_id: e.target.value, config: {} } : x,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{vizBundles.map((vb) => (
|
||||||
|
<option key={vb.id} value={vb.id}>
|
||||||
|
{vb.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 11, color: 'var(--text3)', display: 'block', marginBottom: 4 }}>
|
||||||
|
Zeitraum für dieses Bundle (Tage): {chartDaysMin}–{chartDaysMax}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
style={{ maxWidth: 160 }}
|
||||||
|
min={chartDaysMin}
|
||||||
|
max={chartDaysMax}
|
||||||
|
value={vizBundleChartDays(b.config)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const days = normalizeBodyChartDays(e.target.value)
|
||||||
|
patchSelectedPayload((p) => ({
|
||||||
|
...p,
|
||||||
|
blocks: (p.blocks || []).map((x, j) =>
|
||||||
|
j === idx ? { ...x, config: { ...x.config, chart_days: days } } : x,
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{b.bundle_id === 'body_history_viz' && (
|
||||||
|
<BodyHistoryVizConfigEditor
|
||||||
|
config={b.config || {}}
|
||||||
|
onChange={(next) =>
|
||||||
|
patchSelectedPayload((p) => ({
|
||||||
|
...p,
|
||||||
|
blocks: (p.blocks || []).map((x, j) => {
|
||||||
|
if (j !== idx) return x
|
||||||
|
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||||
|
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{b.bundle_id === 'nutrition_history_viz' && (
|
||||||
|
<NutritionHistoryVizConfigEditor
|
||||||
|
config={b.config || {}}
|
||||||
|
onChange={(next) =>
|
||||||
|
patchSelectedPayload((p) => ({
|
||||||
|
...p,
|
||||||
|
blocks: (p.blocks || []).map((x, j) => {
|
||||||
|
if (j !== idx) return x
|
||||||
|
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||||
|
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{b.bundle_id === 'fitness_history_viz' && (
|
||||||
|
<FitnessHistoryVizConfigEditor
|
||||||
|
config={b.config || {}}
|
||||||
|
onChange={(next) =>
|
||||||
|
patchSelectedPayload((p) => ({
|
||||||
|
...p,
|
||||||
|
blocks: (p.blocks || []).map((x, j) => {
|
||||||
|
if (j !== idx) return x
|
||||||
|
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||||
|
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{b.bundle_id === 'recovery_history_viz' && (
|
||||||
|
<RecoveryHistoryVizConfigEditor
|
||||||
|
config={b.config || {}}
|
||||||
|
onChange={(next) =>
|
||||||
|
patchSelectedPayload((p) => ({
|
||||||
|
...p,
|
||||||
|
blocks: (p.blocks || []).map((x, j) => {
|
||||||
|
if (j !== idx) return x
|
||||||
|
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||||
|
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{b.bundle_id === 'history_overview_viz' && (
|
||||||
|
<HistoryOverviewVizConfigEditor
|
||||||
|
config={b.config || {}}
|
||||||
|
onChange={(next) =>
|
||||||
|
patchSelectedPayload((p) => ({
|
||||||
|
...p,
|
||||||
|
blocks: (p.blocks || []).map((x, j) => {
|
||||||
|
if (j !== idx) return x
|
||||||
|
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||||
|
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
style={{ maxWidth: 280 }}
|
||||||
|
defaultValue=""
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
if (!v) return
|
||||||
|
patchSelectedPayload((p) => ({
|
||||||
|
...p,
|
||||||
|
blocks: [...(p.blocks || []), reportNewBlock(v)],
|
||||||
|
}))
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">+ Block hinzufügen…</option>
|
||||||
|
<option value="section">Überschrift</option>
|
||||||
|
<option value="viz_bundle">Verlauf-Bundle (KPIs & Diagramme)</option>
|
||||||
|
<option value="chart">Einzel-Diagramm (Legacy)</option>
|
||||||
|
<option value="ai_insight">KI-Auswertung</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={handleSave}
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||||||
|
>
|
||||||
|
<Save size={16} />
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={handleDeleteDefinition}
|
||||||
|
>
|
||||||
|
Diesen Bericht löschen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={handleGeneratePdf}
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
PDF erzeugen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{exportUsage && (
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<UsageBadge {...exportUsage} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,10 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target, LayoutDashboard, FileText, Trash2 } from 'lucide-react'
|
import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target, LayoutDashboard, FileText } from 'lucide-react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useProfile } from '../context/ProfileContext'
|
import { useProfile } from '../context/ProfileContext'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { Avatar } from './ProfileSelect'
|
import { Avatar } from './ProfileSelect'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor'
|
|
||||||
import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor'
|
|
||||||
import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor'
|
|
||||||
import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor'
|
|
||||||
import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor'
|
|
||||||
import FeatureUsageOverview from '../components/FeatureUsageOverview'
|
import FeatureUsageOverview from '../components/FeatureUsageOverview'
|
||||||
import UsageBadge from '../components/UsageBadge'
|
import UsageBadge from '../components/UsageBadge'
|
||||||
|
|
||||||
|
|
@ -28,11 +23,6 @@ export default function SettingsPage() {
|
||||||
const [newPin, setNewPin] = useState('')
|
const [newPin, setNewPin] = useState('')
|
||||||
const [pinMsg, setPinMsg] = useState(null)
|
const [pinMsg, setPinMsg] = useState(null)
|
||||||
const [exportUsage, setExportUsage] = useState(null) // Phase 3: Usage badge
|
const [exportUsage, setExportUsage] = useState(null) // Phase 3: Usage badge
|
||||||
const [reportCatalog, setReportCatalog] = useState(null)
|
|
||||||
const [reportDraft, setReportDraft] = useState(null)
|
|
||||||
const [reportStored, setReportStored] = useState(false)
|
|
||||||
const [reportBusy, setReportBusy] = useState(false)
|
|
||||||
const [reportNote, setReportNote] = useState(null)
|
|
||||||
|
|
||||||
// Load feature usage for export badges
|
// Load feature usage for export badges
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -42,87 +32,6 @@ export default function SettingsPage() {
|
||||||
}).catch(err => console.error('Failed to load usage:', err))
|
}).catch(err => console.error('Failed to load usage:', err))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activeProfile?.id) return
|
|
||||||
let cancel = false
|
|
||||||
Promise.all([api.getReportsCatalog(), api.getReportProfile()])
|
|
||||||
.then(([cat, bundle]) => {
|
|
||||||
if (cancel) return
|
|
||||||
setReportCatalog(cat)
|
|
||||||
setReportDraft(JSON.parse(JSON.stringify(bundle.profile)))
|
|
||||||
setReportStored(!!bundle.stored)
|
|
||||||
setReportNote(null)
|
|
||||||
})
|
|
||||||
.catch((e) => console.error('report profile load', e))
|
|
||||||
return () => {
|
|
||||||
cancel = true
|
|
||||||
}
|
|
||||||
}, [activeProfile?.id])
|
|
||||||
|
|
||||||
const reportNewBlock = (kind) => {
|
|
||||||
const charts = reportCatalog?.charts || []
|
|
||||||
const first = charts[0]
|
|
||||||
const bundles = reportCatalog?.viz_bundles || []
|
|
||||||
const firstBundleId = bundles[0]?.id || 'body_history_viz'
|
|
||||||
if (kind === 'section') return { type: 'section', title: 'Neue Überschrift' }
|
|
||||||
if (kind === 'viz_bundle') return { type: 'viz_bundle', bundle_id: firstBundleId, config: {} }
|
|
||||||
if (kind === 'chart')
|
|
||||||
return {
|
|
||||||
type: 'chart',
|
|
||||||
chart_id: first?.id || 'weight_trend',
|
|
||||||
window_days: first?.default_window_days || 28,
|
|
||||||
}
|
|
||||||
return { type: 'ai_insight', title: '', insight_id: null }
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSaveReportProfile = async () => {
|
|
||||||
if (!reportDraft) return
|
|
||||||
if (!reportDraft.blocks?.length) {
|
|
||||||
setReportNote({ type: 'err', text: 'Mindestens ein Block erforderlich.' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setReportBusy(true)
|
|
||||||
setReportNote(null)
|
|
||||||
try {
|
|
||||||
await api.putReportProfile(reportDraft)
|
|
||||||
setReportStored(true)
|
|
||||||
setReportNote({ type: 'ok', text: 'Berichtsprofil gespeichert.' })
|
|
||||||
} catch (e) {
|
|
||||||
setReportNote({ type: 'err', text: e.message })
|
|
||||||
} finally {
|
|
||||||
setReportBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleResetReportProfile = async () => {
|
|
||||||
if (!confirm('Persönliches Berichtsprofil löschen und Standard wiederherstellen?')) return
|
|
||||||
setReportBusy(true)
|
|
||||||
setReportNote(null)
|
|
||||||
try {
|
|
||||||
const bundle = await api.resetReportProfile()
|
|
||||||
setReportDraft(bundle.profile)
|
|
||||||
setReportStored(false)
|
|
||||||
setReportNote({ type: 'ok', text: 'Standard wiederhergestellt.' })
|
|
||||||
} catch (e) {
|
|
||||||
setReportNote({ type: 'err', text: e.message })
|
|
||||||
} finally {
|
|
||||||
setReportBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleGenerateStructuredPdf = async () => {
|
|
||||||
setReportBusy(true)
|
|
||||||
setReportNote(null)
|
|
||||||
try {
|
|
||||||
await api.generateStructuredReportPdf()
|
|
||||||
setReportNote({ type: 'ok', text: 'PDF wurde heruntergeladen.' })
|
|
||||||
} catch (e) {
|
|
||||||
setReportNote({ type: 'err', text: e.message })
|
|
||||||
} finally {
|
|
||||||
setReportBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
if (!confirm('Ausloggen?')) return
|
if (!confirm('Ausloggen?')) return
|
||||||
await logout()
|
await logout()
|
||||||
|
|
@ -587,20 +496,15 @@ export default function SettingsPage() {
|
||||||
<FeatureUsageOverview />
|
<FeatureUsageOverview />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Strukturierter PDF-Bericht (Profil v1) */}
|
{/* PDF-Berichte: eigener Tab (wie Übersicht) */}
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<FileText size={18} color="var(--accent)" />
|
<FileText size={18} color="var(--accent)" />
|
||||||
PDF-Bericht (strukturiert)
|
PDF-Berichte
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.65 }}>
|
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.65 }}>
|
||||||
<strong>Eigenes Berichtsprofil:</strong> Überschriften, <strong>Verlauf-Bundles</strong> (KPIs,
|
Mehrere strukturierte Berichte, Zeiträume pro Verlauf-Bundle und PDF-Erzeugung findest du im
|
||||||
Einschätzungen und Diagramme wie im Bereich Verlauf) und optional einzelne Legacy-Diagramme. Gleiche
|
eigenen Bereich — analog „Übersicht“ in den Einstellungen.
|
||||||
Schalter wie unter{' '}
|
|
||||||
<Link to="/settings/dashboard-layout" style={{ color: 'var(--accent)' }}>
|
|
||||||
Übersicht anpassen
|
|
||||||
</Link>
|
|
||||||
. PDF wird serverseitig aus dem Datenlayer erzeugt — kein Screenshot der Widgets.
|
|
||||||
</p>
|
</p>
|
||||||
{!canExport && (
|
{!canExport && (
|
||||||
<div
|
<div
|
||||||
|
|
@ -613,319 +517,17 @@ export default function SettingsPage() {
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
🔒 PDF-Bericht nutzt dasselbe Kontingent wie Datenexporte. Bitte Admin kontaktieren.
|
PDF nutzt dasselbe Kontingent wie Datenexporte. Bitte Admin kontaktieren.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{reportNote && (
|
{canExport && (
|
||||||
<div
|
<Link
|
||||||
style={{
|
to="/settings/reports"
|
||||||
padding: '10px 12px',
|
className="btn btn-primary btn-full"
|
||||||
borderRadius: 8,
|
style={{ textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box' }}
|
||||||
fontSize: 13,
|
|
||||||
marginBottom: 12,
|
|
||||||
background: reportNote.type === 'ok' ? '#E1F5EE' : '#FCEBEB',
|
|
||||||
color: reportNote.type === 'ok' ? 'var(--accent)' : '#D85A30',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{reportNote.text}
|
Zu den PDF-Berichten
|
||||||
</div>
|
</Link>
|
||||||
)}
|
|
||||||
{canExport && reportDraft && reportCatalog && (
|
|
||||||
<>
|
|
||||||
<label className="form-label" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
|
||||||
Dokumenttitel (optional)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-input"
|
|
||||||
maxLength={120}
|
|
||||||
placeholder="Leer = Profilname + Bericht"
|
|
||||||
value={reportDraft.document_title || ''}
|
|
||||||
onChange={(e) => setReportDraft((d) => ({ ...d, document_title: e.target.value }))}
|
|
||||||
style={{ marginBottom: 14 }}
|
|
||||||
/>
|
|
||||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text2)', marginBottom: 8 }}>
|
|
||||||
Blöcke {reportStored ? '' : '(Standard — noch nicht separat gespeichert)'}
|
|
||||||
</div>
|
|
||||||
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 12px' }}>
|
|
||||||
{reportDraft.blocks?.map((b, idx) => (
|
|
||||||
<li
|
|
||||||
key={idx}
|
|
||||||
style={{
|
|
||||||
border: '1px solid var(--border)',
|
|
||||||
borderRadius: 10,
|
|
||||||
padding: 12,
|
|
||||||
marginBottom: 10,
|
|
||||||
background: 'var(--surface2)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
|
|
||||||
<span style={{ fontSize: 11, color: 'var(--text3)', textTransform: 'uppercase' }}>{b.type}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
style={{ padding: '4px 8px' }}
|
|
||||||
aria-label="Block entfernen"
|
|
||||||
onClick={() =>
|
|
||||||
setReportDraft((d) => ({
|
|
||||||
...d,
|
|
||||||
blocks: d.blocks.filter((_, j) => j !== idx),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{b.type === 'section' && (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-input"
|
|
||||||
style={{ marginTop: 8 }}
|
|
||||||
value={b.title || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setReportDraft((d) => {
|
|
||||||
const blocks = d.blocks.map((x, j) =>
|
|
||||||
j === idx ? { ...x, title: e.target.value } : x
|
|
||||||
)
|
|
||||||
return { ...d, blocks }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{b.type === 'chart' && (
|
|
||||||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
value={b.chart_id}
|
|
||||||
onChange={(e) =>
|
|
||||||
setReportDraft((d) => {
|
|
||||||
const blocks = d.blocks.map((x, j) =>
|
|
||||||
j === idx ? { ...x, chart_id: e.target.value } : x
|
|
||||||
)
|
|
||||||
return { ...d, blocks }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{reportCatalog.charts?.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>
|
|
||||||
{c.title}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<div>
|
|
||||||
<label style={{ fontSize: 11, color: 'var(--text3)' }}>Zeitraum (Tage)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="form-input"
|
|
||||||
min={7}
|
|
||||||
max={365}
|
|
||||||
value={b.window_days}
|
|
||||||
onChange={(e) =>
|
|
||||||
setReportDraft((d) => {
|
|
||||||
const n = Number(e.target.value)
|
|
||||||
const blocks = d.blocks.map((x, j) =>
|
|
||||||
j === idx ? { ...x, window_days: Number.isFinite(n) ? n : x.window_days } : x
|
|
||||||
)
|
|
||||||
return { ...d, blocks }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{b.type === 'ai_insight' && (
|
|
||||||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-input"
|
|
||||||
placeholder="Optional: Überschrift"
|
|
||||||
value={b.title || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setReportDraft((d) => {
|
|
||||||
const blocks = d.blocks.map((x, j) =>
|
|
||||||
j === idx ? { ...x, title: e.target.value } : x
|
|
||||||
)
|
|
||||||
return { ...d, blocks }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="form-input"
|
|
||||||
placeholder="Optional: Insight-UUID (aus KI-Verlauf)"
|
|
||||||
value={b.insight_id || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setReportDraft((d) => {
|
|
||||||
const v = e.target.value.trim() || null
|
|
||||||
const blocks = d.blocks.map((x, j) =>
|
|
||||||
j === idx ? { ...x, insight_id: v } : x
|
|
||||||
)
|
|
||||||
return { ...d, blocks }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{b.type === 'viz_bundle' && (
|
|
||||||
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
||||||
<div>
|
|
||||||
<label style={{ fontSize: 11, color: 'var(--text3)', display: 'block', marginBottom: 4 }}>
|
|
||||||
Verlauf-Bundle
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
value={b.bundle_id}
|
|
||||||
onChange={(e) =>
|
|
||||||
setReportDraft((d) => {
|
|
||||||
const blocks = d.blocks.map((x, j) =>
|
|
||||||
j === idx ? { ...x, bundle_id: e.target.value, config: {} } : x
|
|
||||||
)
|
|
||||||
return { ...d, blocks }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(reportCatalog.viz_bundles || []).map((vb) => (
|
|
||||||
<option key={vb.id} value={vb.id}>
|
|
||||||
{vb.title}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{b.bundle_id === 'body_history_viz' && (
|
|
||||||
<BodyHistoryVizConfigEditor
|
|
||||||
config={b.config || {}}
|
|
||||||
onChange={(next) =>
|
|
||||||
setReportDraft((d) => {
|
|
||||||
const blocks = d.blocks.map((x, j) => {
|
|
||||||
if (j !== idx) return x
|
|
||||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
|
||||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
|
||||||
})
|
|
||||||
return { ...d, blocks }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{b.bundle_id === 'nutrition_history_viz' && (
|
|
||||||
<NutritionHistoryVizConfigEditor
|
|
||||||
config={b.config || {}}
|
|
||||||
onChange={(next) =>
|
|
||||||
setReportDraft((d) => {
|
|
||||||
const blocks = d.blocks.map((x, j) => {
|
|
||||||
if (j !== idx) return x
|
|
||||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
|
||||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
|
||||||
})
|
|
||||||
return { ...d, blocks }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{b.bundle_id === 'fitness_history_viz' && (
|
|
||||||
<FitnessHistoryVizConfigEditor
|
|
||||||
config={b.config || {}}
|
|
||||||
onChange={(next) =>
|
|
||||||
setReportDraft((d) => {
|
|
||||||
const blocks = d.blocks.map((x, j) => {
|
|
||||||
if (j !== idx) return x
|
|
||||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
|
||||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
|
||||||
})
|
|
||||||
return { ...d, blocks }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{b.bundle_id === 'recovery_history_viz' && (
|
|
||||||
<RecoveryHistoryVizConfigEditor
|
|
||||||
config={b.config || {}}
|
|
||||||
onChange={(next) =>
|
|
||||||
setReportDraft((d) => {
|
|
||||||
const blocks = d.blocks.map((x, j) => {
|
|
||||||
if (j !== idx) return x
|
|
||||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
|
||||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
|
||||||
})
|
|
||||||
return { ...d, blocks }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{b.bundle_id === 'history_overview_viz' && (
|
|
||||||
<HistoryOverviewVizConfigEditor
|
|
||||||
config={b.config || {}}
|
|
||||||
onChange={(next) =>
|
|
||||||
setReportDraft((d) => {
|
|
||||||
const blocks = d.blocks.map((x, j) => {
|
|
||||||
if (j !== idx) return x
|
|
||||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
|
||||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
|
||||||
})
|
|
||||||
return { ...d, blocks }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
|
|
||||||
<select
|
|
||||||
className="form-input"
|
|
||||||
style={{ maxWidth: 220 }}
|
|
||||||
defaultValue=""
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value
|
|
||||||
if (!v) return
|
|
||||||
setReportDraft((d) => ({ ...d, blocks: [...(d.blocks || []), reportNewBlock(v)] }))
|
|
||||||
e.target.value = ''
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">+ Block hinzufügen…</option>
|
|
||||||
<option value="section">Überschrift</option>
|
|
||||||
<option value="viz_bundle">Verlauf-Bundle (KPIs & Diagramme)</option>
|
|
||||||
<option value="chart">Einzel-Diagramm (Legacy)</option>
|
|
||||||
<option value="ai_insight">KI-Auswertung</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary"
|
|
||||||
disabled={reportBusy}
|
|
||||||
onClick={handleSaveReportProfile}
|
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
|
||||||
>
|
|
||||||
<Save size={16} />
|
|
||||||
Bericht speichern
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
disabled={reportBusy}
|
|
||||||
onClick={handleResetReportProfile}
|
|
||||||
>
|
|
||||||
Standard wiederherstellen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
disabled={reportBusy}
|
|
||||||
onClick={handleGenerateStructuredPdf}
|
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
|
||||||
>
|
|
||||||
<Download size={16} />
|
|
||||||
PDF erzeugen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{exportUsage && (
|
|
||||||
<div style={{ marginTop: 10 }}>
|
|
||||||
<UsageBadge {...exportUsage} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -268,11 +268,18 @@ export const api = {
|
||||||
|
|
||||||
// Strukturierter PDF-Bericht (Profil v1, unabhängig vom Dashboard)
|
// Strukturierter PDF-Bericht (Profil v1, unabhängig vom Dashboard)
|
||||||
getReportsCatalog: () => req('/reports/catalog'),
|
getReportsCatalog: () => req('/reports/catalog'),
|
||||||
getReportProfile: () => req('/reports/profile'),
|
listReportDefinitions: () => req('/reports/definitions'),
|
||||||
putReportProfile: (profile) => req('/reports/profile', jput(profile)),
|
createReportDefinition: (body) => req('/reports/definitions', json(body ?? {})),
|
||||||
resetReportProfile: () => req('/reports/profile', { method: 'DELETE' }),
|
updateReportDefinition: (id, body) =>
|
||||||
generateStructuredReportPdf: async () => {
|
req(`/reports/definitions/${encodeURIComponent(id)}`, jput(body)),
|
||||||
const res = await fetch(`${BASE}/reports/generate-pdf`, { method: 'POST', headers: hdrs() })
|
deleteReportDefinition: (id) =>
|
||||||
|
req(`/reports/definitions/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
|
generateStructuredReportPdf: async (definitionId) => {
|
||||||
|
const res = await fetch(`${BASE}/reports/generate-pdf`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...hdrs(), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(definitionId ? { definition_id: definitionId } : {}),
|
||||||
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let msg = `HTTP ${res.status}`
|
let msg = `HTTP ${res.status}`
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user