diff --git a/backend/dashboard_layout_schema.py b/backend/dashboard_layout_schema.py index de3fced..b7b75f5 100644 --- a/backend/dashboard_layout_schema.py +++ b/backend/dashboard_layout_schema.py @@ -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 diff --git a/backend/routers/app_dashboard.py b/backend/routers/app_dashboard.py index a9f9c08..19b489e 100644 --- a/backend/routers/app_dashboard.py +++ b/backend/routers/app_dashboard.py @@ -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} diff --git a/backend/tests/test_dashboard_layout_schema.py b/backend/tests/test_dashboard_layout_schema.py index e030adf..60bc7ac 100644 --- a/backend/tests/test_dashboard_layout_schema.py +++ b/backend/tests/test_dashboard_layout_schema.py @@ -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}], } ) diff --git a/backend/tests/test_widget_catalog.py b/backend/tests/test_widget_catalog.py index 0ee34a8..478ead4 100644 --- a/backend/tests/test_widget_catalog.py +++ b/backend/tests/test_widget_catalog.py @@ -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", diff --git a/backend/version.py b/backend/version.py index 8f5f3e9..1e8c235 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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 = [ diff --git a/backend/widget_catalog.py b/backend/widget_catalog.py index af755d9..f89a908 100644 --- a/backend/widget_catalog.py +++ b/backend/widget_catalog.py @@ -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) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c7ec039..d02d77c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { }> } /> } /> + } /> }> }> diff --git a/frontend/src/config/settingsNav.js b/frontend/src/config/settingsNav.js index 4e769c1..f03cc3f 100644 --- a/frontend/src/config/settingsNav.js +++ b/frontend/src/config/settingsNav.js @@ -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' }, ] diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 9a7987c..c8d9d2d 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -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() + }, []) - const runPipeline = async () => { - setPipelineLoading(true); setPipelineError(null) - try { - await api.insightPipeline() - await load() - } catch(e) { - setPipelineError('Fehler: '+e.message) - } finally { setPipelineLoading(false) } - } - - useEffect(()=>{ load() },[]) + useEffect(() => { + let cancel = false + setLayoutLoading(true) + Promise.all([api.getAppDashboardLayout(), api.getAppWidgetsCatalog()]) + .then(([b, c]) => { + if (cancel) return + setLayoutBundle(b) + setCatalog(c) + }) + .catch(() => { + if (cancel) return + setLayoutBundle(null) + setCatalog(null) + }) + .finally(() => { + if (!cancel) setLayoutLoading(false) + }) + return () => { + cancel = true + } + }, []) 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
- - 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 (
@@ -157,296 +90,41 @@ export default function Dashboard() { lineHeight: 1.5, }} > - Kein Admin-Zugriff. Dieser Bereich ist nur für Konten mit Administrator-Rolle. - Du wurdest zur Übersicht weitergeleitet. + Kein Admin-Zugriff. Dieser Bereich ist nur für Konten mit Administrator-Rolle. Du wurdest zur + Übersicht weitergeleitet.
)} - {/* Header greeting */} -
-

- Hallo, {activeProfile?.name||'Nutzer'} 👋 -

-
- {dayjs().format('dddd, DD. MMMM YYYY')} - {latestW && ` · Letztes Update ${dayjs(latestW.date).format('DD.MM.')}`} -
+ +
+ + + Übersicht anpassen +
- {/* Email Verification Banner */} - {activeProfile && } + {activeProfile && } + {activeProfile && } - {/* Trial Banner */} - {activeProfile && } - - {!hasAnyData && ( + {layoutLoading && (
-

Willkommen bei Mitai Jinkendo!

-

Starte mit deiner ersten Messung.

- +
)} - {hasAnyData && <> - nav('/weight')}> - Alle Einträge → - - } - > -
- -
-
- - -
- nav('/history')} color="#378ADD"/> - {latestCal?.body_fat_pct && nav('/history',{state:{tab:'body'}})} color={bfCat?.color}/>} - {latestCal?.lean_mass && nav('/history',{state:{tab:'body'}})}/>} - {avgKcal && nav('/history',{state:{tab:'nutrition'}})} color="#EF9F27"/>} -
- {pills.length > 0 && ( -
- {pills.map((p,i)=>)} -
- )} -
- - {(activeProfile?.goal_weight||activeProfile?.goal_bf_pct) && latestW && ( - -
- {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 ( -
-
- Gewicht: {curr} → {goal} kg - {remain>0?`noch ${remain}kg`:'Ziel erreicht! 🎉'} -
-
-
-
-
{pct}% des Weges
-
- ) - })()} - {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 ( -
-
- Körperfett: {curr}% → {goal}% - {remain>0?`noch ${remain}%`:'Ziel erreicht! 🎉'} -
-
-
-
-
Aktuell: {bfCat?.label}
-
- ) - })()} -
- - )} - - {(weights.length>2||nutrition.length>2) && ( - nav('/history',{state:{tab:'body'}})}> - Details → - - } - > - -
- -
- Ø Kalorien - Gewicht -
-
-
-
- )} - - {(showNutrSummary || showActSummary) && ( - -
- {showNutrSummary && ( - -
nav('/history',{state:{tab:'nutrition'}})}> -
🍽️ ERNÄHRUNG (Ø 7T)
- {avgKcal &&
{avgKcal} kcal
} - {avgProtein &&
- {avgProtein}g Protein {proteinOk?'✓':'⚠️'} -
} -
→ Verlauf Ernährung
-
-
- )} - {showActSummary && ( - -
nav('/history',{state:{tab:'activity'}})}> -
🏋️ AKTIVITÄT (7T)
-
{actKcal} kcal
-
{recentAct.length} Trainings
-
→ Verlauf Aktivität
-
-
- )} -
-
- )} - - -
- - - - - - -
-
- - {activities.length > 0 && ( - nav('/activity')}> - Details → - - } - > - -
- -
-
-
- )} - - { e.stopPropagation(); nav('/goals') }}> - Ziele bearbeiten → - - } - > - -
nav('/goals')}> - {goalsCount != null && ( -
- {goalsCount === 0 - ? 'Noch keine Ziele angelegt.' - : `${goalsCount} ${goalsCount === 1 ? 'Ziel' : 'Ziele'} im System.`} -
- )} -
- Hier pflegst du Focus Areas, Meilensteine und Fortschritt – unabhängig von der KI-Analyse-Seite. - Tippen zum Öffnen oder unten in der Navigation Ziele wählen. -
-
-
-
- - nav('/analysis')}> - Analysen → - - } - > - -
- - {pipelineError &&
{pipelineError}
} - - {latestInsight ? ( - <> -
- Letzte Analyse: {dayjs(latestInsight.created).format('DD. MMMM YYYY, HH:mm')} -
-
- - {!showInsight && ( -
- )} -
- - - ) : ( -
- Noch keine KI-Auswertung vorhanden. - -
- )} -
- - - } + {!layoutLoading && layoutForPreview && ( + + )}
) } diff --git a/frontend/src/pages/DashboardConfigurePage.jsx b/frontend/src/pages/DashboardConfigurePage.jsx new file mode 100644 index 0000000..ed81efb --- /dev/null +++ b/frontend/src/pages/DashboardConfigurePage.jsx @@ -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 ( +
+

{err}

+ +
+ ) + } + + if (!layout) { + return ( +
+
+
+ ) + } + + return ( +
+
+ + ← Einstellungen + +

+ + Übersicht anpassen +

+

+ 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. +

+ {!bundle?.custom && ( +

+ Du bearbeitest gerade das System-Standardlayout. Mit „Speichern“ legst du deine persönliche + Version ab. +

+ )} +
+ +
+ +
+ + setSearch(e.target.value)} + aria-label="Widgets durchsuchen" + style={{ flex: 1 }} + /> +
+
+ +
+
+ Auf der Übersicht aktiv · {activeIndices.length} +
+ {err &&

{err}

} + {msg &&

{msg}

} +
    + {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 ( +
  • +
    + +
    + + +
    +
    + {w.id === 'quick_capture' && ( + + 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' && ( + + 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) && ( +
    + + + 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() + }} + /> +
    + )} +
  • + ) + })} +
+
+ +
+
+ Bibliothek · hinzufügen +
+ {libraryIndices.length === 0 ? ( +

+ {searchLower ? 'Keine passenden inaktiven Widgets.' : 'Alle verfügbaren Kacheln sind schon aktiv.'} +

+ ) : ( +
    + {libraryIndices.map((i) => { + const w = layout.widgets[i] + const m = metaById[w.id] + return ( +
  • +
    +
    {m?.title || w.id}
    +
    {m?.description || ''}
    +
    + +
  • + ) + })} +
+ )} +
+ +
+ + + + Zur Übersicht + +
+
+ ) +} diff --git a/frontend/src/pages/DashboardLabPage.jsx b/frontend/src/pages/DashboardLabPage.jsx index 33628db..7145d59 100644 --- a/frontend/src/pages/DashboardLabPage.jsx +++ b/frontend/src/pages/DashboardLabPage.jsx @@ -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).') } } diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 5932df9..32cf8c4 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -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() {
+
+
+ Startseite (Übersicht) +
+

+ Kacheln wählen und sortieren. Es wird nur dein persönliches Layout gespeichert – der App-Standard für neue + Nutzer wird dadurch nicht überschrieben. +

+ + Übersicht anpassen + +
+
Strategische Ziele @@ -449,8 +466,8 @@ export default function SettingsPage() { Pilot: Visualisierungs-Module

- 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 Übersicht anpassen oben.