mitai-jinkendo/frontend/src/pages/ReportConfigurePage.jsx
Lars ed2b457da3
All checks were successful
Deploy Development / deploy (push) Successful in 1m5s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 20s
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.
2026-04-29 12:11:26 +02:00

630 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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