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() {
{err}
+ 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)
+
+ .
+
+ Status: {bundle.custom ? 'individuell gespeichert' : 'Standard (nicht in DB)'}
+ {err} {msg}
+
+
+ {layout.widgets.map((w, i) => {
+ const label = metaById[w.id]?.label || w.id
+ return (
+
+