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 { moveWidget, moveWidgetToIndex, normalizeLayoutForEditor, toggleWidget, } from '../widgetSystem/layoutEditor' const CHART_DAYS_WIDGET_IDS = new Set([ 'body_overview', '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])) } export default function DashboardConfigurePage() { ensurePilotLabWidgetsRegistered() const [bundle, setBundle] = 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 { const [cat, b] = await Promise.all([api.getAppWidgetsCatalog(), api.getAppDashboardLayout()]) setCatalog(cat) setBundle(b) setChartDaysDraftByWidgetId({}) const base = b.custom ? b.layout : structuredClone(b.product_default_layout) setLayout(normalizeLayoutForEditor(base)) } catch (e) { setErr(formatFastApiDetail(null, e.message)) } }, []) 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 { await api.putAppDashboardLayout(toSave) setMsg('Dein Dashboard wurde gespeichert.') await load() } catch (e) { setErr(formatFastApiDetail(null, e.message)) } finally { setBusy(false) } } const resetToSystem = async () => { if (!window.confirm('Dein individuelles Layout löschen und System-Standard wiederherstellen?')) return setBusy(true) setMsg(null) setErr(null) try { 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 (
{err}
Kacheln für die Startseite sortieren und entfernen. Neue Kacheln über „Kachel hinzufügen“ – mit Suche direkt im eigenen Fenster, ohne langes Scrollen.
{!bundle?.custom && (Du bearbeitest gerade das System-Standardlayout. Mit „Speichern“ legst du deine persönliche Version ab.
)}Alle freigeschalteten Kacheln sind aktiv.
)} {err &&{err}
} {msg &&{msg}
}