From e5f6e6c10dc7b0d68481ff52041da3e29118b368 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 7 Apr 2026 11:38:35 +0200 Subject: [PATCH] feat: Integrate Dashboard-Lab layout and enhance settings navigation - Added new routes and API endpoints for the Dashboard-Lab layout in the app. - Updated main.py to include the app_dashboard router for backend integration. - Enhanced App.jsx to include a route for the DashboardLabPage. - Modified SettingsPage to add a link to the new Dashboard-Lab layout, improving user access to dashboard features. - Updated version.py to reflect the new app_dashboard module version. --- backend/dashboard_layout_schema.py | 90 +++++++ backend/main.py | 2 + backend/migrations/039_dashboard_layout.sql | 5 + backend/routers/app_dashboard.py | 81 ++++++ backend/tests/test_dashboard_layout_schema.py | 56 ++++ backend/version.py | 3 +- frontend/src/App.jsx | 2 + .../areas/dashboardLab/dashboardLabWidgets.js | 10 + frontend/src/pages/DashboardLabPage.jsx | 243 ++++++++++++++++++ frontend/src/pages/SettingsPage.jsx | 34 ++- frontend/src/utils/api.js | 5 + 11 files changed, 522 insertions(+), 9 deletions(-) create mode 100644 backend/dashboard_layout_schema.py create mode 100644 backend/migrations/039_dashboard_layout.sql create mode 100644 backend/routers/app_dashboard.py create mode 100644 backend/tests/test_dashboard_layout_schema.py create mode 100644 frontend/src/app/areas/dashboardLab/dashboardLabWidgets.js create mode 100644 frontend/src/pages/DashboardLabPage.jsx diff --git a/backend/dashboard_layout_schema.py b/backend/dashboard_layout_schema.py new file mode 100644 index 0000000..63372a4 --- /dev/null +++ b/backend/dashboard_layout_schema.py @@ -0,0 +1,90 @@ +""" +Dashboard-Layout v1 (Nutzer-Lab): Validierung und Standard-Layout. + +Single Source für erlaubte Widget-IDs (sync mit Frontend widgetRegistry). +""" +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field, model_validator + +ALLOWED_WIDGET_IDS: frozenset[str] = frozenset( + { + "welcome", + "quick_capture", + "kpi_board", + "body_overview", + "activity_overview", + } +) + + +def default_layout_dict() -> dict[str, Any]: + return { + "version": 1, + "widgets": [ + {"id": "welcome", "enabled": True}, + {"id": "quick_capture", "enabled": True}, + {"id": "kpi_board", "enabled": True}, + {"id": "body_overview", "enabled": True}, + {"id": "activity_overview", "enabled": True}, + ], + } + + +class DashboardWidgetEntry(BaseModel): + id: str = Field(min_length=1, max_length=64) + enabled: bool = True + + +class DashboardLayoutPayload(BaseModel): + version: Literal[1] = 1 + widgets: list[DashboardWidgetEntry] = Field(min_length=1, max_length=32) + + @model_validator(mode="after") + def _validate_widgets(self) -> DashboardLayoutPayload: + ids = [w.id for w in self.widgets] + if len(ids) != len(set(ids)): + raise ValueError("Doppelte widget id") + bad = [i for i in ids if i not in ALLOWED_WIDGET_IDS] + if bad: + raise ValueError(f"Unbekannte Widget-IDs: {bad}") + if not any(w.enabled for w in self.widgets): + raise ValueError("Mindestens ein Widget muss aktiv sein") + return self + + def to_stored_dict(self) -> dict[str, Any]: + return { + "version": self.version, + "widgets": [{"id": w.id, "enabled": w.enabled} for w in self.widgets], + } + + +def coalesce_effective_layout(raw: Any) -> tuple[bool, dict[str, Any]]: + """ + Returns (has_custom, effective_layout). + has_custom=True nur wenn DB-Wert vorhanden und gültig (v1). + """ + if raw is None: + return False, default_layout_dict() + parsed_obj: Any = raw + if isinstance(raw, str): + import json + + try: + parsed_obj = json.loads(raw) + except json.JSONDecodeError: + return False, default_layout_dict() + if not isinstance(parsed_obj, dict): + return False, default_layout_dict() + try: + parsed = DashboardLayoutPayload.model_validate( + { + "version": parsed_obj.get("version", 1), + "widgets": parsed_obj.get("widgets", []), + } + ) + return True, parsed.to_stored_dict() + except Exception: + return False, default_layout_dict() diff --git a/backend/main.py b/backend/main.py index 850d589..5ad6444 100644 --- a/backend/main.py +++ b/backend/main.py @@ -30,6 +30,7 @@ from routers import workflow_questions # Phase 1 Workflow Engine - Question Cat from routers import workflows # Phase 2 Workflow Engine - Execution from routers import reference_values # Persönliche Referenzwerte (Profil) from routers import admin_reference_value_types # Admin: Referenzwert-Typen +from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Lab Layout # ── App Configuration ───────────────────────────────────────────────────────── DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) @@ -119,6 +120,7 @@ app.include_router(workflow_questions.router) # /api/workflow/questions/* (Pha app.include_router(workflows.router) # /api/workflows/* (Phase 2 Execution) app.include_router(reference_values.router) # /api/reference-value-types, /api/profile-reference-values app.include_router(admin_reference_value_types.router) # /api/admin/reference-value-types +app.include_router(app_dashboard.router) # /api/app/dashboard-layout # ── Health Check ────────────────────────────────────────────────────────────── @app.get("/") diff --git a/backend/migrations/039_dashboard_layout.sql b/backend/migrations/039_dashboard_layout.sql new file mode 100644 index 0000000..5dc6772 --- /dev/null +++ b/backend/migrations/039_dashboard_layout.sql @@ -0,0 +1,5 @@ +-- Nutzer-Dashboard: Layout (JSONB) für geschützten App-Lab-Bereich (Issue #65, Phase 1) +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS dashboard_layout JSONB DEFAULT NULL; + +COMMENT ON COLUMN profiles.dashboard_layout IS + 'Optional: konfigurierbare Dashboard-Widget-Reihenfolge/Sichtbarkeit (v1 JSON). NULL = Standard.'; diff --git a/backend/routers/app_dashboard.py b/backend/routers/app_dashboard.py new file mode 100644 index 0000000..7124ed4 --- /dev/null +++ b/backend/routers/app_dashboard.py @@ -0,0 +1,81 @@ +""" +Geschützter App-Bereich: Dashboard-Lab Layout (kein Produktiv-Dashboard). + +/api/app/dashboard-layout — nur mit Session + aktivem Profil (X-Profile-Id). +""" +from typing import Any, Optional + +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 db import get_cursor, get_db +from routers.profiles import get_pid + +router = APIRouter(prefix="/api/app", tags=["app-dashboard-lab"]) + + +@router.get("/dashboard-layout") +def get_dashboard_layout( + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +) -> dict[str, Any]: + _ = session + pid = get_pid(x_profile_id) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "SELECT dashboard_layout FROM profiles WHERE id = %s", + (pid,), + ) + row = cur.fetchone() + raw = row["dashboard_layout"] if row else None + custom, effective = coalesce_effective_layout(raw) + return { + "custom": custom, + "layout": effective, + "default_layout": default_layout_dict(), + } + + +@router.put("/dashboard-layout") +def put_dashboard_layout( + body: dict[str, Any], + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +) -> dict[str, Any]: + _ = session + pid = get_pid(x_profile_id) + try: + payload = DashboardLayoutPayload.model_validate(body) + except Exception as e: + raise HTTPException(422, str(e)) from e + stored = payload.to_stored_dict() + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "UPDATE profiles SET dashboard_layout = %s WHERE id = %s", + (Json(stored), pid), + ) + if cur.rowcount == 0: + raise HTTPException(404, "Profil nicht gefunden") + return {"ok": True, "layout": stored} + + +@router.post("/dashboard-layout/reset") +def reset_dashboard_layout( + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +) -> dict[str, Any]: + _ = session + pid = get_pid(x_profile_id) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "UPDATE profiles SET dashboard_layout = NULL WHERE id = %s", + (pid,), + ) + if cur.rowcount == 0: + raise HTTPException(404, "Profil nicht gefunden") + return {"ok": True, "layout": default_layout_dict()} diff --git a/backend/tests/test_dashboard_layout_schema.py b/backend/tests/test_dashboard_layout_schema.py new file mode 100644 index 0000000..e030adf --- /dev/null +++ b/backend/tests/test_dashboard_layout_schema.py @@ -0,0 +1,56 @@ +import pytest + +from dashboard_layout_schema import ( + ALLOWED_WIDGET_IDS, + DashboardLayoutPayload, + coalesce_effective_layout, + default_layout_dict, +) + + +def test_default_has_all_allowed_ids(): + d = default_layout_dict() + got = {w["id"] for w in d["widgets"]} + assert got == ALLOWED_WIDGET_IDS + + +def test_payload_rejects_duplicate_ids(): + with pytest.raises(Exception): + DashboardLayoutPayload.model_validate( + { + "version": 1, + "widgets": [ + {"id": "welcome", "enabled": True}, + {"id": "welcome", "enabled": False}, + ], + } + ) + + +def test_payload_requires_one_enabled(): + with pytest.raises(Exception): + DashboardLayoutPayload.model_validate( + { + "version": 1, + "widgets": [{"id": "welcome", "enabled": False}], + } + ) + + +def test_coalesce_none(): + custom, eff = coalesce_effective_layout(None) + assert custom is False + assert eff == default_layout_dict() + + +def test_coalesce_valid_raw(): + raw = { + "version": 1, + "widgets": [ + {"id": "welcome", "enabled": True}, + {"id": "kpi_board", "enabled": True}, + ], + } + custom, eff = coalesce_effective_layout(raw) + assert custom is True + assert eff == raw diff --git a/backend/version.py b/backend/version.py index 47d2c3d..a54f0a8 100644 --- a/backend/version.py +++ b/backend/version.py @@ -9,7 +9,7 @@ Semantic Versioning: MAJOR.MINOR.PATCH APP_VERSION = "0.9n" BUILD_DATE = "2026-04-05" -DB_SCHEMA_VERSION = "20260406b" # Migration 038 +DB_SCHEMA_VERSION = "20260406c" # Migration 039 MODULE_VERSIONS = { "auth": "1.2.0", @@ -30,6 +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.0.0", # Dashboard-Lab Layout API (/api/app) } CHANGELOG = [ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cd4f495..c7ec039 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -25,6 +25,7 @@ import SettingsPage from './pages/SettingsPage' import SettingsShell from './layouts/SettingsShell' import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage' import PilotVizPage from './pages/PilotVizPage' +import DashboardLabPage from './pages/DashboardLabPage' import GuidePage from './pages/GuidePage' import AdminTierLimitsPage from './pages/AdminTierLimitsPage' import AdminFeaturesPage from './pages/AdminFeaturesPage' @@ -255,6 +256,7 @@ function AppShell() { }/> }/> } /> + } /> diff --git a/frontend/src/app/areas/dashboardLab/dashboardLabWidgets.js b/frontend/src/app/areas/dashboardLab/dashboardLabWidgets.js new file mode 100644 index 0000000..13db16f --- /dev/null +++ b/frontend/src/app/areas/dashboardLab/dashboardLabWidgets.js @@ -0,0 +1,10 @@ +/** + * Dashboard-Lab: Widget-Metadaten (IDs müssen mit backend/dashboard_layout_schema.py übereinstimmen). + */ +export const DASHBOARD_LAB_WIDGET_META = [ + { id: 'welcome', label: 'Willkommen' }, + { id: 'quick_capture', label: 'Schnelleingabe' }, + { id: 'kpi_board', label: 'KPI-Kacheln' }, + { id: 'body_overview', label: 'Körper (Chart)' }, + { id: 'activity_overview', label: 'Aktivität' }, +] diff --git a/frontend/src/pages/DashboardLabPage.jsx b/frontend/src/pages/DashboardLabPage.jsx new file mode 100644 index 0000000..18ce9fe --- /dev/null +++ b/frontend/src/pages/DashboardLabPage.jsx @@ -0,0 +1,243 @@ +import { useCallback, useEffect, useState } from 'react' +import { ChevronDown, ChevronUp, LayoutGrid } from 'lucide-react' +import { Link } from 'react-router-dom' +import { api, formatFastApiDetail } from '../utils/api' +import { DASHBOARD_LAB_WIDGET_META } from '../app/areas/dashboardLab/dashboardLabWidgets' +import PilotWelcome from '../components/pilot/PilotWelcome' +import PilotQuickCapture from '../components/pilot/PilotQuickCapture' +import PilotKpiBoard from '../components/pilot/PilotKpiBoard' +import PilotBodySection from '../components/pilot/PilotBodySection' +import PilotActivitySection from '../components/pilot/PilotActivitySection' + +const metaById = Object.fromEntries(DASHBOARD_LAB_WIDGET_META.map((m) => [m.id, m])) + +function moveWidget(layout, index, delta) { + const next = [...layout.widgets] + const j = index + delta + if (j < 0 || j >= next.length) return layout + ;[next[index], next[j]] = [next[j], next[index]] + return { ...layout, widgets: next } +} + +function toggleWidget(layout, index) { + const next = layout.widgets.map((w, i) => + i === index ? { ...w, enabled: !w.enabled } : w + ) + const anyOn = next.some((w) => w.enabled) + if (!anyOn) return layout + return { ...layout, widgets: next } +} + +export default function DashboardLabPage() { + const [refreshTick, setRefreshTick] = useState(0) + const bump = () => setRefreshTick((t) => t + 1) + const [bundle, setBundle] = useState(null) + const [layout, setLayout] = useState(null) + const [err, setErr] = useState(null) + const [busy, setBusy] = useState(false) + const [msg, setMsg] = useState(null) + + const load = useCallback(async () => { + setErr(null) + try { + const b = await api.getAppDashboardLayout() + setBundle(b) + setLayout(b.layout) + } catch (e) { + setErr(formatFastApiDetail(null, e.message)) + } + }, []) + + useEffect(() => { + load() + }, [load]) + + const save = async () => { + if (!layout) return + setBusy(true) + setMsg(null) + setErr(null) + try { + await api.putAppDashboardLayout(layout) + setMsg('Layout gespeichert.') + await load() + } catch (e) { + setErr(formatFastApiDetail(null, e.message)) + } finally { + setBusy(false) + } + } + + const reset = async () => { + if (!confirm('Persönliches Layout löschen und Standard wiederherstellen?')) return + setBusy(true) + setMsg(null) + setErr(null) + try { + const r = await api.resetAppDashboardLayout() + setLayout(r.layout) + setMsg('Auf Standard zurückgesetzt.') + await load() + } catch (e) { + setErr(formatFastApiDetail(null, e.message)) + } finally { + setBusy(false) + } + } + + const applyDefaultLocal = () => { + if (bundle?.default_layout) { + setLayout(structuredClone(bundle.default_layout)) + setMsg('Standard geladen (noch nicht gespeichert).') + } + } + + const renderWidget = (id) => { + switch (id) { + case 'welcome': + return + case 'quick_capture': + return + case 'kpi_board': + return + case 'body_overview': + return + case 'activity_overview': + return + default: + return null + } + } + + if (err && !layout) { + return ( +
+

{err}

+ +
+ ) + } + + if (!layout) { + return ( +
+
+
+ ) + } + + const enabledOrder = layout.widgets.filter((w) => w.enabled) + + return ( +
+
+ + ← Einstellungen + +

+ + App-Bereich: Dashboard-Lab +

+

+ Geschützte Route (Login erforderlich). Widget-Reihenfolge und Sichtbarkeit werden pro Profil in der + Datenbank gespeichert — getrennt vom Produktiv-Dashboard. Vergleich:{' '} + + Pilot-Übersicht (festes Layout) + + . +

+
+ +
+
+ Layout (v1) +
+ {bundle && ( +

+ Status: {bundle.custom ? 'individuell gespeichert' : 'Standard (nicht in DB)'} +

+ )} + {err && ( +

{err}

+ )} + {msg && ( +

{msg}

+ )} +
    + {layout.widgets.map((w, i) => { + const label = metaById[w.id]?.label || w.id + return ( +
  • + +
    + + +
    +
  • + ) + })} +
+
+ + + +
+
+ + {enabledOrder.map((w) => renderWidget(w.id))} +
+ ) +} diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 887db25..5932df9 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 } from 'lucide-react' +import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target, LayoutGrid } from 'lucide-react' import { Link } from 'react-router-dom' import { useProfile } from '../context/ProfileContext' import { useAuth } from '../context/AuthContext' @@ -452,13 +452,31 @@ export default function SettingsPage() { Ziel-Übersicht-Pilot: Schnelleingabe, KPIs (Referenzen + KF% + Ø-Kalorien), Körper-Chart, Bewertungen, Aktivität. Produktives Dashboard bleibt unverändert.

- - Pilot öffnen - +
+ + Pilot öffnen + + + + Dashboard-Lab (Layout API) + +
{/* Auth actions */} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 3cb7659..815ae7d 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -70,6 +70,11 @@ export const api = { getProfile: () => req('/profile'), updateActiveProfile:(d)=> req('/profile', jput(d)), + // App-Bereich: Dashboard-Lab (Layout JSON, Issue #65) + getAppDashboardLayout: () => req('/app/dashboard-layout'), + putAppDashboardLayout: (layout) => req('/app/dashboard-layout', jput(layout)), + resetAppDashboardLayout: () => req('/app/dashboard-layout/reset', { method: 'POST' }), + // Persönliche Referenzwerte (Profil, historisch) listReferenceValueTypes: () => req('/reference-value-types'), listReferenceValueMetaEnums: () => req('/reference-value-meta/enums'),