feat: Update widget catalog and enhance dashboard layout features
- Added new "Dashboard-Lab-Widgets" entry to the documentation for better guidance on widget configuration. - Updated the app_dashboard version to 1.8.0 to reflect the introduction of widget catalog features and layout entitlements. - Enhanced widget catalog entries to include optional feature requirements for better visibility and access control. - Improved the DashboardLabPage to manage widget visibility based on feature entitlements, ensuring a more tailored user experience.
This commit is contained in:
parent
bc91396885
commit
9bc0cf70da
|
|
@ -7,6 +7,7 @@
|
||||||
> | Coding-Regeln | `.claude/rules/CODING_RULES.md` |
|
> | Coding-Regeln | `.claude/rules/CODING_RULES.md` |
|
||||||
> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` |
|
> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` |
|
||||||
> | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** |
|
> | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** |
|
||||||
|
> | **Dashboard-Lab-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** |
|
||||||
|
|
||||||
## Claude Code Verantwortlichkeiten
|
## Claude Code Verantwortlichkeiten
|
||||||
|
|
||||||
|
|
@ -842,6 +843,7 @@ Bottom-Padding Mobile: 80px (Navigation)
|
||||||
|Auth-Flow|`.claude/library/AUTH.md`|Sicherheit + Sessions|
|
|Auth-Flow|`.claude/library/AUTH.md`|Sicherheit + Sessions|
|
||||||
|API-Referenz|`.claude/library/API\_REFERENCE.md`|Alle Endpoints|
|
|API-Referenz|`.claude/library/API\_REFERENCE.md`|Alle Endpoints|
|
||||||
|Datenbankschema|`.claude/library/DATABASE.md`|Tabellen + Beziehungen|
|
|Datenbankschema|`.claude/library/DATABASE.md`|Tabellen + Beziehungen|
|
||||||
|
|Dashboard-Lab-Widgets|`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`|Katalog, Validierung, Frontend-Registry, konfigurierbare `config`|
|
||||||
|
|
||||||
> Library-Dateien werden mit `/document` generiert und nach größeren
|
> Library-Dateien werden mit `/document` generiert und nach größeren
|
||||||
> Änderungen aktualisiert.
|
> Änderungen aktualisiert.
|
||||||
|
|
|
||||||
79
backend/dashboard_widget_entitlements.py
Normal file
79
backend/dashboard_widget_entitlements.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""
|
||||||
|
Dashboard-Widgets × Feature-System: Sichtbarkeit aus check_feature_access.
|
||||||
|
|
||||||
|
Katalog-Einträge optional `requires_feature` (features.id). Fehlt der Key → immer erlaubt.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from widget_catalog import WIDGET_CATALOG
|
||||||
|
|
||||||
|
|
||||||
|
def _check_feature_access(profile_id: str, feature_id: str, conn) -> dict:
|
||||||
|
"""Indirection für Tests (monkeypatch) und spätes Laden von auth (bcrypt)."""
|
||||||
|
from auth import check_feature_access
|
||||||
|
|
||||||
|
return check_feature_access(profile_id, feature_id, conn)
|
||||||
|
|
||||||
|
_WIDGET_ENTRY_BY_ID: dict[str, dict[str, Any]] = {e["id"]: e for e in WIDGET_CATALOG}
|
||||||
|
|
||||||
|
|
||||||
|
def widget_id_allowed(widget_id: str, profile_id: str, conn) -> bool:
|
||||||
|
entry = _WIDGET_ENTRY_BY_ID.get(widget_id)
|
||||||
|
if entry is None:
|
||||||
|
return False
|
||||||
|
fid = entry.get("requires_feature")
|
||||||
|
if not fid:
|
||||||
|
return True
|
||||||
|
return bool(_check_feature_access(profile_id, fid, conn)["allowed"])
|
||||||
|
|
||||||
|
|
||||||
|
def _public_row(entry: dict[str, Any], *, allowed: bool) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": entry["id"],
|
||||||
|
"title": entry["title"],
|
||||||
|
"description": entry["description"],
|
||||||
|
"allowed": allowed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def widgets_catalog_for_profile(profile_id: str, conn) -> list[dict[str, Any]]:
|
||||||
|
"""Zeilen für GET /api/app/widgets/catalog (ohne internes requires_feature-Feld)."""
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for e in WIDGET_CATALOG:
|
||||||
|
fid = e.get("requires_feature")
|
||||||
|
allowed = True
|
||||||
|
if fid:
|
||||||
|
allowed = bool(_check_feature_access(profile_id, fid, conn)["allowed"])
|
||||||
|
out.append(_public_row(e, allowed=allowed))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def widgets_catalog_payload(profile_id: str, conn) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"catalog_version": 1,
|
||||||
|
"widgets": widgets_catalog_for_profile(profile_id, conn),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_entitlements_to_layout_dict(layout: dict[str, Any], profile_id: str, conn) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Setzt enabled=False für Widgets ohne Berechtigung. Mindestens ein Widget bleibt aktiv (welcome).
|
||||||
|
"""
|
||||||
|
out = copy.deepcopy(layout)
|
||||||
|
widgets = out.get("widgets") or []
|
||||||
|
for w in widgets:
|
||||||
|
wid = w.get("id")
|
||||||
|
if not wid:
|
||||||
|
continue
|
||||||
|
if w.get("enabled") and not widget_id_allowed(wid, profile_id, conn):
|
||||||
|
w["enabled"] = False
|
||||||
|
if not any(w.get("enabled") for w in widgets):
|
||||||
|
for w in widgets:
|
||||||
|
if w.get("id") == "welcome":
|
||||||
|
w["enabled"] = True
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
@ -10,18 +10,23 @@ from psycopg2.extras import Json
|
||||||
|
|
||||||
from auth import require_auth
|
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, default_layout_dict
|
||||||
|
from dashboard_widget_entitlements import apply_entitlements_to_layout_dict, widgets_catalog_payload
|
||||||
from db import get_cursor, get_db
|
from db import get_cursor, get_db
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
from widget_catalog import catalog_response
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/app", tags=["app-dashboard-lab"])
|
router = APIRouter(prefix="/api/app", tags=["app-dashboard-lab"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/widgets/catalog")
|
@router.get("/widgets/catalog")
|
||||||
def get_widgets_catalog(session: dict = Depends(require_auth)) -> dict[str, Any]:
|
def get_widgets_catalog(
|
||||||
"""Metadaten aller registrierbaren Dashboard-Widgets (IDs, Titel)."""
|
x_profile_id: Optional[str] = Header(default=None),
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Katalog inkl. allowed pro Widget (Feature / Subscription, effektiver Tier)."""
|
||||||
_ = session
|
_ = session
|
||||||
return catalog_response()
|
pid = get_pid(x_profile_id)
|
||||||
|
with get_db() as conn:
|
||||||
|
return widgets_catalog_payload(pid, conn)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard-layout")
|
@router.get("/dashboard-layout")
|
||||||
|
|
@ -40,10 +45,13 @@ def get_dashboard_layout(
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
raw = row["dashboard_layout"] if row else None
|
raw = row["dashboard_layout"] if row else None
|
||||||
custom, effective = coalesce_effective_layout(raw)
|
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)
|
||||||
return {
|
return {
|
||||||
"custom": custom,
|
"custom": custom,
|
||||||
"layout": effective,
|
"layout": effective,
|
||||||
"default_layout": default_layout_dict(),
|
"default_layout": default_adj,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -59,6 +67,12 @@ def put_dashboard_layout(
|
||||||
payload = DashboardLayoutPayload.model_validate(body)
|
payload = DashboardLayoutPayload.model_validate(body)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(422, str(e)) from e
|
raise HTTPException(422, str(e)) from e
|
||||||
|
with get_db() as conn:
|
||||||
|
adjusted = apply_entitlements_to_layout_dict(payload.to_stored_dict(), pid, conn)
|
||||||
|
try:
|
||||||
|
payload = DashboardLayoutPayload.model_validate(adjusted)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(422, str(e)) from e
|
||||||
stored = payload.to_stored_dict()
|
stored = payload.to_stored_dict()
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
|
||||||
57
backend/tests/test_dashboard_widget_entitlements.py
Normal file
57
backend/tests/test_dashboard_widget_entitlements.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
from dashboard_layout_schema import DashboardLayoutPayload
|
||||||
|
from dashboard_widget_entitlements import apply_entitlements_to_layout_dict, widget_id_allowed
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_entitlements_disables_widget_without_access(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"dashboard_widget_entitlements.widget_id_allowed",
|
||||||
|
lambda wid, pid, conn: wid != "nutrition_detail_charts",
|
||||||
|
)
|
||||||
|
raw = {
|
||||||
|
"version": 1,
|
||||||
|
"widgets": [
|
||||||
|
{"id": "welcome", "enabled": True},
|
||||||
|
{"id": "nutrition_detail_charts", "enabled": True},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
out = apply_entitlements_to_layout_dict(raw, "p", None)
|
||||||
|
assert {w["id"]: w["enabled"] for w in out["widgets"]} == {
|
||||||
|
"welcome": True,
|
||||||
|
"nutrition_detail_charts": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_entitlements_leaves_welcome_on_when_all_blocked(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"dashboard_widget_entitlements.widget_id_allowed",
|
||||||
|
lambda wid, pid, conn: False,
|
||||||
|
)
|
||||||
|
raw = {
|
||||||
|
"version": 1,
|
||||||
|
"widgets": [
|
||||||
|
{"id": "welcome", "enabled": False},
|
||||||
|
{"id": "nutrition_detail_charts", "enabled": False},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
out = apply_entitlements_to_layout_dict(raw, "p", None)
|
||||||
|
assert any(w["id"] == "welcome" and w["enabled"] for w in out["widgets"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_widget_id_allowed_false_for_unknown_id():
|
||||||
|
assert widget_id_allowed("not-a-widget", "p", None) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_default_layout_still_validates_after_entitlements(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"dashboard_widget_entitlements.widget_id_allowed",
|
||||||
|
lambda wid, pid, conn: wid != "ai_pipeline_insight",
|
||||||
|
)
|
||||||
|
from dashboard_layout_schema import default_layout_dict
|
||||||
|
|
||||||
|
d = default_layout_dict()
|
||||||
|
d["widgets"] = [{**x, "enabled": x["id"] == "ai_pipeline_insight"} for x in d["widgets"]]
|
||||||
|
adj = apply_entitlements_to_layout_dict(d, "p", None)
|
||||||
|
p2 = DashboardLayoutPayload.model_validate(adj)
|
||||||
|
ai = next(w for w in p2.widgets if w.id == "ai_pipeline_insight")
|
||||||
|
assert ai.enabled is False
|
||||||
|
assert any(w.enabled for w in p2.widgets)
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"""Widget-Katalog: Konsistenz (IDs, Default-Layout, Katalog-Response)."""
|
"""Widget-Katalog: Konsistenz (IDs, Default-Layout, Katalog-Response)."""
|
||||||
|
|
||||||
from dashboard_layout_schema import default_layout_dict
|
from dashboard_layout_schema import default_layout_dict
|
||||||
from widget_catalog import ALLOWED_WIDGET_IDS, DEFAULT_LAB_WIDGET_IDS, WIDGET_CATALOG, catalog_response
|
from dashboard_widget_entitlements import widgets_catalog_payload
|
||||||
|
from widget_catalog import ALLOWED_WIDGET_IDS, DEFAULT_LAB_WIDGET_IDS, WIDGET_CATALOG
|
||||||
|
|
||||||
|
|
||||||
def test_catalog_ids_unique_and_match_allowed():
|
def test_catalog_ids_unique_and_match_allowed():
|
||||||
|
|
@ -20,8 +21,27 @@ def test_default_layout_follows_catalog_order():
|
||||||
assert any(w["enabled"] for w in d["widgets"])
|
assert any(w["enabled"] for w in d["widgets"])
|
||||||
|
|
||||||
|
|
||||||
def test_catalog_response_shape():
|
def test_catalog_payload_shape(monkeypatch):
|
||||||
r = catalog_response()
|
monkeypatch.setattr(
|
||||||
|
"dashboard_widget_entitlements._check_feature_access",
|
||||||
|
lambda *args, **kwargs: {"allowed": True},
|
||||||
|
)
|
||||||
|
r = widgets_catalog_payload("test-profile", None)
|
||||||
assert r["catalog_version"] == 1
|
assert r["catalog_version"] == 1
|
||||||
assert len(r["widgets"]) == len(WIDGET_CATALOG)
|
assert len(r["widgets"]) == len(WIDGET_CATALOG)
|
||||||
assert {w["id"] for w in r["widgets"]} == ALLOWED_WIDGET_IDS
|
assert {w["id"] for w in r["widgets"]} == ALLOWED_WIDGET_IDS
|
||||||
|
for w in r["widgets"]:
|
||||||
|
assert set(w.keys()) == {"id", "title", "description", "allowed"}
|
||||||
|
assert w["allowed"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_marks_disallowed_when_feature_blocks(monkeypatch):
|
||||||
|
def _check(_pid, feature_id, conn=None):
|
||||||
|
return {"allowed": feature_id != "nutrition_entries"}
|
||||||
|
|
||||||
|
monkeypatch.setattr("dashboard_widget_entitlements._check_feature_access", _check)
|
||||||
|
r = widgets_catalog_payload("p", None)
|
||||||
|
by_id = {w["id"]: w for w in r["widgets"]}
|
||||||
|
assert by_id["welcome"]["allowed"] is True
|
||||||
|
assert by_id["nutrition_detail_charts"]["allowed"] is False
|
||||||
|
assert by_id["body_overview"]["allowed"] is True
|
||||||
|
|
|
||||||
|
|
@ -30,7 +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.7.0", # nutrition_detail_charts, recovery_charts_panel, progress_photos
|
"app_dashboard": "1.8.0", # widget catalog allowed via features; layout entitlements on GET/PUT
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,16 @@ Frontend-Komponenten registrieren dieselben IDs lokal (siehe widgetSystem/regist
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, TypedDict
|
from typing import Any, NotRequired, TypedDict
|
||||||
|
|
||||||
|
|
||||||
class WidgetCatalogEntry(TypedDict):
|
class WidgetCatalogEntry(TypedDict):
|
||||||
|
"""requires_feature: optional features.id; fehlt oder leer → Widget immer sichtbar (nur Auth)."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
title: str
|
title: str
|
||||||
description: str
|
description: str
|
||||||
|
requires_feature: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
# Reihenfolge = Default-Layout-Reihenfolge. Aktiv-Flags: DEFAULT_LAB_WIDGET_IDS (Rest zunächst aus).
|
# Reihenfolge = Default-Layout-Reihenfolge. Aktiv-Flags: DEFAULT_LAB_WIDGET_IDS (Rest zunächst aus).
|
||||||
|
|
@ -25,7 +28,8 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
||||||
{
|
{
|
||||||
"id": "quick_capture",
|
"id": "quick_capture",
|
||||||
"title": "Schnelleingabe",
|
"title": "Schnelleingabe",
|
||||||
"description": "Gewicht + Baseline-Vitals; optional show_weight / show_resting_hr / show_hrv / show_vo2_max (false = aus)",
|
"description": "Gewicht + Baseline-Vitals; optional show_weight / show_resting_hr / show_hrv / show_vo2_max (false = aus); Feature weight_entries",
|
||||||
|
"requires_feature": "weight_entries",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "kpi_board",
|
"id": "kpi_board",
|
||||||
|
|
@ -35,12 +39,14 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
||||||
{
|
{
|
||||||
"id": "body_overview",
|
"id": "body_overview",
|
||||||
"title": "Körper (Chart)",
|
"title": "Körper (Chart)",
|
||||||
"description": "Gewicht & Kennzahlen (optional: config chart_days 7–90)",
|
"description": "Gewicht & Kennzahlen (optional: config chart_days 7–90); Feature weight_entries",
|
||||||
|
"requires_feature": "weight_entries",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "activity_overview",
|
"id": "activity_overview",
|
||||||
"title": "Aktivität",
|
"title": "Aktivität",
|
||||||
"description": "Trainingstyp-Verteilung (Kuchen) + Konsistenz — Zeitraum über config chart_days 7–90",
|
"description": "Trainingstyp-Verteilung (Kuchen) + Konsistenz — Zeitraum über config chart_days 7–90; Feature activity_entries",
|
||||||
|
"requires_feature": "activity_entries",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "dashboard_greeting",
|
"id": "dashboard_greeting",
|
||||||
|
|
@ -50,17 +56,20 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
||||||
{
|
{
|
||||||
"id": "quick_weight_today",
|
"id": "quick_weight_today",
|
||||||
"title": "Gewicht heute",
|
"title": "Gewicht heute",
|
||||||
"description": "Tagesgewicht erfassen (wie Produkt-Dashboard)",
|
"description": "Tagesgewicht erfassen (wie Produkt-Dashboard); Feature weight_entries",
|
||||||
|
"requires_feature": "weight_entries",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "body_stat_strip",
|
"id": "body_stat_strip",
|
||||||
"title": "Kennzahlen-Kacheln",
|
"title": "Kennzahlen-Kacheln",
|
||||||
"description": "Gewicht, KF, Magermasse, Ø-kcal — Oberreihe",
|
"description": "Gewicht, KF, Magermasse, Ø-kcal — Oberreihe; u. a. nutrition_entries (Ø-kcal)",
|
||||||
|
"requires_feature": "nutrition_entries",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "status_pills",
|
"id": "status_pills",
|
||||||
"title": "Indikatoren (Pills)",
|
"title": "Indikatoren (Pills)",
|
||||||
"description": "WHR, WHtR, Protein, KF",
|
"description": "WHR, WHtR, Protein, KF; Feature nutrition_entries",
|
||||||
|
"requires_feature": "nutrition_entries",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "profile_goals_progress",
|
"id": "profile_goals_progress",
|
||||||
|
|
@ -70,17 +79,20 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
||||||
{
|
{
|
||||||
"id": "trend_kcal_weight",
|
"id": "trend_kcal_weight",
|
||||||
"title": "Trend Kalorien + Gewicht",
|
"title": "Trend Kalorien + Gewicht",
|
||||||
"description": "Linienchart (optional config chart_days 7–90, Default 30)",
|
"description": "Linienchart (optional config chart_days 7–90, Default 30); Feature nutrition_entries",
|
||||||
|
"requires_feature": "nutrition_entries",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "nutrition_activity_summary",
|
"id": "nutrition_activity_summary",
|
||||||
"title": "Ernährung & Aktivität Kurz",
|
"title": "Ernährung & Aktivität Kurz",
|
||||||
"description": "Ø 7T Kacheln",
|
"description": "Ø 7T Kacheln; Feature nutrition_entries",
|
||||||
|
"requires_feature": "nutrition_entries",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "nutrition_detail_charts",
|
"id": "nutrition_detail_charts",
|
||||||
"title": "Ernährung — Detaillierte Charts",
|
"title": "Ernährung — Detaillierte Charts",
|
||||||
"description": "Phase-0c NutritionCharts (optional chart_days 7–90, Default 30)",
|
"description": "Phase-0c NutritionCharts (optional chart_days 7–90, Default 30); Feature nutrition_entries",
|
||||||
|
"requires_feature": "nutrition_entries",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "recovery_charts_panel",
|
"id": "recovery_charts_panel",
|
||||||
|
|
@ -90,7 +102,8 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
||||||
{
|
{
|
||||||
"id": "progress_photos",
|
"id": "progress_photos",
|
||||||
"title": "Fortschrittsfotos",
|
"title": "Fortschrittsfotos",
|
||||||
"description": "Galerie der hochgeladenen Fotos",
|
"description": "Galerie der hochgeladenen Fotos; Feature photos",
|
||||||
|
"requires_feature": "photos",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "recovery_sleep_rest",
|
"id": "recovery_sleep_rest",
|
||||||
|
|
@ -105,7 +118,8 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
||||||
{
|
{
|
||||||
"id": "ai_pipeline_insight",
|
"id": "ai_pipeline_insight",
|
||||||
"title": "KI Pipeline & letzte Analyse",
|
"title": "KI Pipeline & letzte Analyse",
|
||||||
"description": "Pipeline starten + Gesamt-Insight",
|
"description": "Pipeline starten + Gesamt-Insight; Feature ai_pipeline",
|
||||||
|
"requires_feature": "ai_pipeline",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -120,11 +134,3 @@ DEFAULT_LAB_WIDGET_IDS: frozenset[str] = frozenset(
|
||||||
)
|
)
|
||||||
|
|
||||||
ALLOWED_WIDGET_IDS: frozenset[str] = frozenset(e["id"] for e in WIDGET_CATALOG)
|
ALLOWED_WIDGET_IDS: frozenset[str] = frozenset(e["id"] for e in WIDGET_CATALOG)
|
||||||
|
|
||||||
|
|
||||||
def catalog_response() -> dict[str, Any]:
|
|
||||||
"""Payload für GET /api/app/widgets/catalog."""
|
|
||||||
return {
|
|
||||||
"catalog_version": 1,
|
|
||||||
"widgets": list(WIDGET_CATALOG),
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { ChevronDown, ChevronUp, LayoutGrid } from 'lucide-react'
|
import { ChevronDown, ChevronUp, LayoutGrid } from 'lucide-react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { api, formatFastApiDetail } from '../utils/api'
|
import { api, formatFastApiDetail } from '../utils/api'
|
||||||
|
|
@ -43,6 +43,35 @@ export default function DashboardLabPage() {
|
||||||
|
|
||||||
const metaById = catalogMetaById(catalog)
|
const metaById = catalogMetaById(catalog)
|
||||||
|
|
||||||
|
const isWidgetCatalogAllowed = useCallback(
|
||||||
|
(widgetId) => {
|
||||||
|
const m = metaById[widgetId]
|
||||||
|
if (m == null) return true
|
||||||
|
return m.allowed !== false
|
||||||
|
},
|
||||||
|
[metaById],
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleEditorIndices = useMemo(
|
||||||
|
() =>
|
||||||
|
layout?.widgets?.map((_, i) => i).filter((i) => isWidgetCatalogAllowed(layout.widgets[i].id)) ?? [],
|
||||||
|
[layout, isWidgetCatalogAllowed],
|
||||||
|
)
|
||||||
|
|
||||||
|
const layoutForPreview = useMemo(
|
||||||
|
() =>
|
||||||
|
layout
|
||||||
|
? {
|
||||||
|
...layout,
|
||||||
|
widgets: layout.widgets.map((w) => ({
|
||||||
|
...w,
|
||||||
|
enabled: w.enabled && isWidgetCatalogAllowed(w.id),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
[layout, isWidgetCatalogAllowed],
|
||||||
|
)
|
||||||
|
|
||||||
const commitChartDaysDraftToLayout = useCallback((draftStr, baseLayout, widgetId) => {
|
const commitChartDaysDraftToLayout = useCallback((draftStr, baseLayout, widgetId) => {
|
||||||
const clamped = normalizeBodyChartDays(
|
const clamped = normalizeBodyChartDays(
|
||||||
draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr
|
draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr
|
||||||
|
|
@ -189,7 +218,8 @@ export default function DashboardLabPage() {
|
||||||
{err && <p style={{ fontSize: 12, color: '#D85A30', marginBottom: 8 }}>{err}</p>}
|
{err && <p style={{ fontSize: 12, color: '#D85A30', marginBottom: 8 }}>{err}</p>}
|
||||||
{msg && <p style={{ fontSize: 12, color: 'var(--accent)', marginBottom: 8 }}>{msg}</p>}
|
{msg && <p style={{ fontSize: 12, color: 'var(--accent)', marginBottom: 8 }}>{msg}</p>}
|
||||||
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 12px' }}>
|
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 12px' }}>
|
||||||
{layout.widgets.map((w, i) => {
|
{visibleEditorIndices.map((i) => {
|
||||||
|
const w = layout.widgets[i]
|
||||||
const label = metaById[w.id]?.title || w.id
|
const label = metaById[w.id]?.title || w.id
|
||||||
const chartDaysVal =
|
const chartDaysVal =
|
||||||
w.config?.chart_days != null
|
w.config?.chart_days != null
|
||||||
|
|
@ -361,7 +391,9 @@ export default function DashboardLabPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WidgetRenderer layout={layout} refreshTick={refreshTick} requestRefresh={requestRefresh} />
|
{layoutForPreview && (
|
||||||
|
<WidgetRenderer layout={layoutForPreview} refreshTick={refreshTick} requestRefresh={requestRefresh} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user