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

677 lines
25 KiB
React
Raw Normal View History

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { ChevronDown, ChevronUp, GripVertical, LayoutDashboard, Plus, Search, X } from 'lucide-react'
import { api, formatFastApiDetail } from '../utils/api'
import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
import {
BODY_CHART_DAYS_DEFAULT,
BODY_CHART_DAYS_MAX,
BODY_CHART_DAYS_MIN,
normalizeBodyChartDays,
} from '../widgetSystem/bodyChartDays'
import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor'
import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor'
import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor'
import {
moveWidget,
moveWidgetToIndex,
normalizeLayoutForEditor,
toggleWidget,
} from '../widgetSystem/layoutEditor'
const CHART_DAYS_WIDGET_IDS = new Set([
'body_overview',
'body_history_viz',
'activity_overview',
'nutrition_detail_charts',
'recovery_charts_panel',
])
const DESKTOP_DND_MQ = '(min-width: 768px)'
function catalogMetaById(catalog) {
if (!catalog?.widgets?.length) return {}
return Object.fromEntries(catalog.widgets.map((w) => [w.id, w]))
}
/**
* @param {{ adminMode?: boolean }} [props]
*/
export default function DashboardConfigurePage({ adminMode = false } = {}) {
ensurePilotLabWidgetsRegistered()
const [bundle, setBundle] = useState(null)
const [adminFromDatabase, setAdminFromDatabase] = useState(null)
const [catalog, setCatalog] = useState(null)
const [layout, setLayout] = useState(null)
const [addPanelOpen, setAddPanelOpen] = useState(false)
const [pickerSearch, setPickerSearch] = useState('')
const [viewportDesktop, setViewportDesktop] = useState(() =>
typeof window !== 'undefined' ? window.matchMedia(DESKTOP_DND_MQ).matches : false
)
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState(null)
const [err, setErr] = useState(null)
const [chartDaysDraftByWidgetId, setChartDaysDraftByWidgetId] = useState({})
const [dragOverFullIndex, setDragOverFullIndex] = useState(null)
const pickerSearchRef = useRef(null)
const dndEnabled = viewportDesktop
useEffect(() => {
const mq = window.matchMedia(DESKTOP_DND_MQ)
const fn = () => setViewportDesktop(mq.matches)
fn()
mq.addEventListener('change', fn)
return () => mq.removeEventListener('change', fn)
}, [])
useEffect(() => {
if (!addPanelOpen) return
const t = window.setTimeout(() => pickerSearchRef.current?.focus(), 50)
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
const onKey = (e) => {
if (e.key === 'Escape') setAddPanelOpen(false)
}
window.addEventListener('keydown', onKey)
return () => {
window.clearTimeout(t)
document.body.style.overflow = prev
window.removeEventListener('keydown', onKey)
}
}, [addPanelOpen])
const metaById = useMemo(() => catalogMetaById(catalog), [catalog])
const isWidgetCatalogAllowed = useCallback(
(widgetId) => {
const m = metaById[widgetId]
if (m == null) return true
return m.allowed !== false
},
[metaById],
)
const commitChartDaysDraftToLayout = useCallback((draftStr, baseLayout, widgetId) => {
const clamped = normalizeBodyChartDays(
draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr
)
return {
...baseLayout,
widgets: baseLayout.widgets.map((x) =>
x.id !== widgetId ? x : { ...x, config: { ...x.config, chart_days: clamped } }
),
}
}, [])
const load = useCallback(async () => {
setErr(null)
try {
if (adminMode) {
const [cat, d] = await Promise.all([
api.adminGetWidgetsCatalogFull(),
api.adminGetDashboardProductDefault(),
])
setCatalog(cat)
setBundle({ custom: false, product_default_layout: d.layout })
setAdminFromDatabase(!!d.from_database)
setChartDaysDraftByWidgetId({})
setLayout(normalizeLayoutForEditor(structuredClone(d.layout)))
} else {
const [cat, b] = await Promise.all([api.getAppWidgetsCatalog(), api.getAppDashboardLayout()])
setCatalog(cat)
setBundle(b)
setAdminFromDatabase(null)
setChartDaysDraftByWidgetId({})
const base = b.custom ? b.layout : structuredClone(b.product_default_layout)
setLayout(normalizeLayoutForEditor(base))
}
} catch (e) {
setErr(formatFastApiDetail(null, e.message))
}
}, [adminMode])
useEffect(() => {
load()
}, [load])
const openAddPanel = () => {
setPickerSearch('')
setAddPanelOpen(true)
}
const save = async () => {
if (!layout) return
let toSave = layout
const draftEntries = Object.entries(chartDaysDraftByWidgetId)
if (draftEntries.length) {
for (const [wid, val] of draftEntries) {
toSave = normalizeLayoutForEditor(commitChartDaysDraftToLayout(val, toSave, wid))
}
setLayout(toSave)
setChartDaysDraftByWidgetId({})
}
setBusy(true)
setMsg(null)
setErr(null)
try {
if (adminMode) {
await api.adminPutDashboardProductDefault(toSave)
setMsg('System-Standard für die Übersicht wurde gespeichert.')
} else {
await api.putAppDashboardLayout(toSave)
setMsg('Dein Dashboard wurde gespeichert.')
}
await load()
} catch (e) {
setErr(formatFastApiDetail(null, e.message))
} finally {
setBusy(false)
}
}
const resetToSystem = async () => {
const ok = adminMode
? window.confirm(
'Eintrag in der Datenbank löschen und Layout aus dem Code (widget_catalog) wiederherstellen?'
)
: window.confirm('Dein individuelles Layout löschen und System-Standard wiederherstellen?')
if (!ok) return
setBusy(true)
setMsg(null)
setErr(null)
try {
if (adminMode) {
const r = await api.adminDeleteDashboardProductDefault()
setChartDaysDraftByWidgetId({})
setLayout(normalizeLayoutForEditor(r.layout))
setMsg('Code-Standard wiederhergestellt (kein DB-Override mehr).')
} else {
const r = await api.resetAppDashboardLayout()
setChartDaysDraftByWidgetId({})
setLayout(normalizeLayoutForEditor(r.layout))
setMsg('Auf System-Standard zurückgesetzt.')
}
await load()
} catch (e) {
setErr(formatFastApiDetail(null, e.message))
} finally {
setBusy(false)
}
}
const pickerLower = pickerSearch.trim().toLowerCase()
const libraryIndices = useMemo(() => {
if (!layout?.widgets) return []
return layout.widgets
.map((w, i) => i)
.filter((i) => {
const w = layout.widgets[i]
if (w.enabled || !isWidgetCatalogAllowed(w.id)) return false
if (!pickerLower) return true
const m = metaById[w.id]
const hay = `${m?.title || ''} ${m?.description || ''} ${w.id}`.toLowerCase()
return hay.includes(pickerLower)
})
}, [layout, pickerLower, metaById, isWidgetCatalogAllowed])
const activeIndices = useMemo(() => {
if (!layout?.widgets) return []
return layout.widgets
.map((w, i) => i)
.filter((i) => layout.widgets[i].enabled && isWidgetCatalogAllowed(layout.widgets[i].id))
}, [layout, isWidgetCatalogAllowed])
const addableCount = useMemo(() => {
if (!layout?.widgets) return 0
return layout.widgets.filter((w) => !w.enabled && isWidgetCatalogAllowed(w.id)).length
}, [layout, isWidgetCatalogAllowed])
const onDragStartRow = (e, fullIndex) => {
if (!dndEnabled) return
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', String(fullIndex))
try {
e.dataTransfer.setDragImage(e.currentTarget, 0, 0)
} catch {
/* ok */
}
}
const onDragOverRow = (e, fullIndex) => {
if (!dndEnabled) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setDragOverFullIndex(fullIndex)
}
const onDragLeaveRow = () => {
setDragOverFullIndex(null)
}
const onDropRow = (e, dropFullIndex) => {
if (!dndEnabled) return
e.preventDefault()
setDragOverFullIndex(null)
const raw = e.dataTransfer.getData('text/plain')
const from = Number.parseInt(raw, 10)
if (!Number.isFinite(from)) return
setLayout((L) => normalizeLayoutForEditor(moveWidgetToIndex(L, from, dropFullIndex)))
}
const onDragEndRow = () => {
setDragOverFullIndex(null)
}
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: 720, margin: '0 auto' }}>
<div style={{ marginBottom: 20 }}>
<Link
to={adminMode ? '/admin/g/system' : '/settings'}
className="btn btn-secondary"
style={{ display: 'inline-flex', marginBottom: 12, textDecoration: 'none' }}
>
{adminMode ? '← Basiseinstellungen (Admin)' : '← Einstellungen'}
</Link>
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<LayoutDashboard size={26} color="var(--accent)" />
{adminMode ? 'Produkt-Übersicht: Systemstandard' : 'Übersicht anpassen'}
</h1>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
{adminMode
? 'Globales Standard-Dashboard für alle Nutzer ohne eigenes Layout. Gespeichert in der Datenbank; mit „Code-Standard wiederherstellen“ wird der Eintrag entfernt und der Fallback aus dem Code genutzt.'
: 'Kacheln für die Startseite sortieren und entfernen. Neue Kacheln über „Kachel hinzufügen“ – mit Suche direkt im eigenen Fenster, ohne langes Scrollen.'}
</p>
{adminMode && adminFromDatabase != null && (
<p style={{ fontSize: 12, color: 'var(--accent)', marginTop: 10, lineHeight: 1.5 }}>
{adminFromDatabase ? (
<>
Aktuell gilt ein <strong>gespeicherter Systemstandard</strong> (Datenbank).
</>
) : (
<>
Es liegt <strong>kein DB-Override</strong> vor — es wird der Code-Standard aus dem Widget-Katalog
verwendet.
</>
)}
</p>
)}
{!adminMode && !bundle?.custom && (
<p style={{ fontSize: 12, color: 'var(--accent)', marginTop: 10, lineHeight: 1.5 }}>
Du bearbeitest gerade das <strong>System-Standardlayout</strong>. Mit „Speichern“ legst du deine persönliche
Version ab.
</p>
)}
</div>
<div className="card section-gap" style={{ marginBottom: 16 }}>
<div className="card-title" style={{ fontSize: 15, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<span>
Aktive Kacheln · {activeIndices.length}
{dndEnabled && (
<span style={{ fontSize: 12, fontWeight: 400, color: 'var(--text3)', marginLeft: 8 }}>
(ab Desktop: ziehen oder Pfeile)
</span>
)}
</span>
<button type="button" className="btn btn-primary" style={{ fontSize: 13 }} onClick={openAddPanel} disabled={addableCount === 0}>
<Plus size={16} style={{ marginRight: 6, verticalAlign: 'middle' }} />
Kachel hinzufügen{addableCount > 0 ? ` (${addableCount})` : ''}
</button>
</div>
{addableCount === 0 && (
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8, marginBottom: 0 }}>
Alle freigeschalteten Kacheln sind aktiv.
</p>
)}
{err && <p style={{ fontSize: 12, color: '#D85A30', marginTop: 12, marginBottom: 0 }}>{err}</p>}
{msg && <p style={{ fontSize: 12, color: 'var(--accent)', marginTop: 12, marginBottom: 0 }}>{msg}</p>}
<ul style={{ listStyle: 'none', padding: 0, margin: '12px 0 0' }}>
{activeIndices.map((i) => {
const w = layout.widgets[i]
const label = metaById[w.id]?.title || w.id
const chartDaysVal =
w.config?.chart_days != null
? normalizeBodyChartDays(w.config.chart_days)
: BODY_CHART_DAYS_DEFAULT
const dragOver = dragOverFullIndex === i
return (
<li
key={w.id}
onDragOver={(e) => onDragOverRow(e, i)}
onDragLeave={onDragLeaveRow}
onDrop={(e) => onDropRow(e, i)}
style={{
padding: '10px 0',
borderBottom: '1px solid var(--border)',
background: dragOver ? 'var(--surface2)' : undefined,
borderRadius: dragOver ? 8 : undefined,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{dndEnabled && (
<span
draggable
role="button"
tabIndex={0}
aria-label={`${label} verschieben`}
style={{ cursor: 'grab', color: 'var(--text3)', display: 'flex', touchAction: 'none' }}
onDragStart={(e) => onDragStartRow(e, i)}
onDragEnd={onDragEndRow}
>
<GripVertical size={18} />
</span>
)}
<label style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 160px' }}>
<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>
</div>
{w.id === 'quick_capture' && (
<QuickCaptureConfigEditor
config={w.config || {}}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => {
if (j !== i) return x
const cfg = { ...(x.config || {}) }
for (const k of ['show_weight', 'show_resting_hr', 'show_hrv', 'show_vo2_max']) {
delete cfg[k]
}
Object.assign(cfg, next)
return { ...x, config: cfg }
}),
})
)
}
/>
)}
{w.id === 'kpi_board' && (
<KpiBoardConfigEditor
tiles={Object.prototype.hasOwnProperty.call(w.config || {}, 'tiles') ? w.config.tiles : undefined}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => {
if (j !== i) return x
const cfg = { ...(x.config || {}) }
if (next === undefined) {
delete cfg.tiles
} else {
cfg.tiles = next
}
return { ...x, config: cfg }
}),
})
)
}
/>
)}
{CHART_DAYS_WIDGET_IDS.has(w.id) && (
<div style={{ marginTop: 10, marginLeft: 28 }}>
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 4 }}>
Zeitraum (Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX}
</label>
<input
type="text"
inputMode="numeric"
autoComplete="off"
className="form-input"
style={{ maxWidth: 120 }}
value={
chartDaysDraftByWidgetId[w.id] !== undefined
? chartDaysDraftByWidgetId[w.id]
: String(chartDaysVal)
}
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
setLayout((L) =>
normalizeLayoutForEditor(commitChartDaysDraftToLayout(raw, L, w.id))
)
setChartDaysDraftByWidgetId((prev) => {
const next = { ...prev }
delete next[w.id]
return next
})
}}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur()
}}
/>
</div>
)}
{w.id === 'body_history_viz' && (
<BodyHistoryVizConfigEditor
config={w.config || {}}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => {
if (j !== i) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
})
)
}
/>
)}
</li>
)
})}
</ul>
</div>
<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={resetToSystem}>
{adminMode ? 'Code-Standard wiederherstellen' : 'System-Standard wiederherstellen'}
</button>
<Link
to={adminMode ? '/admin' : '/'}
className="btn btn-secondary"
style={{ textDecoration: 'none', textAlign: 'center' }}
>
{adminMode ? 'Admin-Übersicht' : 'Zur Übersicht'}
</Link>
</div>
{addPanelOpen && (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 300,
display: 'flex',
alignItems: viewportDesktop ? 'center' : 'flex-end',
justifyContent: 'center',
padding: viewportDesktop ? 16 : 0,
paddingBottom: viewportDesktop ? 16 : undefined,
}}
>
<button
type="button"
aria-label="Schließen"
style={{
position: 'absolute',
inset: 0,
border: 'none',
padding: 0,
margin: 0,
background: 'rgba(0,0,0,0.45)',
cursor: 'pointer',
}}
onClick={() => setAddPanelOpen(false)}
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="dashboard-add-widget-title"
onClick={(e) => e.stopPropagation()}
style={{
position: 'relative',
width: '100%',
maxWidth: viewportDesktop ? 520 : '100%',
maxHeight: viewportDesktop ? 'min(85vh, 640px)' : 'min(92vh, 100%)',
background: 'var(--surface)',
borderRadius: viewportDesktop ? 12 : '16px 16px 0 0',
boxShadow: viewportDesktop ? '0 12px 40px rgba(0,0,0,0.2)' : '0 -4px 24px rgba(0,0,0,0.12)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
paddingBottom: 'max(12px, env(safe-area-inset-bottom))',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '14px 16px',
borderBottom: '1px solid var(--border)',
flexShrink: 0,
}}
>
<h2 id="dashboard-add-widget-title" className="card-title" style={{ fontSize: 16, margin: 0 }}>
Kachel hinzufügen
</h2>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '8px 10px' }}
aria-label="Schließen"
onClick={() => setAddPanelOpen(false)}
>
<X size={20} />
</button>
</div>
<div style={{ padding: '12px 16px', flexShrink: 0, borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Search size={18} color="var(--text3)" />
<input
ref={pickerSearchRef}
type="search"
className="form-input"
placeholder="Suchen nach Titel, Beschreibung, ID …"
value={pickerSearch}
onChange={(e) => setPickerSearch(e.target.value)}
aria-label="Widgets durchsuchen"
style={{ flex: 1 }}
/>
</div>
</div>
<div style={{ overflowY: 'auto', flex: 1, padding: '8px 16px 16px' }}>
{libraryIndices.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text3)', margin: 12 }}>
{pickerLower ? 'Keine Treffer.' : 'Keine weiteren Kacheln verfügbar.'}
</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{libraryIndices.map((i) => {
const w = layout.widgets[i]
const m = metaById[w.id]
return (
<li
key={w.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '12px 0',
borderBottom: '1px solid var(--border)',
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>{m?.title || w.id}</div>
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 2 }}>{m?.description || ''}</div>
</div>
<button
type="button"
className="btn btn-primary"
style={{ flexShrink: 0, fontSize: 12, padding: '8px 14px' }}
onClick={() => {
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => (j === i ? { ...x, enabled: true } : x)),
})
)
}}
>
Hinzufügen
</button>
</li>
)
})}
</ul>
)}
</div>
</div>
</div>
)}
</div>
)
}