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
|
||||
|
||||
|
|
@ -10,7 +10,12 @@ from typing import Any, Literal
|
|||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
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)
|
||||
__all__ = [
|
||||
|
|
@ -19,10 +24,13 @@ __all__ = [
|
|||
"DashboardWidgetEntry",
|
||||
"coalesce_effective_layout",
|
||||
"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
|
||||
return {
|
||||
"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):
|
||||
id: str = Field(min_length=1, max_length=64)
|
||||
enabled: bool = True
|
||||
|
|
|
|||
|
|
@ -9,7 +9,12 @@ from fastapi import APIRouter, Depends, Header, HTTPException
|
|||
from psycopg2.extras import Json
|
||||
|
||||
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 db import get_cursor, get_db
|
||||
from routers.profiles import get_pid
|
||||
|
|
@ -47,11 +52,13 @@ def get_dashboard_layout(
|
|||
custom, effective = coalesce_effective_layout(raw)
|
||||
with get_db() as 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 {
|
||||
"custom": custom,
|
||||
"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:
|
||||
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,
|
||||
default_layout_dict,
|
||||
)
|
||||
from widget_catalog import DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS
|
||||
|
||||
|
||||
def test_default_has_all_allowed_ids():
|
||||
d = default_layout_dict()
|
||||
got = {w["id"] for w in d["widgets"]}
|
||||
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():
|
||||
|
|
@ -32,7 +34,7 @@ def test_payload_requires_one_enabled():
|
|||
DashboardLayoutPayload.model_validate(
|
||||
{
|
||||
"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_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():
|
||||
|
|
@ -17,10 +22,17 @@ def test_default_layout_follows_catalog_order():
|
|||
got = [w["id"] for w in d["widgets"]]
|
||||
assert got == [e["id"] for e in WIDGET_CATALOG]
|
||||
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"])
|
||||
|
||||
|
||||
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):
|
||||
monkeypatch.setattr(
|
||||
"dashboard_widget_entitlements._check_feature_access",
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ MODULE_VERSIONS = {
|
|||
"importdata": "1.0.0",
|
||||
"membership": "2.1.0",
|
||||
"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 = [
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import SettingsShell from './layouts/SettingsShell'
|
|||
import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage'
|
||||
import PilotVizPage from './pages/PilotVizPage'
|
||||
import DashboardLabPage from './pages/DashboardLabPage'
|
||||
import DashboardConfigurePage from './pages/DashboardConfigurePage'
|
||||
import GuidePage from './pages/GuidePage'
|
||||
import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
|
||||
import AdminFeaturesPage from './pages/AdminFeaturesPage'
|
||||
|
|
@ -232,6 +233,7 @@ function AppShell() {
|
|||
<Route path="/settings" element={<SettingsShell />}>
|
||||
<Route index element={<SettingsPage />} />
|
||||
<Route path="reference-values" element={<ProfileReferenceValuesPage />} />
|
||||
<Route path="dashboard-layout" element={<DashboardConfigurePage />} />
|
||||
</Route>
|
||||
<Route element={<RequireAdmin />}>
|
||||
<Route path="admin" element={<AdminShell />}>
|
||||
|
|
|
|||
|
|
@ -5,5 +5,6 @@
|
|||
|
||||
export const SETTINGS_SHELL_NAV_ITEMS = [
|
||||
{ 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' },
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,82 +1,56 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Brain } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { LayoutDashboard } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import { useProfile } from '../context/ProfileContext'
|
||||
import { getBfCategory } from '../utils/calc'
|
||||
import TrialBanner from '../components/TrialBanner'
|
||||
import EmailVerificationBanner from '../components/EmailVerificationBanner'
|
||||
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
||||
import SleepWidget from '../components/SleepWidget'
|
||||
import RestDaysWidget from '../components/RestDaysWidget'
|
||||
import Markdown from '../utils/Markdown'
|
||||
import QuickWeightEntry from '../components/QuickWeightEntry'
|
||||
import TrendKcalWeightChart from '../components/TrendKcalWeightChart'
|
||||
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')
|
||||
import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
|
||||
import { WidgetRenderer } from '../widgetSystem/dashboardWidgetRegistry'
|
||||
|
||||
function catalogMetaById(catalog) {
|
||||
if (!catalog?.widgets?.length) return {}
|
||||
return Object.fromEntries(catalog.widgets.map((w) => [w.id, w]))
|
||||
}
|
||||
|
||||
// ── Main Dashboard ────────────────────────────────────────────────────────────
|
||||
export default function Dashboard() {
|
||||
const nav = useNavigate()
|
||||
const location = useLocation()
|
||||
const { activeProfile } = useProfile()
|
||||
|
||||
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 [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 requestRefresh = () => setRefreshTick((t) => t + 1)
|
||||
|
||||
const load = () => Promise.all([
|
||||
api.getStats(),
|
||||
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)
|
||||
useEffect(() => {
|
||||
ensurePilotLabWidgetsRegistered()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancel = false
|
||||
setLayoutLoading(true)
|
||||
Promise.all([api.getAppDashboardLayout(), api.getAppWidgetsCatalog()])
|
||||
.then(([b, c]) => {
|
||||
if (cancel) return
|
||||
setLayoutBundle(b)
|
||||
setCatalog(c)
|
||||
})
|
||||
|
||||
const runPipeline = async () => {
|
||||
setPipelineLoading(true); setPipelineError(null)
|
||||
try {
|
||||
await api.insightPipeline()
|
||||
await load()
|
||||
} catch(e) {
|
||||
setPipelineError('Fehler: '+e.message)
|
||||
} finally { setPipelineLoading(false) }
|
||||
.catch(() => {
|
||||
if (cancel) return
|
||||
setLayoutBundle(null)
|
||||
setCatalog(null)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancel) setLayoutLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancel = true
|
||||
}
|
||||
|
||||
useEffect(()=>{ load() },[])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!location.state?.adminDenied) return
|
||||
|
|
@ -86,60 +60,19 @@ export default function Dashboard() {
|
|||
return () => window.clearTimeout(clear)
|
||||
}, [location.state, nav])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeProfile?.id) return
|
||||
api.listGoals()
|
||||
.then((list) => setGoalsCount(Array.isArray(list) ? list.length : 0))
|
||||
.catch(() => setGoalsCount(null))
|
||||
}, [activeProfile?.id])
|
||||
const metaById = useMemo(() => catalogMetaById(catalog), [catalog])
|
||||
|
||||
if (loading) return <div className="empty-state"><div className="spinner"/></div>
|
||||
|
||||
const latestCal = calipers[0]
|
||||
const latestCir = circs[0]
|
||||
const latestW = weights[0]
|
||||
const prevW = weights[1]
|
||||
const sex = activeProfile?.sex||'m'
|
||||
const height = activeProfile?.height||178
|
||||
|
||||
// Deltas
|
||||
const wDelta = latestW&&prevW ? Math.round((latestW.weight-prevW.weight)*10)/10 : null
|
||||
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
|
||||
const layoutForPreview = useMemo(() => {
|
||||
if (!layoutBundle?.layout) return null
|
||||
const L = layoutBundle.layout
|
||||
return {
|
||||
...L,
|
||||
widgets: L.widgets.map((w) => ({
|
||||
...w,
|
||||
enabled: w.enabled && metaById[w.id]?.allowed !== false,
|
||||
})),
|
||||
}
|
||||
}, [layoutBundle, metaById])
|
||||
|
||||
return (
|
||||
<div className="dashboard-page">
|
||||
|
|
@ -157,296 +90,41 @@ export default function Dashboard() {
|
|||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<strong>Kein Admin-Zugriff.</strong> Dieser Bereich ist nur für Konten mit Administrator-Rolle.
|
||||
Du wurdest zur Übersicht weitergeleitet.
|
||||
<strong>Kein Admin-Zugriff.</strong> Dieser Bereich ist nur für Konten mit Administrator-Rolle. Du wurdest zur
|
||||
Übersicht weitergeleitet.
|
||||
</div>
|
||||
)}
|
||||
{/* Header greeting */}
|
||||
<div className="dashboard-greeting">
|
||||
<h1 style={{fontSize:22,fontWeight:800,margin:0,color:'var(--text1)'}}>
|
||||
Hallo, {activeProfile?.name||'Nutzer'} 👋
|
||||
</h1>
|
||||
<div className="dashboard-greeting__meta" style={{fontSize:12,color:'var(--text3)',marginTop:2}}>
|
||||
{dayjs().format('dddd, DD. MMMM YYYY')}
|
||||
{latestW && ` · Letztes Update ${dayjs(latestW.date).format('DD.MM.')}`}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 10 }}>
|
||||
<Link
|
||||
to="/settings/dashboard-layout"
|
||||
className="btn btn-secondary"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: '8px 12px',
|
||||
textDecoration: 'none',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<LayoutDashboard size={16} />
|
||||
Übersicht anpassen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Email Verification Banner */}
|
||||
{activeProfile && <EmailVerificationBanner profile={activeProfile} />}
|
||||
|
||||
{/* Trial Banner */}
|
||||
{activeProfile && <TrialBanner profile={activeProfile} />}
|
||||
|
||||
{!hasAnyData && (
|
||||
{layoutLoading && (
|
||||
<div className="empty-state">
|
||||
<h3>Willkommen bei Mitai Jinkendo!</h3>
|
||||
<p>Starte mit deiner ersten Messung.</p>
|
||||
<button className="btn btn-primary" onClick={()=>nav('/capture')}>
|
||||
Erfassen starten
|
||||
</button>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasAnyData && <>
|
||||
<DashboardSection
|
||||
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>
|
||||
{!layoutLoading && layoutForPreview && (
|
||||
<WidgetRenderer layout={layoutForPreview} refreshTick={refreshTick} requestRefresh={requestRefresh} />
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
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 = () => {
|
||||
if (bundle?.default_layout) {
|
||||
if (bundle?.lab_default_layout) {
|
||||
setChartDaysDraftByWidgetId({})
|
||||
setLayout(normalizeLayoutForEditor(structuredClone(bundle.default_layout)))
|
||||
setMsg('Standard geladen (noch nicht gespeichert).')
|
||||
setLayout(normalizeLayoutForEditor(structuredClone(bundle.lab_default_layout)))
|
||||
setMsg('Lab-Standard geladen (noch nicht gespeichert).')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { useProfile } from '../context/ProfileContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
|
@ -428,6 +428,23 @@ export default function SettingsPage() {
|
|||
</button>
|
||||
</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-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Target size={15} color="var(--accent)" /> Strategische Ziele
|
||||
|
|
@ -449,8 +466,8 @@ export default function SettingsPage() {
|
|||
Pilot: Visualisierungs-Module
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.5 }}>
|
||||
Ziel-Übersicht-Pilot: Schnelleingabe, KPIs (Referenzen + KF% + Ø-Kalorien), Körper-Chart,
|
||||
Bewertungen, Aktivität. Produktives Dashboard bleibt unverändert.
|
||||
Ziel-Übersicht-Pilot: Schnelleingabe, KPIs, Körper-Chart, Aktivität. Die reguläre Übersicht konfigurierst du
|
||||
unter <strong>Übersicht anpassen</strong> oben.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Link
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user