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 { ensureDashboardWidgetsRegistered } from '../widgetSystem/registerDashboardWidgets' 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 NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor' import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor' import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor' import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor' 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', 'nutrition_history_viz', 'fitness_history_viz', 'recovery_history_viz', 'history_overview_viz', '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 } = {}) { ensureDashboardWidgetsRegistered() 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 (
{err}
{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.'}
{adminMode && adminFromDatabase != null && ({adminFromDatabase ? ( <> Aktuell gilt ein gespeicherter Systemstandard (Datenbank). > ) : ( <> Es liegt kein DB-Override vor — es wird der Code-Standard aus dem Widget-Katalog verwendet. > )}
)} {!adminMode && !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}
}