feat: Enhance dashboard layout and widget configuration
- Updated dashboard layout schema to introduce separate default layouts for product and lab dashboards. - Added new functions for managing product and lab default layouts, improving user customization options. - Updated app_dashboard version to 1.9.0 to reflect the introduction of product vs lab layout defaults and new API fields for dashboard configuration. - Enhanced tests to validate new layout functionalities and ensure proper widget visibility based on user settings.
This commit is contained in:
parent
9bc0cf70da
commit
e4e2f23d7f
|
|
@ -1,7 +1,7 @@
|
||||||
"""
|
"""
|
||||||
Dashboard-Layout v1 (Nutzer-Lab): Validierung und Standard-Layout.
|
Dashboard-Layout v1: Validierung, Produkt-Standard (Übersicht) und Lab-Standard.
|
||||||
|
|
||||||
Erlaubte Widget-IDs und Standard-Reihenfolge: widget_catalog.WIDGET_CATALOG
|
Erlaubte Widget-IDs und Reihenfolge: widget_catalog.WIDGET_CATALOG.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -10,7 +10,12 @@ from typing import Any, Literal
|
||||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
|
|
||||||
from dashboard_widget_config import validate_widget_entry_config
|
from dashboard_widget_config import validate_widget_entry_config
|
||||||
from widget_catalog import ALLOWED_WIDGET_IDS, DEFAULT_LAB_WIDGET_IDS, WIDGET_CATALOG
|
from widget_catalog import (
|
||||||
|
ALLOWED_WIDGET_IDS,
|
||||||
|
DEFAULT_LAB_WIDGET_IDS,
|
||||||
|
DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS,
|
||||||
|
WIDGET_CATALOG,
|
||||||
|
)
|
||||||
|
|
||||||
# Abwärtskompatibel (Tests importieren weiterhin aus diesem Modul)
|
# Abwärtskompatibel (Tests importieren weiterhin aus diesem Modul)
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|
@ -19,10 +24,13 @@ __all__ = [
|
||||||
"DashboardWidgetEntry",
|
"DashboardWidgetEntry",
|
||||||
"coalesce_effective_layout",
|
"coalesce_effective_layout",
|
||||||
"default_layout_dict",
|
"default_layout_dict",
|
||||||
|
"lab_default_layout_dict",
|
||||||
|
"product_default_layout_dict",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def default_layout_dict() -> dict[str, Any]:
|
def lab_default_layout_dict() -> dict[str, Any]:
|
||||||
|
"""Standard für Dashboard-Lab (Experimentier-Widgets)."""
|
||||||
on = DEFAULT_LAB_WIDGET_IDS
|
on = DEFAULT_LAB_WIDGET_IDS
|
||||||
return {
|
return {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
|
|
@ -30,6 +38,20 @@ def default_layout_dict() -> dict[str, Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def product_default_layout_dict() -> dict[str, Any]:
|
||||||
|
"""System-Standard für die Produkt-Übersicht (kein DB-Override)."""
|
||||||
|
on = DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS
|
||||||
|
return {
|
||||||
|
"version": 1,
|
||||||
|
"widgets": [{"id": e["id"], "enabled": e["id"] in on} for e in WIDGET_CATALOG],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def default_layout_dict() -> dict[str, Any]:
|
||||||
|
"""Alias: Produkt-Standard (coalesce, Reset). Lab nutzt lab_default_layout_dict()."""
|
||||||
|
return product_default_layout_dict()
|
||||||
|
|
||||||
|
|
||||||
class DashboardWidgetEntry(BaseModel):
|
class DashboardWidgetEntry(BaseModel):
|
||||||
id: str = Field(min_length=1, max_length=64)
|
id: str = Field(min_length=1, max_length=64)
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,12 @@ from fastapi import APIRouter, Depends, Header, HTTPException
|
||||||
from psycopg2.extras import Json
|
from psycopg2.extras import Json
|
||||||
|
|
||||||
from auth import require_auth
|
from auth import require_auth
|
||||||
from dashboard_layout_schema import DashboardLayoutPayload, coalesce_effective_layout, default_layout_dict
|
from dashboard_layout_schema import (
|
||||||
|
DashboardLayoutPayload,
|
||||||
|
coalesce_effective_layout,
|
||||||
|
lab_default_layout_dict,
|
||||||
|
product_default_layout_dict,
|
||||||
|
)
|
||||||
from dashboard_widget_entitlements import apply_entitlements_to_layout_dict, widgets_catalog_payload
|
from dashboard_widget_entitlements import apply_entitlements_to_layout_dict, widgets_catalog_payload
|
||||||
from db import get_cursor, get_db
|
from db import get_cursor, get_db
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
|
|
@ -47,11 +52,13 @@ def get_dashboard_layout(
|
||||||
custom, effective = coalesce_effective_layout(raw)
|
custom, effective = coalesce_effective_layout(raw)
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
effective = apply_entitlements_to_layout_dict(effective, pid, conn)
|
effective = apply_entitlements_to_layout_dict(effective, pid, conn)
|
||||||
default_adj = apply_entitlements_to_layout_dict(default_layout_dict(), pid, conn)
|
product_adj = apply_entitlements_to_layout_dict(product_default_layout_dict(), pid, conn)
|
||||||
|
lab_adj = apply_entitlements_to_layout_dict(lab_default_layout_dict(), pid, conn)
|
||||||
return {
|
return {
|
||||||
"custom": custom,
|
"custom": custom,
|
||||||
"layout": effective,
|
"layout": effective,
|
||||||
"default_layout": default_adj,
|
"product_default_layout": product_adj,
|
||||||
|
"lab_default_layout": lab_adj,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -100,4 +107,5 @@ def reset_dashboard_layout(
|
||||||
)
|
)
|
||||||
if cur.rowcount == 0:
|
if cur.rowcount == 0:
|
||||||
raise HTTPException(404, "Profil nicht gefunden")
|
raise HTTPException(404, "Profil nicht gefunden")
|
||||||
return {"ok": True, "layout": default_layout_dict()}
|
cleared = apply_entitlements_to_layout_dict(product_default_layout_dict(), pid, conn)
|
||||||
|
return {"ok": True, "layout": cleared}
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,14 @@ from dashboard_layout_schema import (
|
||||||
coalesce_effective_layout,
|
coalesce_effective_layout,
|
||||||
default_layout_dict,
|
default_layout_dict,
|
||||||
)
|
)
|
||||||
|
from widget_catalog import DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS
|
||||||
|
|
||||||
|
|
||||||
def test_default_has_all_allowed_ids():
|
def test_default_has_all_allowed_ids():
|
||||||
d = default_layout_dict()
|
d = default_layout_dict()
|
||||||
got = {w["id"] for w in d["widgets"]}
|
got = {w["id"] for w in d["widgets"]}
|
||||||
assert got == ALLOWED_WIDGET_IDS
|
assert got == ALLOWED_WIDGET_IDS
|
||||||
|
assert {w["id"] for w in d["widgets"] if w["enabled"]} == DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS
|
||||||
|
|
||||||
|
|
||||||
def test_payload_rejects_duplicate_ids():
|
def test_payload_rejects_duplicate_ids():
|
||||||
|
|
@ -32,7 +34,7 @@ def test_payload_requires_one_enabled():
|
||||||
DashboardLayoutPayload.model_validate(
|
DashboardLayoutPayload.model_validate(
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"widgets": [{"id": "welcome", "enabled": False}],
|
"widgets": [{"id": "dashboard_greeting", "enabled": False}],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,12 @@
|
||||||
|
|
||||||
from dashboard_layout_schema import default_layout_dict
|
from dashboard_layout_schema import default_layout_dict
|
||||||
from dashboard_widget_entitlements import widgets_catalog_payload
|
from dashboard_widget_entitlements import widgets_catalog_payload
|
||||||
from widget_catalog import ALLOWED_WIDGET_IDS, DEFAULT_LAB_WIDGET_IDS, WIDGET_CATALOG
|
from widget_catalog import (
|
||||||
|
ALLOWED_WIDGET_IDS,
|
||||||
|
DEFAULT_LAB_WIDGET_IDS,
|
||||||
|
DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS,
|
||||||
|
WIDGET_CATALOG,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_catalog_ids_unique_and_match_allowed():
|
def test_catalog_ids_unique_and_match_allowed():
|
||||||
|
|
@ -17,10 +22,17 @@ def test_default_layout_follows_catalog_order():
|
||||||
got = [w["id"] for w in d["widgets"]]
|
got = [w["id"] for w in d["widgets"]]
|
||||||
assert got == [e["id"] for e in WIDGET_CATALOG]
|
assert got == [e["id"] for e in WIDGET_CATALOG]
|
||||||
enabled_ids = {w["id"] for w in d["widgets"] if w["enabled"]}
|
enabled_ids = {w["id"] for w in d["widgets"] if w["enabled"]}
|
||||||
assert enabled_ids == DEFAULT_LAB_WIDGET_IDS
|
assert enabled_ids == DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS
|
||||||
assert any(w["enabled"] for w in d["widgets"])
|
assert any(w["enabled"] for w in d["widgets"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_lab_default_matches_lab_widget_ids():
|
||||||
|
from dashboard_layout_schema import lab_default_layout_dict
|
||||||
|
|
||||||
|
d = lab_default_layout_dict()
|
||||||
|
assert {w["id"] for w in d["widgets"] if w["enabled"]} == DEFAULT_LAB_WIDGET_IDS
|
||||||
|
|
||||||
|
|
||||||
def test_catalog_payload_shape(monkeypatch):
|
def test_catalog_payload_shape(monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"dashboard_widget_entitlements._check_feature_access",
|
"dashboard_widget_entitlements._check_feature_access",
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ MODULE_VERSIONS = {
|
||||||
"importdata": "1.0.0",
|
"importdata": "1.0.0",
|
||||||
"membership": "2.1.0",
|
"membership": "2.1.0",
|
||||||
"workflow": "0.6.0", # Phase 4: End Node Template Engine
|
"workflow": "0.6.0", # Phase 4: End Node Template Engine
|
||||||
"app_dashboard": "1.8.0", # widget catalog allowed via features; layout entitlements on GET/PUT
|
"app_dashboard": "1.9.0", # product vs lab layout defaults; user dashboard configure page API fields
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
|
|
||||||
|
|
@ -133,4 +133,21 @@ DEFAULT_LAB_WIDGET_IDS: frozenset[str] = frozenset(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Produkt-Übersicht (/): Default wenn Nutzer kein dashboard_layout in der DB hat (Physisch: nur Profil-JSON).
|
||||||
|
DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"dashboard_greeting",
|
||||||
|
"quick_weight_today",
|
||||||
|
"body_stat_strip",
|
||||||
|
"status_pills",
|
||||||
|
"trend_kcal_weight",
|
||||||
|
"nutrition_activity_summary",
|
||||||
|
"activity_overview",
|
||||||
|
"recovery_sleep_rest",
|
||||||
|
"goals_focus_teaser",
|
||||||
|
"profile_goals_progress",
|
||||||
|
"ai_pipeline_insight",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
ALLOWED_WIDGET_IDS: frozenset[str] = frozenset(e["id"] for e in WIDGET_CATALOG)
|
ALLOWED_WIDGET_IDS: frozenset[str] = frozenset(e["id"] for e in WIDGET_CATALOG)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import SettingsShell from './layouts/SettingsShell'
|
||||||
import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage'
|
import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage'
|
||||||
import PilotVizPage from './pages/PilotVizPage'
|
import PilotVizPage from './pages/PilotVizPage'
|
||||||
import DashboardLabPage from './pages/DashboardLabPage'
|
import DashboardLabPage from './pages/DashboardLabPage'
|
||||||
|
import DashboardConfigurePage from './pages/DashboardConfigurePage'
|
||||||
import GuidePage from './pages/GuidePage'
|
import GuidePage from './pages/GuidePage'
|
||||||
import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
|
import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
|
||||||
import AdminFeaturesPage from './pages/AdminFeaturesPage'
|
import AdminFeaturesPage from './pages/AdminFeaturesPage'
|
||||||
|
|
@ -232,6 +233,7 @@ function AppShell() {
|
||||||
<Route path="/settings" element={<SettingsShell />}>
|
<Route path="/settings" element={<SettingsShell />}>
|
||||||
<Route index element={<SettingsPage />} />
|
<Route index element={<SettingsPage />} />
|
||||||
<Route path="reference-values" element={<ProfileReferenceValuesPage />} />
|
<Route path="reference-values" element={<ProfileReferenceValuesPage />} />
|
||||||
|
<Route path="dashboard-layout" element={<DashboardConfigurePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<RequireAdmin />}>
|
<Route element={<RequireAdmin />}>
|
||||||
<Route path="admin" element={<AdminShell />}>
|
<Route path="admin" element={<AdminShell />}>
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,6 @@
|
||||||
|
|
||||||
export const SETTINGS_SHELL_NAV_ITEMS = [
|
export const SETTINGS_SHELL_NAV_ITEMS = [
|
||||||
{ id: 'general', label: 'Allgemein', to: '/settings', end: true },
|
{ id: 'general', label: 'Allgemein', to: '/settings', end: true },
|
||||||
|
{ id: 'dashboard-layout', label: 'Übersicht', to: '/settings/dashboard-layout' },
|
||||||
{ id: 'reference-values', label: 'Referenzwerte', to: '/settings/reference-values' },
|
{ id: 'reference-values', label: 'Referenzwerte', to: '/settings/reference-values' },
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,82 +1,56 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { Brain } from 'lucide-react'
|
import { LayoutDashboard } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import { useProfile } from '../context/ProfileContext'
|
import { useProfile } from '../context/ProfileContext'
|
||||||
import { getBfCategory } from '../utils/calc'
|
|
||||||
import TrialBanner from '../components/TrialBanner'
|
import TrialBanner from '../components/TrialBanner'
|
||||||
import EmailVerificationBanner from '../components/EmailVerificationBanner'
|
import EmailVerificationBanner from '../components/EmailVerificationBanner'
|
||||||
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
|
||||||
import SleepWidget from '../components/SleepWidget'
|
import { WidgetRenderer } from '../widgetSystem/dashboardWidgetRegistry'
|
||||||
import RestDaysWidget from '../components/RestDaysWidget'
|
|
||||||
import Markdown from '../utils/Markdown'
|
function catalogMetaById(catalog) {
|
||||||
import QuickWeightEntry from '../components/QuickWeightEntry'
|
if (!catalog?.widgets?.length) return {}
|
||||||
import TrendKcalWeightChart from '../components/TrendKcalWeightChart'
|
return Object.fromEntries(catalog.widgets.map((w) => [w.id, w]))
|
||||||
import { Pill, StatCard } from '../components/DashboardStatKit'
|
}
|
||||||
import dayjs from 'dayjs'
|
|
||||||
import 'dayjs/locale/de'
|
|
||||||
import DashboardSection from '../components/DashboardSection'
|
|
||||||
import DashboardTile from '../components/DashboardTile'
|
|
||||||
import {
|
|
||||||
DASHBOARD_TILE_GRID_COLS,
|
|
||||||
dashboardStatGridClassName,
|
|
||||||
dashboardTileGridClassName
|
|
||||||
} from '../utils/dashboardLayout'
|
|
||||||
dayjs.locale('de')
|
|
||||||
|
|
||||||
// ── Main Dashboard ────────────────────────────────────────────────────────────
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const nav = useNavigate()
|
const nav = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { activeProfile } = useProfile()
|
const { activeProfile } = useProfile()
|
||||||
|
|
||||||
const [adminDeniedHint, setAdminDeniedHint] = useState(false)
|
const [adminDeniedHint, setAdminDeniedHint] = useState(false)
|
||||||
const [goalsCount, setGoalsCount] = useState(null)
|
const [layoutBundle, setLayoutBundle] = useState(null)
|
||||||
|
const [catalog, setCatalog] = useState(null)
|
||||||
|
const [layoutLoading, setLayoutLoading] = useState(true)
|
||||||
|
const [refreshTick, setRefreshTick] = useState(0)
|
||||||
|
|
||||||
const [stats, setStats] = useState(null)
|
const requestRefresh = () => setRefreshTick((t) => t + 1)
|
||||||
const [weights, setWeights] = useState([])
|
|
||||||
const [calipers, setCalipers] = useState([])
|
|
||||||
const [circs, setCircs] = useState([])
|
|
||||||
const [nutrition, setNutrition] = useState([])
|
|
||||||
const [activities,setActivities]= useState([])
|
|
||||||
const [insights, setInsights] = useState([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [showInsight, setShowInsight] = useState(false)
|
|
||||||
const [pipelineLoading, setPipelineLoading] = useState(false)
|
|
||||||
const [pipelineError, setPipelineError] = useState(null)
|
|
||||||
|
|
||||||
const load = () => Promise.all([
|
useEffect(() => {
|
||||||
api.getStats(),
|
ensurePilotLabWidgetsRegistered()
|
||||||
api.listWeight(60),
|
}, [])
|
||||||
api.listCaliper(3),
|
|
||||||
api.listCirc(2),
|
|
||||||
api.listNutrition(30),
|
|
||||||
api.listActivity(800, 30),
|
|
||||||
api.latestInsights(),
|
|
||||||
]).then(([s,w,ca,ci,n,a,ins])=>{
|
|
||||||
setStats(s); setWeights(w); setCalipers(ca); setCircs(ci)
|
|
||||||
setNutrition(n); setActivities(a)
|
|
||||||
setInsights(Array.isArray(ins)?ins:[])
|
|
||||||
setLoading(false)
|
|
||||||
}).catch(err => {
|
|
||||||
console.error('Dashboard load failed:', err)
|
|
||||||
// Set empty data on error so UI can still render
|
|
||||||
setStats(null); setWeights([]); setCalipers([]); setCircs([])
|
|
||||||
setNutrition([]); setActivities([]); setInsights([])
|
|
||||||
setLoading(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
const runPipeline = async () => {
|
useEffect(() => {
|
||||||
setPipelineLoading(true); setPipelineError(null)
|
let cancel = false
|
||||||
try {
|
setLayoutLoading(true)
|
||||||
await api.insightPipeline()
|
Promise.all([api.getAppDashboardLayout(), api.getAppWidgetsCatalog()])
|
||||||
await load()
|
.then(([b, c]) => {
|
||||||
} catch(e) {
|
if (cancel) return
|
||||||
setPipelineError('Fehler: '+e.message)
|
setLayoutBundle(b)
|
||||||
} finally { setPipelineLoading(false) }
|
setCatalog(c)
|
||||||
}
|
})
|
||||||
|
.catch(() => {
|
||||||
useEffect(()=>{ load() },[])
|
if (cancel) return
|
||||||
|
setLayoutBundle(null)
|
||||||
|
setCatalog(null)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancel) setLayoutLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancel = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!location.state?.adminDenied) return
|
if (!location.state?.adminDenied) return
|
||||||
|
|
@ -86,60 +60,19 @@ export default function Dashboard() {
|
||||||
return () => window.clearTimeout(clear)
|
return () => window.clearTimeout(clear)
|
||||||
}, [location.state, nav])
|
}, [location.state, nav])
|
||||||
|
|
||||||
useEffect(() => {
|
const metaById = useMemo(() => catalogMetaById(catalog), [catalog])
|
||||||
if (!activeProfile?.id) return
|
|
||||||
api.listGoals()
|
|
||||||
.then((list) => setGoalsCount(Array.isArray(list) ? list.length : 0))
|
|
||||||
.catch(() => setGoalsCount(null))
|
|
||||||
}, [activeProfile?.id])
|
|
||||||
|
|
||||||
if (loading) return <div className="empty-state"><div className="spinner"/></div>
|
const layoutForPreview = useMemo(() => {
|
||||||
|
if (!layoutBundle?.layout) return null
|
||||||
const latestCal = calipers[0]
|
const L = layoutBundle.layout
|
||||||
const latestCir = circs[0]
|
return {
|
||||||
const latestW = weights[0]
|
...L,
|
||||||
const prevW = weights[1]
|
widgets: L.widgets.map((w) => ({
|
||||||
const sex = activeProfile?.sex||'m'
|
...w,
|
||||||
const height = activeProfile?.height||178
|
enabled: w.enabled && metaById[w.id]?.allowed !== false,
|
||||||
|
})),
|
||||||
// Deltas
|
}
|
||||||
const wDelta = latestW&&prevW ? Math.round((latestW.weight-prevW.weight)*10)/10 : null
|
}, [layoutBundle, metaById])
|
||||||
const bfCat = latestCal?.body_fat_pct ? getBfCategory(latestCal.body_fat_pct,sex) : null
|
|
||||||
const bfPrev = calipers[1]?.body_fat_pct
|
|
||||||
const bfDelta = latestCal?.body_fat_pct&&bfPrev ? Math.round((latestCal.body_fat_pct-bfPrev)*10)/10 : null
|
|
||||||
|
|
||||||
// WHR / WHtR
|
|
||||||
const whr = latestCir?.c_waist&&latestCir?.c_hip ? Math.round(latestCir.c_waist/latestCir.c_hip*100)/100 : null
|
|
||||||
const whtr = latestCir?.c_waist&&height ? Math.round(latestCir.c_waist/height*100)/100 : null
|
|
||||||
|
|
||||||
// Nutrition averages (last 7 days)
|
|
||||||
const recentNutr = nutrition.filter(n=>n.date>=dayjs().subtract(7,'day').format('YYYY-MM-DD'))
|
|
||||||
const avgKcal = recentNutr.length ? Math.round(recentNutr.reduce((s,n)=>s+(n.kcal||0),0)/recentNutr.length) : null
|
|
||||||
const avgProtein = recentNutr.length ? Math.round(recentNutr.reduce((s,n)=>s+(n.protein_g||0),0)/recentNutr.length*10)/10 : null
|
|
||||||
const ptLow = Math.round((latestW?.weight||80)*1.6)
|
|
||||||
const proteinOk = avgProtein && avgProtein >= ptLow
|
|
||||||
|
|
||||||
// Activity (last 7 days)
|
|
||||||
const recentAct = activities.filter(a=>a.date>=dayjs().subtract(7,'day').format('YYYY-MM-DD'))
|
|
||||||
const actKcal = recentAct.length ? Math.round(recentAct.reduce((s,a)=>s+(a.kcal_active||0),0)) : null
|
|
||||||
|
|
||||||
// Status pills
|
|
||||||
const pills = []
|
|
||||||
if (whr) pills.push({label:'WHR', value:whr, status:whr<(sex==='m'?0.90:0.85)?'good':'warn', sub:`<${sex==='m'?'0,90':'0,85'}`})
|
|
||||||
if (whtr) pills.push({label:'WHtR', value:whtr, status:whtr<0.5?'good':'warn', sub:'<0,50'})
|
|
||||||
if (avgProtein) pills.push({label:'Protein Ø7T', value:avgProtein+'g', status:proteinOk?'good':'warn', sub:`Ziel ${ptLow}g`})
|
|
||||||
if (bfCat) pills.push({label:'KF', value:latestCal.body_fat_pct+'%', status:latestCal.body_fat_pct<(sex==='m'?18:25)?'good':'warn', sub:bfCat.label})
|
|
||||||
|
|
||||||
// Latest overall insight
|
|
||||||
const latestInsight = insights.find(i=>i.scope==='gesamt')||insights[0]
|
|
||||||
|
|
||||||
const hasAnyData = latestW||latestCal||nutrition.length>0
|
|
||||||
|
|
||||||
const showNutrSummary = !!(avgKcal || avgProtein)
|
|
||||||
const showActSummary = actKcal != null
|
|
||||||
const summaryBoth = showNutrSummary && showActSummary
|
|
||||||
const summarySpanM = summaryBoth ? 1 : 2
|
|
||||||
const summarySpanD = summaryBoth ? 2 : 4
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard-page">
|
<div className="dashboard-page">
|
||||||
|
|
@ -157,296 +90,41 @@ export default function Dashboard() {
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong>Kein Admin-Zugriff.</strong> Dieser Bereich ist nur für Konten mit Administrator-Rolle.
|
<strong>Kein Admin-Zugriff.</strong> Dieser Bereich ist nur für Konten mit Administrator-Rolle. Du wurdest zur
|
||||||
Du wurdest zur Übersicht weitergeleitet.
|
Übersicht weitergeleitet.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Header greeting */}
|
|
||||||
<div className="dashboard-greeting">
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 10 }}>
|
||||||
<h1 style={{fontSize:22,fontWeight:800,margin:0,color:'var(--text1)'}}>
|
<Link
|
||||||
Hallo, {activeProfile?.name||'Nutzer'} 👋
|
to="/settings/dashboard-layout"
|
||||||
</h1>
|
className="btn btn-secondary"
|
||||||
<div className="dashboard-greeting__meta" style={{fontSize:12,color:'var(--text3)',marginTop:2}}>
|
style={{
|
||||||
{dayjs().format('dddd, DD. MMMM YYYY')}
|
fontSize: 12,
|
||||||
{latestW && ` · Letztes Update ${dayjs(latestW.date).format('DD.MM.')}`}
|
padding: '8px 12px',
|
||||||
</div>
|
textDecoration: 'none',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LayoutDashboard size={16} />
|
||||||
|
Übersicht anpassen
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email Verification Banner */}
|
{activeProfile && <EmailVerificationBanner profile={activeProfile} />}
|
||||||
{activeProfile && <EmailVerificationBanner profile={activeProfile}/>}
|
{activeProfile && <TrialBanner profile={activeProfile} />}
|
||||||
|
|
||||||
{/* Trial Banner */}
|
{layoutLoading && (
|
||||||
{activeProfile && <TrialBanner profile={activeProfile}/>}
|
|
||||||
|
|
||||||
{!hasAnyData && (
|
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<h3>Willkommen bei Mitai Jinkendo!</h3>
|
<div className="spinner" />
|
||||||
<p>Starte mit deiner ersten Messung.</p>
|
|
||||||
<button className="btn btn-primary" onClick={()=>nav('/capture')}>
|
|
||||||
Erfassen starten
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasAnyData && <>
|
{!layoutLoading && layoutForPreview && (
|
||||||
<DashboardSection
|
<WidgetRenderer layout={layoutForPreview} refreshTick={refreshTick} requestRefresh={requestRefresh} />
|
||||||
title="Gewicht heute"
|
)}
|
||||||
description="Tageswert erfassen – Grundlage für Trends und Ziele."
|
|
||||||
headerRight={
|
|
||||||
<button type="button" className="btn btn-secondary"
|
|
||||||
style={{ fontSize: 12, padding: '6px 12px' }}
|
|
||||||
onClick={() => nav('/weight')}>
|
|
||||||
Alle Einträge →
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="card section-gap">
|
|
||||||
<QuickWeightEntry onSaved={load} />
|
|
||||||
</div>
|
|
||||||
</DashboardSection>
|
|
||||||
|
|
||||||
<DashboardSection
|
|
||||||
title="Kennzahlen"
|
|
||||||
description="Aktuelle Messwerte und Ernährungs-Schnitt (7 Tage)."
|
|
||||||
>
|
|
||||||
<div className={dashboardStatGridClassName(DASHBOARD_TILE_GRID_COLS.mobile)}>
|
|
||||||
<StatCard icon="⚖️" label="Gewicht" value={latestW?.weight??'–'} unit="kg"
|
|
||||||
delta={wDelta} deltaGoodWhenNeg={true}
|
|
||||||
sub={latestW ? dayjs(latestW.date).format('DD.MM.') : '–'}
|
|
||||||
onClick={()=>nav('/history')} color="#378ADD"/>
|
|
||||||
{latestCal?.body_fat_pct && <StatCard icon="🫧" label="Körperfett" value={latestCal.body_fat_pct} unit="%"
|
|
||||||
delta={bfDelta} deltaGoodWhenNeg={true}
|
|
||||||
sub={bfCat?.label}
|
|
||||||
onClick={()=>nav('/history',{state:{tab:'body'}})} color={bfCat?.color}/>}
|
|
||||||
{latestCal?.lean_mass && <StatCard icon="💪" label="Magermasse" value={latestCal.lean_mass} unit="kg"
|
|
||||||
sub={latestCal.date ? dayjs(latestCal.date).format('DD.MM.') : '–'}
|
|
||||||
onClick={()=>nav('/history',{state:{tab:'body'}})}/>}
|
|
||||||
{avgKcal && <StatCard icon="🍽️" label="Ø Kalorien" value={avgKcal} unit="kcal"
|
|
||||||
sub="letzte 7 Tage" onClick={()=>nav('/history',{state:{tab:'nutrition'}})} color="#EF9F27"/>}
|
|
||||||
</div>
|
|
||||||
{pills.length > 0 && (
|
|
||||||
<div className="dashboard-pill-row">
|
|
||||||
{pills.map((p,i)=><Pill key={i} {...p}/>)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DashboardSection>
|
|
||||||
|
|
||||||
{(activeProfile?.goal_weight||activeProfile?.goal_bf_pct) && latestW && (
|
|
||||||
<DashboardSection
|
|
||||||
title="Profil-Ziele"
|
|
||||||
description="Fortschritt zu den Zielwerten in deinem Profil."
|
|
||||||
>
|
|
||||||
<div className="card section-gap">
|
|
||||||
{activeProfile?.goal_weight && latestW && (()=>{
|
|
||||||
const start = Math.max(...weights.map(w=>w.weight))
|
|
||||||
const curr = latestW.weight
|
|
||||||
const goal = activeProfile.goal_weight
|
|
||||||
const total = start - goal
|
|
||||||
const done = start - curr
|
|
||||||
const pct = total > 0 ? Math.min(100, Math.round(done/total*100)) : 100
|
|
||||||
const remain = Math.round((curr-goal)*10)/10
|
|
||||||
return (
|
|
||||||
<div style={{marginBottom:10}}>
|
|
||||||
<div style={{display:'flex',justifyContent:'space-between',fontSize:12,marginBottom:4}}>
|
|
||||||
<span>Gewicht: {curr} → {goal} kg</span>
|
|
||||||
<span style={{color:'var(--accent)',fontWeight:600}}>{remain>0?`noch ${remain}kg`:'Ziel erreicht! 🎉'}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{height:8,background:'var(--border)',borderRadius:4,overflow:'hidden'}}>
|
|
||||||
<div style={{height:'100%',width:`${pct}%`,background:'var(--accent)',borderRadius:4,transition:'width 0.5s'}}/>
|
|
||||||
</div>
|
|
||||||
<div style={{fontSize:10,color:'var(--text3)',marginTop:2}}>{pct}% des Weges</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
{activeProfile?.goal_bf_pct && latestCal?.body_fat_pct && (()=>{
|
|
||||||
const curr = latestCal.body_fat_pct
|
|
||||||
const goal = activeProfile.goal_bf_pct
|
|
||||||
const remain= Math.round((curr-goal)*10)/10
|
|
||||||
const pct = curr<=goal ? 100 : Math.min(100,Math.round((1-(curr-goal)/Math.max(curr-goal,5))*100))
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div style={{display:'flex',justifyContent:'space-between',fontSize:12,marginBottom:4}}>
|
|
||||||
<span>Körperfett: {curr}% → {goal}%</span>
|
|
||||||
<span style={{color:'var(--accent)',fontWeight:600}}>{remain>0?`noch ${remain}%`:'Ziel erreicht! 🎉'}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{height:8,background:'var(--border)',borderRadius:4,overflow:'hidden'}}>
|
|
||||||
<div style={{height:'100%',width:`${pct}%`,background:bfCat?.color||'var(--accent)',borderRadius:4}}/>
|
|
||||||
</div>
|
|
||||||
<div style={{fontSize:10,color:'var(--text3)',marginTop:2}}>Aktuell: {bfCat?.label}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</DashboardSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(weights.length>2||nutrition.length>2) && (
|
|
||||||
<DashboardSection
|
|
||||||
title="Trends"
|
|
||||||
description="Kalorien und Gewicht der letzten 30 Tage."
|
|
||||||
headerRight={
|
|
||||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }}
|
|
||||||
onClick={()=>nav('/history',{state:{tab:'body'}})}>
|
|
||||||
Details →
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DashboardTile>
|
|
||||||
<div className="card section-gap">
|
|
||||||
<TrendKcalWeightChart weights={weights} nutrition={nutrition} windowDays={30} />
|
|
||||||
<div style={{display:'flex',gap:16,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)'}}>
|
|
||||||
<span><span style={{display:'inline-block',width:12,height:2,background:'#EF9F27',verticalAlign:'middle',marginRight:3}}/>Ø Kalorien</span>
|
|
||||||
<span><span style={{display:'inline-block',width:12,height:2,background:'#378ADD',verticalAlign:'middle',marginRight:3}}/>Gewicht</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DashboardTile>
|
|
||||||
</DashboardSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(showNutrSummary || showActSummary) && (
|
|
||||||
<DashboardSection
|
|
||||||
title="Ernährung & Aktivität"
|
|
||||||
description="Kurzüberblick; volle Verläufe unter Historie."
|
|
||||||
>
|
|
||||||
<div className={`dashboard-summary-row ${dashboardTileGridClassName(DASHBOARD_TILE_GRID_COLS.mobile)}`}>
|
|
||||||
{showNutrSummary && (
|
|
||||||
<DashboardTile spanMobile={summarySpanM} spanDesktop={summarySpanD}>
|
|
||||||
<div className="card" style={{ cursor: 'pointer', height: '100%' }} onClick={()=>nav('/history',{state:{tab:'nutrition'}})}>
|
|
||||||
<div style={{fontWeight:600,fontSize:12,marginBottom:8,color:'var(--text3)'}}>🍽️ ERNÄHRUNG (Ø 7T)</div>
|
|
||||||
{avgKcal && <div style={{fontSize:16,fontWeight:700,color:'#EF9F27'}}>{avgKcal} kcal</div>}
|
|
||||||
{avgProtein && <div style={{fontSize:13,fontWeight:600,
|
|
||||||
color:proteinOk?'var(--accent)':'var(--warn)'}}>
|
|
||||||
{avgProtein}g Protein {proteinOk?'✓':'⚠️'}
|
|
||||||
</div>}
|
|
||||||
<div style={{fontSize:10,color:'var(--text3)',marginTop:4}}>→ Verlauf Ernährung</div>
|
|
||||||
</div>
|
|
||||||
</DashboardTile>
|
|
||||||
)}
|
|
||||||
{showActSummary && (
|
|
||||||
<DashboardTile spanMobile={summarySpanM} spanDesktop={summarySpanD}>
|
|
||||||
<div className="card" style={{ cursor: 'pointer', height: '100%' }} onClick={()=>nav('/history',{state:{tab:'activity'}})}>
|
|
||||||
<div style={{fontWeight:600,fontSize:12,marginBottom:8,color:'var(--text3)'}}>🏋️ AKTIVITÄT (7T)</div>
|
|
||||||
<div style={{fontSize:16,fontWeight:700,color:'#EF9F27'}}>{actKcal} kcal</div>
|
|
||||||
<div style={{fontSize:13,color:'var(--text2)'}}>{recentAct.length} Trainings</div>
|
|
||||||
<div style={{fontSize:10,color:'var(--text3)',marginTop:4}}>→ Verlauf Aktivität</div>
|
|
||||||
</div>
|
|
||||||
</DashboardTile>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DashboardSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DashboardSection
|
|
||||||
title="Erholung"
|
|
||||||
description="Schlaf und Ruhetage im Überblick."
|
|
||||||
>
|
|
||||||
<div className={`dashboard-erholung-grid ${dashboardTileGridClassName(DASHBOARD_TILE_GRID_COLS.mobile)}`}>
|
|
||||||
<DashboardTile spanMobile={1} spanDesktop={2}>
|
|
||||||
<SleepWidget/>
|
|
||||||
</DashboardTile>
|
|
||||||
<DashboardTile spanMobile={1} spanDesktop={2}>
|
|
||||||
<RestDaysWidget/>
|
|
||||||
</DashboardTile>
|
|
||||||
</div>
|
|
||||||
</DashboardSection>
|
|
||||||
|
|
||||||
{activities.length > 0 && (
|
|
||||||
<DashboardSection
|
|
||||||
title="Training"
|
|
||||||
description="Verteilung der Trainingstypen (28 Tage)."
|
|
||||||
headerRight={
|
|
||||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }}
|
|
||||||
onClick={()=>nav('/activity')}>
|
|
||||||
Details →
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DashboardTile>
|
|
||||||
<div className="card section-gap">
|
|
||||||
<TrainingTypeDistribution days={28} />
|
|
||||||
</div>
|
|
||||||
</DashboardTile>
|
|
||||||
</DashboardSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DashboardSection
|
|
||||||
title="Ziele & Fokus"
|
|
||||||
description="Strategische Ziele und Schwerpunkte – eigener Menüpunkt „Ziele“, Kontext für KI und Dashboard."
|
|
||||||
headerRight={
|
|
||||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }}
|
|
||||||
onClick={(e)=>{ e.stopPropagation(); nav('/goals') }}>
|
|
||||||
Ziele bearbeiten →
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DashboardTile>
|
|
||||||
<div className="card section-gap" style={{ cursor: 'pointer' }} onClick={()=>nav('/goals')}>
|
|
||||||
{goalsCount != null && (
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text1)', marginBottom: 8 }}>
|
|
||||||
{goalsCount === 0
|
|
||||||
? 'Noch keine Ziele angelegt.'
|
|
||||||
: `${goalsCount} ${goalsCount === 1 ? 'Ziel' : 'Ziele'} im System.`}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text2)', padding: goalsCount != null ? '0 0 8px' : '8px 0' }}>
|
|
||||||
Hier pflegst du Focus Areas, Meilensteine und Fortschritt – unabhängig von der KI-Analyse-Seite.
|
|
||||||
Tippen zum Öffnen oder unten in der Navigation <strong>Ziele</strong> wählen.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DashboardTile>
|
|
||||||
</DashboardSection>
|
|
||||||
|
|
||||||
<DashboardSection
|
|
||||||
title="KI-Auswertung"
|
|
||||||
description="Mehrstufige Pipeline und letzte Zusammenfassung."
|
|
||||||
headerRight={
|
|
||||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '4px 10px' }}
|
|
||||||
onClick={()=>nav('/analysis')}>
|
|
||||||
<Brain size={11}/> Analysen →
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DashboardTile>
|
|
||||||
<div className="card section-gap">
|
|
||||||
<button type="button" className="btn btn-primary btn-full" style={{marginBottom:10}}
|
|
||||||
onClick={runPipeline} disabled={pipelineLoading}>
|
|
||||||
{pipelineLoading
|
|
||||||
? <><div className="spinner" style={{width:13,height:13}}/> Analyse läuft… (3 Stufen)</>
|
|
||||||
: <><Brain size={13}/> 🔬 Mehrstufige Analyse starten</>}
|
|
||||||
</button>
|
|
||||||
{pipelineError && <div style={{fontSize:12,color:'#D85A30',marginBottom:8}}>{pipelineError}</div>}
|
|
||||||
|
|
||||||
{latestInsight ? (
|
|
||||||
<>
|
|
||||||
<div style={{fontSize:11,color:'var(--text3)',marginBottom:6}}>
|
|
||||||
Letzte Analyse: {dayjs(latestInsight.created).format('DD. MMMM YYYY, HH:mm')}
|
|
||||||
</div>
|
|
||||||
<div style={{maxHeight: showInsight?'none':120, overflow:'hidden', position:'relative'}}>
|
|
||||||
<Markdown text={latestInsight.content}/>
|
|
||||||
{!showInsight && (
|
|
||||||
<div style={{position:'absolute',bottom:0,left:0,right:0,height:40,
|
|
||||||
background:'linear-gradient(transparent,var(--surface))'}}/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button type="button" style={{background:'none',border:'none',cursor:'pointer',
|
|
||||||
fontSize:12,color:'var(--accent)',marginTop:6,padding:0}}
|
|
||||||
onClick={()=>setShowInsight(s=>!s)}>
|
|
||||||
{showInsight?'▲ Weniger anzeigen':'▼ Vollständig anzeigen'}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div style={{fontSize:13,color:'var(--text3)',padding:'8px 0'}}>
|
|
||||||
Noch keine KI-Auswertung vorhanden.
|
|
||||||
<button type="button" className="btn btn-primary" style={{marginTop:8,display:'block',fontSize:12}}
|
|
||||||
onClick={()=>nav('/analysis')}>
|
|
||||||
Erste Analyse erstellen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DashboardTile>
|
|
||||||
</DashboardSection>
|
|
||||||
</>}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
416
frontend/src/pages/DashboardConfigurePage.jsx
Normal file
416
frontend/src/pages/DashboardConfigurePage.jsx
Normal file
|
|
@ -0,0 +1,416 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { ChevronDown, ChevronUp, LayoutDashboard, Search } 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, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor'
|
||||||
|
|
||||||
|
const CHART_DAYS_WIDGET_IDS = new Set([
|
||||||
|
'body_overview',
|
||||||
|
'activity_overview',
|
||||||
|
'nutrition_detail_charts',
|
||||||
|
'recovery_charts_panel',
|
||||||
|
])
|
||||||
|
|
||||||
|
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 [search, setSearch] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState(null)
|
||||||
|
const [err, setErr] = useState(null)
|
||||||
|
const [chartDaysDraftByWidgetId, setChartDaysDraftByWidgetId] = useState({})
|
||||||
|
|
||||||
|
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 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 searchLower = search.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 (!searchLower) return true
|
||||||
|
const m = metaById[w.id]
|
||||||
|
const hay = `${m?.title || ''} ${m?.description || ''} ${w.id}`.toLowerCase()
|
||||||
|
return hay.includes(searchLower)
|
||||||
|
})
|
||||||
|
}, [layout, searchLower, 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])
|
||||||
|
|
||||||
|
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="/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 }}>
|
||||||
|
<LayoutDashboard size={26} color="var(--accent)" />
|
||||||
|
Übersicht anpassen
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
|
||||||
|
Wähle Kacheln für deine Startseite. Änderungen gelten nur für dein Profil – der System-Standard bleibt erhalten,
|
||||||
|
bis du speicherst. Gesperrte Kacheln (Abonnement) erscheinen nicht in der Auswahl.
|
||||||
|
</p>
|
||||||
|
{!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 }}>
|
||||||
|
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 6 }}>
|
||||||
|
Widgets durchsuchen (Bibliothek unten)
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Search size={18} color="var(--text3)" style={{ flexShrink: 0 }} />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Titel, Beschreibung oder ID …"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
aria-label="Widgets durchsuchen"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div className="card-title" style={{ fontSize: 15 }}>
|
||||||
|
Auf der Übersicht aktiv · {activeIndices.length}
|
||||||
|
</div>
|
||||||
|
{err && <p style={{ fontSize: 12, color: '#D85A30', marginBottom: 8 }}>{err}</p>}
|
||||||
|
{msg && <p style={{ fontSize: 12, color: 'var(--accent)', marginBottom: 8 }}>{msg}</p>}
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: 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
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={w.id}
|
||||||
|
style={{
|
||||||
|
padding: '10px 0',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 20 }}>
|
||||||
|
<div className="card-title" style={{ fontSize: 15 }}>
|
||||||
|
Bibliothek · hinzufügen
|
||||||
|
</div>
|
||||||
|
{libraryIndices.length === 0 ? (
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text3)', margin: 0 }}>
|
||||||
|
{searchLower ? 'Keine passenden inaktiven Widgets.' : 'Alle verfügbaren Kacheln sind schon aktiv.'}
|
||||||
|
</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: '10px 0',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<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 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}>
|
||||||
|
System-Standard wiederherstellen
|
||||||
|
</button>
|
||||||
|
<Link to="/" className="btn btn-secondary" style={{ textDecoration: 'none', textAlign: 'center' }}>
|
||||||
|
Zur Übersicht
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -145,10 +145,10 @@ export default function DashboardLabPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyDefaultLocal = () => {
|
const applyDefaultLocal = () => {
|
||||||
if (bundle?.default_layout) {
|
if (bundle?.lab_default_layout) {
|
||||||
setChartDaysDraftByWidgetId({})
|
setChartDaysDraftByWidgetId({})
|
||||||
setLayout(normalizeLayoutForEditor(structuredClone(bundle.default_layout)))
|
setLayout(normalizeLayoutForEditor(structuredClone(bundle.lab_default_layout)))
|
||||||
setMsg('Standard geladen (noch nicht gespeichert).')
|
setMsg('Lab-Standard geladen (noch nicht gespeichert).')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target, LayoutGrid } from 'lucide-react'
|
import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target, LayoutGrid, LayoutDashboard } from 'lucide-react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useProfile } from '../context/ProfileContext'
|
import { useProfile } from '../context/ProfileContext'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
@ -428,6 +428,23 @@ export default function SettingsPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<LayoutDashboard size={15} color="var(--accent)" /> Startseite (Übersicht)
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.6 }}>
|
||||||
|
Kacheln wählen und sortieren. Es wird nur dein persönliches Layout gespeichert – der App-Standard für neue
|
||||||
|
Nutzer wird dadurch nicht überschrieben.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/settings/dashboard-layout"
|
||||||
|
className="btn btn-primary btn-full"
|
||||||
|
style={{ textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box' }}
|
||||||
|
>
|
||||||
|
Übersicht anpassen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<Target size={15} color="var(--accent)" /> Strategische Ziele
|
<Target size={15} color="var(--accent)" /> Strategische Ziele
|
||||||
|
|
@ -449,8 +466,8 @@ export default function SettingsPage() {
|
||||||
Pilot: Visualisierungs-Module
|
Pilot: Visualisierungs-Module
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.5 }}>
|
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.5 }}>
|
||||||
Ziel-Übersicht-Pilot: Schnelleingabe, KPIs (Referenzen + KF% + Ø-Kalorien), Körper-Chart,
|
Ziel-Übersicht-Pilot: Schnelleingabe, KPIs, Körper-Chart, Aktivität. Die reguläre Übersicht konfigurierst du
|
||||||
Bewertungen, Aktivität. Produktives Dashboard bleibt unverändert.
|
unter <strong>Übersicht anpassen</strong> oben.
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user