mitai-jinkendo/frontend/src/pages/ReportConfigurePage.jsx

630 lines
24 KiB
React
Raw Normal View History

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>
)
}