2026-04-07 11:38:35 +02:00
|
|
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
|
|
|
|
import { ChevronDown, ChevronUp, LayoutGrid } from 'lucide-react'
|
|
|
|
|
|
import { Link } from 'react-router-dom'
|
|
|
|
|
|
import { api, formatFastApiDetail } from '../utils/api'
|
2026-04-07 11:47:16 +02:00
|
|
|
|
import { WidgetRenderer } from '../widgetSystem/dashboardWidgetRegistry'
|
|
|
|
|
|
import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
|
2026-04-07 11:58:07 +02:00
|
|
|
|
import {
|
|
|
|
|
|
BODY_CHART_DAYS_DEFAULT,
|
|
|
|
|
|
BODY_CHART_DAYS_MAX,
|
|
|
|
|
|
BODY_CHART_DAYS_MIN,
|
|
|
|
|
|
normalizeBodyChartDays,
|
|
|
|
|
|
} from '../widgetSystem/bodyChartDays'
|
|
|
|
|
|
import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor'
|
2026-04-07 11:38:35 +02:00
|
|
|
|
|
2026-04-07 12:20:23 +02:00
|
|
|
|
/** Widgets mit optionalem config.chart_days (7–90), gleiche UX im Editor */
|
|
|
|
|
|
const CHART_DAYS_WIDGET_IDS = new Set(['body_overview', 'activity_overview'])
|
|
|
|
|
|
|
2026-04-07 11:47:16 +02:00
|
|
|
|
function catalogMetaById(catalog) {
|
|
|
|
|
|
if (!catalog?.widgets?.length) return {}
|
|
|
|
|
|
return Object.fromEntries(catalog.widgets.map((w) => [w.id, w]))
|
2026-04-07 11:38:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function DashboardLabPage() {
|
2026-04-07 11:47:16 +02:00
|
|
|
|
ensurePilotLabWidgetsRegistered()
|
|
|
|
|
|
|
2026-04-07 11:38:35 +02:00
|
|
|
|
const [refreshTick, setRefreshTick] = useState(0)
|
2026-04-07 11:47:16 +02:00
|
|
|
|
const requestRefresh = () => setRefreshTick((t) => t + 1)
|
|
|
|
|
|
const [catalog, setCatalog] = useState(null)
|
2026-04-07 11:38:35 +02:00
|
|
|
|
const [bundle, setBundle] = useState(null)
|
|
|
|
|
|
const [layout, setLayout] = useState(null)
|
|
|
|
|
|
const [err, setErr] = useState(null)
|
|
|
|
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
|
|
const [msg, setMsg] = useState(null)
|
2026-04-07 12:20:23 +02:00
|
|
|
|
/** Pro Widget-ID: Rohstring während der Eingabe (Tippen ohne sofortiges Clampen) */
|
|
|
|
|
|
const [chartDaysDraftByWidgetId, setChartDaysDraftByWidgetId] = useState({})
|
2026-04-07 11:38:35 +02:00
|
|
|
|
|
2026-04-07 11:47:16 +02:00
|
|
|
|
const metaById = catalogMetaById(catalog)
|
|
|
|
|
|
|
2026-04-07 12:20:23 +02:00
|
|
|
|
const commitChartDaysDraftToLayout = useCallback((draftStr, baseLayout, widgetId) => {
|
|
|
|
|
|
const clamped = normalizeBodyChartDays(
|
|
|
|
|
|
draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr
|
|
|
|
|
|
)
|
2026-04-07 12:05:05 +02:00
|
|
|
|
return {
|
|
|
|
|
|
...baseLayout,
|
|
|
|
|
|
widgets: baseLayout.widgets.map((x) =>
|
2026-04-07 12:20:23 +02:00
|
|
|
|
x.id !== widgetId ? x : { ...x, config: { ...x.config, chart_days: clamped } }
|
2026-04-07 12:05:05 +02:00
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
2026-04-07 11:38:35 +02:00
|
|
|
|
const load = useCallback(async () => {
|
|
|
|
|
|
setErr(null)
|
|
|
|
|
|
try {
|
2026-04-07 11:47:16 +02:00
|
|
|
|
const [cat, b] = await Promise.all([api.getAppWidgetsCatalog(), api.getAppDashboardLayout()])
|
|
|
|
|
|
setCatalog(cat)
|
2026-04-07 11:38:35 +02:00
|
|
|
|
setBundle(b)
|
2026-04-07 12:20:23 +02:00
|
|
|
|
setChartDaysDraftByWidgetId({})
|
2026-04-07 11:58:07 +02:00
|
|
|
|
setLayout(normalizeLayoutForEditor(b.layout))
|
2026-04-07 11:38:35 +02:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setErr(formatFastApiDetail(null, e.message))
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
load()
|
|
|
|
|
|
}, [load])
|
|
|
|
|
|
|
|
|
|
|
|
const save = async () => {
|
|
|
|
|
|
if (!layout) return
|
2026-04-07 12:05:05 +02:00
|
|
|
|
let toSave = layout
|
2026-04-07 12:20:23 +02:00
|
|
|
|
const draftEntries = Object.entries(chartDaysDraftByWidgetId)
|
|
|
|
|
|
if (draftEntries.length) {
|
|
|
|
|
|
for (const [wid, val] of draftEntries) {
|
|
|
|
|
|
toSave = normalizeLayoutForEditor(commitChartDaysDraftToLayout(val, toSave, wid))
|
|
|
|
|
|
}
|
2026-04-07 12:05:05 +02:00
|
|
|
|
setLayout(toSave)
|
2026-04-07 12:20:23 +02:00
|
|
|
|
setChartDaysDraftByWidgetId({})
|
2026-04-07 12:05:05 +02:00
|
|
|
|
}
|
2026-04-07 11:38:35 +02:00
|
|
|
|
setBusy(true)
|
|
|
|
|
|
setMsg(null)
|
|
|
|
|
|
setErr(null)
|
|
|
|
|
|
try {
|
2026-04-07 12:05:05 +02:00
|
|
|
|
await api.putAppDashboardLayout(toSave)
|
2026-04-07 11:38:35 +02:00
|
|
|
|
setMsg('Layout gespeichert.')
|
|
|
|
|
|
await load()
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setErr(formatFastApiDetail(null, e.message))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusy(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const reset = async () => {
|
|
|
|
|
|
if (!confirm('Persönliches Layout löschen und Standard wiederherstellen?')) return
|
|
|
|
|
|
setBusy(true)
|
|
|
|
|
|
setMsg(null)
|
|
|
|
|
|
setErr(null)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = await api.resetAppDashboardLayout()
|
2026-04-07 12:20:23 +02:00
|
|
|
|
setChartDaysDraftByWidgetId({})
|
2026-04-07 11:58:07 +02:00
|
|
|
|
setLayout(normalizeLayoutForEditor(r.layout))
|
2026-04-07 11:38:35 +02:00
|
|
|
|
setMsg('Auf Standard zurückgesetzt.')
|
|
|
|
|
|
await load()
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
setErr(formatFastApiDetail(null, e.message))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusy(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const applyDefaultLocal = () => {
|
|
|
|
|
|
if (bundle?.default_layout) {
|
2026-04-07 12:20:23 +02:00
|
|
|
|
setChartDaysDraftByWidgetId({})
|
2026-04-07 11:58:07 +02:00
|
|
|
|
setLayout(normalizeLayoutForEditor(structuredClone(bundle.default_layout)))
|
2026-04-07 11:38:35 +02:00
|
|
|
|
setMsg('Standard geladen (noch nicht gespeichert).')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (err && !layout) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ padding: 24, maxWidth: 640, margin: '0 auto' }}>
|
|
|
|
|
|
<p style={{ color: '#D85A30' }}>{err}</p>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={load}>
|
|
|
|
|
|
Erneut laden
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!layout) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ padding: 48, textAlign: 'center' }}>
|
|
|
|
|
|
<div className="spinner" style={{ width: 32, height: 32, margin: '0 auto' }} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ paddingBottom: 96, textAlign: 'left', maxWidth: 920, margin: '0 auto' }}>
|
|
|
|
|
|
<div style={{ marginBottom: 20 }}>
|
|
|
|
|
|
<Link
|
|
|
|
|
|
to="/settings"
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
style={{ display: 'inline-flex', marginBottom: 12, textDecoration: 'none' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
← Einstellungen
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
|
|
|
|
<LayoutGrid size={26} color="var(--accent)" />
|
|
|
|
|
|
App-Bereich: Dashboard-Lab
|
|
|
|
|
|
</h1>
|
|
|
|
|
|
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
|
2026-04-07 11:58:07 +02:00
|
|
|
|
Widget-System: Katalog, Registry, Renderer; optional pro Widget <code>config</code> (z. B.{' '}
|
2026-04-07 12:20:23 +02:00
|
|
|
|
<strong>Körper</strong> und <strong>Aktivität</strong>: Zeitraum 7–90 Tage). Layout pro Profil in der DB —
|
|
|
|
|
|
getrennt vom Produktiv-Dashboard.
|
2026-04-07 11:58:07 +02:00
|
|
|
|
Vergleich:{' '}
|
2026-04-07 11:38:35 +02:00
|
|
|
|
<Link to="/pilot/viz" style={{ color: 'var(--accent)' }}>
|
2026-04-07 11:47:16 +02:00
|
|
|
|
Pilot-Übersicht (festes Standard-Layout)
|
2026-04-07 11:38:35 +02:00
|
|
|
|
</Link>
|
|
|
|
|
|
.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="card"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
marginBottom: 20,
|
|
|
|
|
|
borderStyle: 'dashed',
|
|
|
|
|
|
borderColor: 'var(--border2)',
|
|
|
|
|
|
background: 'var(--surface2)',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="card-title" style={{ fontSize: 14 }}>
|
|
|
|
|
|
Layout (v1)
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{bundle && (
|
|
|
|
|
|
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12 }}>
|
|
|
|
|
|
Status: {bundle.custom ? 'individuell gespeichert' : 'Standard (nicht in DB)'}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
2026-04-07 11:47:16 +02:00
|
|
|
|
{err && <p style={{ fontSize: 12, color: '#D85A30', marginBottom: 8 }}>{err}</p>}
|
|
|
|
|
|
{msg && <p style={{ fontSize: 12, color: 'var(--accent)', marginBottom: 8 }}>{msg}</p>}
|
2026-04-07 11:38:35 +02:00
|
|
|
|
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 12px' }}>
|
|
|
|
|
|
{layout.widgets.map((w, i) => {
|
2026-04-07 11:47:16 +02:00
|
|
|
|
const label = metaById[w.id]?.title || w.id
|
2026-04-07 11:58:07 +02:00
|
|
|
|
const chartDaysVal =
|
|
|
|
|
|
w.config?.chart_days != null ? normalizeBodyChartDays(w.config.chart_days) : BODY_CHART_DAYS_DEFAULT
|
2026-04-07 11:38:35 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<li
|
|
|
|
|
|
key={w.id}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
padding: '8px 0',
|
|
|
|
|
|
borderBottom: '1px solid var(--border)',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-07 11:58:07 +02:00
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
gap: 8,
|
|
|
|
|
|
flexWrap: 'wrap',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 140px' }}>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={w.enabled}
|
|
|
|
|
|
onChange={() => setLayout((L) => toggleWidget(L, i))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span style={{ fontSize: 14 }}>{label}</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
style={{ padding: '6px 10px' }}
|
|
|
|
|
|
aria-label="Nach oben"
|
|
|
|
|
|
onClick={() => setLayout((L) => moveWidget(L, i, -1))}
|
|
|
|
|
|
>
|
|
|
|
|
|
<ChevronUp size={18} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
style={{ padding: '6px 10px' }}
|
|
|
|
|
|
aria-label="Nach unten"
|
|
|
|
|
|
onClick={() => setLayout((L) => moveWidget(L, i, 1))}
|
|
|
|
|
|
>
|
|
|
|
|
|
<ChevronDown size={18} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-04-07 11:38:35 +02:00
|
|
|
|
</div>
|
2026-04-07 12:20:23 +02:00
|
|
|
|
{CHART_DAYS_WIDGET_IDS.has(w.id) && (
|
2026-04-07 11:58:07 +02:00
|
|
|
|
<div style={{ marginTop: 10, marginLeft: 28 }}>
|
|
|
|
|
|
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 4 }}>
|
2026-04-07 12:20:23 +02:00
|
|
|
|
{w.id === 'body_overview' ? 'Körper-Chart' : 'Aktivität (Verteilung & Konsistenz)'} — Zeitraum
|
|
|
|
|
|
(Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX}
|
2026-04-07 11:58:07 +02:00
|
|
|
|
</label>
|
|
|
|
|
|
<input
|
2026-04-07 12:05:05 +02:00
|
|
|
|
type="text"
|
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
|
autoComplete="off"
|
2026-04-07 11:58:07 +02:00
|
|
|
|
className="form-input"
|
|
|
|
|
|
style={{ maxWidth: 120 }}
|
2026-04-07 12:20:23 +02:00
|
|
|
|
aria-label={
|
|
|
|
|
|
w.id === 'body_overview'
|
|
|
|
|
|
? 'Körper-Chart Zeitraum in Tagen'
|
|
|
|
|
|
: 'Aktivität Zeitraum in Tagen'
|
|
|
|
|
|
}
|
2026-04-07 12:05:05 +02:00
|
|
|
|
value={
|
2026-04-07 12:20:23 +02:00
|
|
|
|
chartDaysDraftByWidgetId[w.id] !== undefined
|
|
|
|
|
|
? chartDaysDraftByWidgetId[w.id]
|
2026-04-07 12:05:05 +02:00
|
|
|
|
: String(chartDaysVal)
|
|
|
|
|
|
}
|
2026-04-07 12:20:23 +02:00
|
|
|
|
onFocus={() =>
|
|
|
|
|
|
setChartDaysDraftByWidgetId((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[w.id]: String(chartDaysVal),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setChartDaysDraftByWidgetId((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[w.id]: e.target.value,
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
onBlur={(e) => {
|
|
|
|
|
|
const raw = e.target.value
|
2026-04-07 12:05:05 +02:00
|
|
|
|
setLayout((L) =>
|
2026-04-07 12:20:23 +02:00
|
|
|
|
normalizeLayoutForEditor(commitChartDaysDraftToLayout(raw, L, w.id))
|
2026-04-07 12:05:05 +02:00
|
|
|
|
)
|
2026-04-07 12:20:23 +02:00
|
|
|
|
setChartDaysDraftByWidgetId((prev) => {
|
|
|
|
|
|
const next = { ...prev }
|
|
|
|
|
|
delete next[w.id]
|
|
|
|
|
|
return next
|
|
|
|
|
|
})
|
2026-04-07 11:58:07 +02:00
|
|
|
|
}}
|
2026-04-07 12:05:05 +02:00
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
|
if (e.key === 'Enter') e.currentTarget.blur()
|
2026-04-07 11:58:07 +02:00
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-04-07 11:38:35 +02:00
|
|
|
|
</li>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
|
|
|
|
|
<button type="button" className="btn btn-primary" disabled={busy} onClick={save}>
|
|
|
|
|
|
Speichern
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={reset}>
|
|
|
|
|
|
Zurücksetzen (DB)
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={applyDefaultLocal}>
|
|
|
|
|
|
Standard in Editor laden
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-07 11:47:16 +02:00
|
|
|
|
<WidgetRenderer layout={layout} refreshTick={refreshTick} requestRefresh={requestRefresh} />
|
2026-04-07 11:38:35 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|