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.
This commit is contained in:
parent
c0cb995a7b
commit
e5f6e6c10d
90
backend/dashboard_layout_schema.py
Normal file
90
backend/dashboard_layout_schema.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -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 workflows # Phase 2 Workflow Engine - Execution
|
||||||
from routers import reference_values # Persönliche Referenzwerte (Profil)
|
from routers import reference_values # Persönliche Referenzwerte (Profil)
|
||||||
from routers import admin_reference_value_types # Admin: Referenzwert-Typen
|
from routers import admin_reference_value_types # Admin: Referenzwert-Typen
|
||||||
|
from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Lab Layout
|
||||||
|
|
||||||
# ── App Configuration ─────────────────────────────────────────────────────────
|
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
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(workflows.router) # /api/workflows/* (Phase 2 Execution)
|
||||||
app.include_router(reference_values.router) # /api/reference-value-types, /api/profile-reference-values
|
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(admin_reference_value_types.router) # /api/admin/reference-value-types
|
||||||
|
app.include_router(app_dashboard.router) # /api/app/dashboard-layout
|
||||||
|
|
||||||
# ── Health Check ──────────────────────────────────────────────────────────────
|
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|
|
||||||
5
backend/migrations/039_dashboard_layout.sql
Normal file
5
backend/migrations/039_dashboard_layout.sql
Normal file
|
|
@ -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.';
|
||||||
81
backend/routers/app_dashboard.py
Normal file
81
backend/routers/app_dashboard.py
Normal file
|
|
@ -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()}
|
||||||
56
backend/tests/test_dashboard_layout_schema.py
Normal file
56
backend/tests/test_dashboard_layout_schema.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -9,7 +9,7 @@ Semantic Versioning: MAJOR.MINOR.PATCH
|
||||||
|
|
||||||
APP_VERSION = "0.9n"
|
APP_VERSION = "0.9n"
|
||||||
BUILD_DATE = "2026-04-05"
|
BUILD_DATE = "2026-04-05"
|
||||||
DB_SCHEMA_VERSION = "20260406b" # Migration 038
|
DB_SCHEMA_VERSION = "20260406c" # Migration 039
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"auth": "1.2.0",
|
"auth": "1.2.0",
|
||||||
|
|
@ -30,6 +30,7 @@ MODULE_VERSIONS = {
|
||||||
"importdata": "1.0.0",
|
"importdata": "1.0.0",
|
||||||
"membership": "2.1.0",
|
"membership": "2.1.0",
|
||||||
"workflow": "0.6.0", # Phase 4: End Node Template Engine
|
"workflow": "0.6.0", # Phase 4: End Node Template Engine
|
||||||
|
"app_dashboard": "1.0.0", # Dashboard-Lab Layout API (/api/app)
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import SettingsPage from './pages/SettingsPage'
|
||||||
import SettingsShell from './layouts/SettingsShell'
|
import SettingsShell from './layouts/SettingsShell'
|
||||||
import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage'
|
import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage'
|
||||||
import PilotVizPage from './pages/PilotVizPage'
|
import PilotVizPage from './pages/PilotVizPage'
|
||||||
|
import DashboardLabPage from './pages/DashboardLabPage'
|
||||||
import GuidePage from './pages/GuidePage'
|
import GuidePage from './pages/GuidePage'
|
||||||
import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
|
import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
|
||||||
import AdminFeaturesPage from './pages/AdminFeaturesPage'
|
import AdminFeaturesPage from './pages/AdminFeaturesPage'
|
||||||
|
|
@ -255,6 +256,7 @@ function AppShell() {
|
||||||
<Route path="/workflow-editor/:id" element={<WorkflowEditorPage/>}/>
|
<Route path="/workflow-editor/:id" element={<WorkflowEditorPage/>}/>
|
||||||
<Route path="/subscription" element={<SubscriptionPage/>}/>
|
<Route path="/subscription" element={<SubscriptionPage/>}/>
|
||||||
<Route path="/pilot/viz" element={<PilotVizPage />} />
|
<Route path="/pilot/viz" element={<PilotVizPage />} />
|
||||||
|
<Route path="/app/dashboard-lab" element={<DashboardLabPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
10
frontend/src/app/areas/dashboardLab/dashboardLabWidgets.js
Normal file
10
frontend/src/app/areas/dashboardLab/dashboardLabWidgets.js
Normal file
|
|
@ -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' },
|
||||||
|
]
|
||||||
243
frontend/src/pages/DashboardLabPage.jsx
Normal file
243
frontend/src/pages/DashboardLabPage.jsx
Normal file
|
|
@ -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 <PilotWelcome key="welcome" />
|
||||||
|
case 'quick_capture':
|
||||||
|
return <PilotQuickCapture key="quick_capture" onSaved={bump} />
|
||||||
|
case 'kpi_board':
|
||||||
|
return <PilotKpiBoard key="kpi_board" refreshTick={refreshTick} />
|
||||||
|
case 'body_overview':
|
||||||
|
return <PilotBodySection key="body_overview" refreshTick={refreshTick} />
|
||||||
|
case 'activity_overview':
|
||||||
|
return <PilotActivitySection key="activity_overview" refreshTick={refreshTick} />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabledOrder = layout.widgets.filter((w) => w.enabled)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ paddingBottom: 96, textAlign: 'left', maxWidth: 920, 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 }}>
|
||||||
|
<LayoutGrid size={26} color="var(--accent)" />
|
||||||
|
App-Bereich: Dashboard-Lab
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
|
||||||
|
Geschützte Route (Login erforderlich). Widget-Reihenfolge und Sichtbarkeit werden pro Profil in der
|
||||||
|
Datenbank gespeichert — getrennt vom Produktiv-Dashboard. Vergleich:{' '}
|
||||||
|
<Link to="/pilot/viz" style={{ color: 'var(--accent)' }}>
|
||||||
|
Pilot-Übersicht (festes Layout)
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
marginBottom: 20,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderColor: 'var(--border2)',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="card-title" style={{ fontSize: 14 }}>
|
||||||
|
Layout (v1)
|
||||||
|
</div>
|
||||||
|
{bundle && (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12 }}>
|
||||||
|
Status: {bundle.custom ? 'individuell gespeichert' : 'Standard (nicht in DB)'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{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 0 12px' }}>
|
||||||
|
{layout.widgets.map((w, i) => {
|
||||||
|
const label = metaById[w.id]?.label || w.id
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={w.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
padding: '8px 0',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 140px' }}>
|
||||||
|
<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>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<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={reset}>
|
||||||
|
Zurücksetzen (DB)
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={applyDefaultLocal}>
|
||||||
|
Standard in Editor laden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{enabledOrder.map((w) => renderWidget(w.id))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect } from 'react'
|
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 { Link } from 'react-router-dom'
|
||||||
import { useProfile } from '../context/ProfileContext'
|
import { useProfile } from '../context/ProfileContext'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
@ -452,13 +452,31 @@ export default function SettingsPage() {
|
||||||
Ziel-Übersicht-Pilot: Schnelleingabe, KPIs (Referenzen + KF% + Ø-Kalorien), Körper-Chart,
|
Ziel-Übersicht-Pilot: Schnelleingabe, KPIs (Referenzen + KF% + Ø-Kalorien), Körper-Chart,
|
||||||
Bewertungen, Aktivität. Produktives Dashboard bleibt unverändert.
|
Bewertungen, Aktivität. Produktives Dashboard bleibt unverändert.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
to="/pilot/viz"
|
<Link
|
||||||
className="btn btn-secondary btn-full"
|
to="/pilot/viz"
|
||||||
style={{ textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box' }}
|
className="btn btn-secondary btn-full"
|
||||||
>
|
style={{ textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box' }}
|
||||||
Pilot öffnen
|
>
|
||||||
</Link>
|
Pilot öffnen
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/app/dashboard-lab"
|
||||||
|
className="btn btn-secondary btn-full"
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
textDecoration: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LayoutGrid size={18} />
|
||||||
|
Dashboard-Lab (Layout API)
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Auth actions */}
|
{/* Auth actions */}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,11 @@ export const api = {
|
||||||
getProfile: () => req('/profile'),
|
getProfile: () => req('/profile'),
|
||||||
updateActiveProfile:(d)=> req('/profile', jput(d)),
|
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)
|
// Persönliche Referenzwerte (Profil, historisch)
|
||||||
listReferenceValueTypes: () => req('/reference-value-types'),
|
listReferenceValueTypes: () => req('/reference-value-types'),
|
||||||
listReferenceValueMetaEnums: () => req('/reference-value-meta/enums'),
|
listReferenceValueMetaEnums: () => req('/reference-value-meta/enums'),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user