feat: Implement widget-feature assignment management in admin dashboard
- Added new API endpoints for listing and updating widget-feature assignments, allowing for custom feature requirements. - Introduced a new admin page for managing widget-feature assignments, enhancing the admin interface. - Updated navigation to include a link to the new widget-feature assignments page. - Refactored widget access logic to support AND-based feature requirements for widgets. - Bumped app_dashboard version to 1.11.0 to reflect these changes and improvements.
This commit is contained in:
parent
365ce49c6a
commit
24daeeb83c
|
|
@ -9,6 +9,7 @@ import copy
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from widget_catalog import WIDGET_CATALOG
|
from widget_catalog import WIDGET_CATALOG
|
||||||
|
from widget_feature_requirements_db import get_widget_required_feature_ids
|
||||||
|
|
||||||
|
|
||||||
def _check_feature_access(profile_id: str, feature_id: str, conn) -> dict:
|
def _check_feature_access(profile_id: str, feature_id: str, conn) -> dict:
|
||||||
|
|
@ -21,13 +22,15 @@ _WIDGET_ENTRY_BY_ID: dict[str, dict[str, Any]] = {e["id"]: e for e in WIDGET_CAT
|
||||||
|
|
||||||
|
|
||||||
def widget_id_allowed(widget_id: str, profile_id: str, conn) -> bool:
|
def widget_id_allowed(widget_id: str, profile_id: str, conn) -> bool:
|
||||||
entry = _WIDGET_ENTRY_BY_ID.get(widget_id)
|
if _WIDGET_ENTRY_BY_ID.get(widget_id) is None:
|
||||||
if entry is None:
|
|
||||||
return False
|
return False
|
||||||
fid = entry.get("requires_feature")
|
fids = get_widget_required_feature_ids(widget_id, conn)
|
||||||
if not fid:
|
if not fids:
|
||||||
return True
|
return True
|
||||||
return bool(_check_feature_access(profile_id, fid, conn)["allowed"])
|
for fid in fids:
|
||||||
|
if not _check_feature_access(profile_id, fid, conn)["allowed"]:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _public_row(entry: dict[str, Any], *, allowed: bool) -> dict[str, Any]:
|
def _public_row(entry: dict[str, Any], *, allowed: bool) -> dict[str, Any]:
|
||||||
|
|
@ -43,10 +46,7 @@ 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)."""
|
"""Zeilen für GET /api/app/widgets/catalog (ohne internes requires_feature-Feld)."""
|
||||||
out: list[dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
for e in WIDGET_CATALOG:
|
for e in WIDGET_CATALOG:
|
||||||
fid = e.get("requires_feature")
|
allowed = widget_id_allowed(e["id"], profile_id, conn)
|
||||||
allowed = True
|
|
||||||
if fid:
|
|
||||||
allowed = bool(_check_feature_access(profile_id, fid, conn)["allowed"])
|
|
||||||
out.append(_public_row(e, allowed=allowed))
|
out.append(_public_row(e, allowed=allowed))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
|
||||||
16
backend/migrations/041_widget_feature_requirements.sql
Normal file
16
backend/migrations/041_widget_feature_requirements.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
-- Dashboard-Widgets: explizite Feature-Anforderungen (Custom) statt nur widget_catalog.requires_feature
|
||||||
|
-- Kein Eintrag in dashboard_widget_requirement_custom → Anforderungen aus Code (Katalog).
|
||||||
|
-- Eintrag vorhanden → AND über alle zugeordneten features (0 Zeilen = kein Feature nötig).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dashboard_widget_requirement_custom (
|
||||||
|
widget_id VARCHAR(64) PRIMARY KEY,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS widget_feature_requirements (
|
||||||
|
widget_id VARCHAR(64) NOT NULL REFERENCES dashboard_widget_requirement_custom (widget_id) ON DELETE CASCADE,
|
||||||
|
feature_id TEXT NOT NULL REFERENCES features (id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (widget_id, feature_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_widget_feature_requirements_feature_id ON widget_feature_requirements (feature_id);
|
||||||
|
|
@ -14,8 +14,14 @@ from fastapi import APIRouter, HTTPException, Depends
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from auth import require_admin, hash_pin
|
from auth import require_admin, hash_pin
|
||||||
from models import AdminProfileUpdate
|
from models import AdminProfileUpdate
|
||||||
from dashboard_layout_schema import DashboardLayoutPayload, product_default_layout_dict
|
from dashboard_layout_schema import ALLOWED_WIDGET_IDS, DashboardLayoutPayload, product_default_layout_dict
|
||||||
from dashboard_widget_entitlements import widgets_catalog_admin_payload
|
from dashboard_widget_entitlements import widgets_catalog_admin_payload
|
||||||
|
from widget_catalog import WIDGET_CATALOG
|
||||||
|
from widget_feature_requirements_db import (
|
||||||
|
clear_custom_requirements,
|
||||||
|
fetch_assignments_bundle,
|
||||||
|
set_custom_requirements,
|
||||||
|
)
|
||||||
from system_dashboard_product_default import (
|
from system_dashboard_product_default import (
|
||||||
delete_product_default_override,
|
delete_product_default_override,
|
||||||
get_product_default_base_dict,
|
get_product_default_base_dict,
|
||||||
|
|
@ -213,3 +219,76 @@ def admin_delete_dashboard_product_default(session: dict = Depends(require_admin
|
||||||
delete_product_default_override(conn)
|
delete_product_default_override(conn)
|
||||||
layout = get_product_default_base_dict(conn)
|
layout = get_product_default_base_dict(conn)
|
||||||
return {"ok": True, "layout": layout, "from_database": False}
|
return {"ok": True, "layout": layout, "from_database": False}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/widget-feature-assignments")
|
||||||
|
def admin_list_widget_feature_assignments(session: dict = Depends(require_admin)):
|
||||||
|
"""Alle Katalog-Widgets mit Custom-/Katalog-Feature-Listen zur Admin-Pflege."""
|
||||||
|
_ = session
|
||||||
|
with get_db() as conn:
|
||||||
|
custom_ids, by_w = fetch_assignments_bundle(conn)
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name, category, active
|
||||||
|
FROM features
|
||||||
|
ORDER BY category NULLS LAST, name
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
features = [r2d(r) for r in cur.fetchall()]
|
||||||
|
widgets_out: list[dict[str, Any]] = []
|
||||||
|
for e in WIDGET_CATALOG:
|
||||||
|
wid = e["id"]
|
||||||
|
cf = e.get("requires_feature")
|
||||||
|
code_fids = [cf] if cf else []
|
||||||
|
uses_custom = wid in custom_ids
|
||||||
|
widgets_out.append(
|
||||||
|
{
|
||||||
|
"id": wid,
|
||||||
|
"title": e["title"],
|
||||||
|
"description": e["description"],
|
||||||
|
"uses_custom_requirements": uses_custom,
|
||||||
|
"feature_ids": list(by_w.get(wid, [])) if uses_custom else [],
|
||||||
|
"catalog_feature_ids": code_fids,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"widgets": widgets_out, "features": features}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/widget-feature-assignments/{widget_id}")
|
||||||
|
def admin_put_widget_feature_assignment(
|
||||||
|
widget_id: str,
|
||||||
|
body: dict[str, Any],
|
||||||
|
session: dict = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
mode=catalog: Katalog-Fallback (requires_feature im Code).
|
||||||
|
mode=custom: AND über feature_ids (leere Liste = Widget ohne Feature-Gate).
|
||||||
|
"""
|
||||||
|
_ = session
|
||||||
|
if widget_id not in ALLOWED_WIDGET_IDS:
|
||||||
|
raise HTTPException(404, "Unbekanntes Widget")
|
||||||
|
mode = body.get("mode", "custom")
|
||||||
|
if mode == "catalog":
|
||||||
|
with get_db() as conn:
|
||||||
|
clear_custom_requirements(conn, widget_id)
|
||||||
|
return {"ok": True, "widget_id": widget_id, "uses_custom_requirements": False, "feature_ids": []}
|
||||||
|
if mode != "custom":
|
||||||
|
raise HTTPException(422, "mode muss catalog oder custom sein")
|
||||||
|
raw_ids = body.get("feature_ids")
|
||||||
|
if not isinstance(raw_ids, list):
|
||||||
|
raise HTTPException(422, "feature_ids muss Liste sein")
|
||||||
|
cleaned = [str(x).strip() for x in raw_ids if x is not None and str(x).strip()]
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
for fid in cleaned:
|
||||||
|
cur.execute("SELECT id FROM features WHERE id = %s", (fid,))
|
||||||
|
if not cur.fetchone():
|
||||||
|
raise HTTPException(422, f"Unbekanntes Feature: {fid}")
|
||||||
|
set_custom_requirements(conn, widget_id, cleaned)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"widget_id": widget_id,
|
||||||
|
"uses_custom_requirements": True,
|
||||||
|
"feature_ids": cleaned,
|
||||||
|
}
|
||||||
|
|
|
||||||
47
backend/tests/test_widget_feature_requirements_db.py
Normal file
47
backend/tests/test_widget_feature_requirements_db.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
from dashboard_widget_entitlements import widget_id_allowed
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_feature_and_requires_all(monkeypatch):
|
||||||
|
wid = "nutrition_detail_charts"
|
||||||
|
|
||||||
|
def fake_check(pid, fid, conn):
|
||||||
|
return {"allowed": fid in ("a", "b")}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"dashboard_widget_entitlements.get_widget_required_feature_ids",
|
||||||
|
lambda w, conn: ["a", "b"] if w == wid else [],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("dashboard_widget_entitlements._check_feature_access", fake_check)
|
||||||
|
assert widget_id_allowed(wid, "p", object()) is True
|
||||||
|
|
||||||
|
def fake_check_one_denied(pid, fid, conn):
|
||||||
|
return {"allowed": fid == "a"}
|
||||||
|
|
||||||
|
monkeypatch.setattr("dashboard_widget_entitlements._check_feature_access", fake_check_one_denied)
|
||||||
|
assert widget_id_allowed(wid, "p", object()) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_features_required_always_allowed(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"dashboard_widget_entitlements.get_widget_required_feature_ids",
|
||||||
|
lambda wid, conn: [],
|
||||||
|
)
|
||||||
|
assert widget_id_allowed("welcome", "p", object()) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_widget_not_allowed():
|
||||||
|
assert widget_id_allowed("not_in_catalog", "p", object()) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_widget_required_catalog_fallback(monkeypatch):
|
||||||
|
from widget_feature_requirements_db import get_widget_required_feature_ids
|
||||||
|
|
||||||
|
class _Cur:
|
||||||
|
def execute(self, *a, **k):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def fetchone(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("widget_feature_requirements_db.get_cursor", lambda _c: _Cur())
|
||||||
|
assert get_widget_required_feature_ids("quick_capture", object()) == ["weight_entries"]
|
||||||
|
|
@ -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 = "20260406d" # Migration 040
|
DB_SCHEMA_VERSION = "20260406e" # Migration 041
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"auth": "1.2.0",
|
"auth": "1.2.0",
|
||||||
|
|
@ -24,13 +24,13 @@ MODULE_VERSIONS = {
|
||||||
"photos": "1.0.0",
|
"photos": "1.0.0",
|
||||||
"insights": "1.3.0",
|
"insights": "1.3.0",
|
||||||
"prompts": "1.1.0",
|
"prompts": "1.1.0",
|
||||||
"admin": "1.3.0", # Dashboard Produkt-Standard (system_config) + catalog-full
|
"admin": "1.4.0", # Widget × Feature-Zuordnung (Migration 041)
|
||||||
"stats": "1.0.1",
|
"stats": "1.0.1",
|
||||||
"exportdata": "1.1.0",
|
"exportdata": "1.1.0",
|
||||||
"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.10.0", # Produkt-Standard aus system_config; Response-Form unverändert
|
"app_dashboard": "1.11.0", # Entitlements: DB-Override widget→features (AND), sonst Katalog
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
|
@ -38,6 +38,7 @@ CHANGELOG = [
|
||||||
"version": "0.9n",
|
"version": "0.9n",
|
||||||
"date": "2026-04-06",
|
"date": "2026-04-06",
|
||||||
"changes": [
|
"changes": [
|
||||||
|
"Admin: Widgets × Features (Migration 041, AND-Gates, Hybrid mit widget_catalog)",
|
||||||
"Admin: Produkt-Dashboard-Systemstandard (Migration 040 system_config, API, UI)",
|
"Admin: Produkt-Dashboard-Systemstandard (Migration 040 system_config, API, UI)",
|
||||||
"Phase 4: End Node Template Engine",
|
"Phase 4: End Node Template Engine",
|
||||||
"workflow_models.py: EndNodeOutputMode enum (AUTO, TEMPLATE)",
|
"workflow_models.py: EndNodeOutputMode enum (AUTO, TEMPLATE)",
|
||||||
|
|
|
||||||
107
backend/widget_feature_requirements_db.py
Normal file
107
backend/widget_feature_requirements_db.py
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
"""
|
||||||
|
DB-Override für Dashboard-Widget → Feature(s): AND-Semantik.
|
||||||
|
|
||||||
|
Ohne Zeile in dashboard_widget_requirement_custom → Fallback auf widget_catalog.requires_feature.
|
||||||
|
Mit Marker-Zeile → nur widget_feature_requirements (0..n, leer = kein Feature erforderlich).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from db import get_cursor, get_db
|
||||||
|
from widget_catalog import WIDGET_CATALOG
|
||||||
|
|
||||||
|
_WIDGET_ENTRY_BY_ID: dict[str, dict[str, Any]] = {e["id"]: e for e in WIDGET_CATALOG}
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_feature_ids_for_widget(widget_id: str) -> list[str]:
|
||||||
|
entry = _WIDGET_ENTRY_BY_ID.get(widget_id)
|
||||||
|
if not entry:
|
||||||
|
return []
|
||||||
|
fid = entry.get("requires_feature")
|
||||||
|
return [fid] if fid else []
|
||||||
|
|
||||||
|
|
||||||
|
def get_widget_required_feature_ids(widget_id: str, conn: Any | None) -> list[str]:
|
||||||
|
"""Liste der Feature-IDs, die alle erlaubt sein müssen (AND). Leer = ohne Feature-Gate."""
|
||||||
|
|
||||||
|
def _query(c: Any) -> list[str]:
|
||||||
|
cur = get_cursor(c)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM dashboard_widget_requirement_custom WHERE widget_id = %s",
|
||||||
|
(widget_id,),
|
||||||
|
)
|
||||||
|
if cur.fetchone() is None:
|
||||||
|
return _catalog_feature_ids_for_widget(widget_id)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT feature_id FROM widget_feature_requirements
|
||||||
|
WHERE widget_id = %s
|
||||||
|
ORDER BY feature_id
|
||||||
|
""",
|
||||||
|
(widget_id,),
|
||||||
|
)
|
||||||
|
return [row["feature_id"] for row in cur.fetchall()]
|
||||||
|
|
||||||
|
if conn is not None:
|
||||||
|
return _query(conn)
|
||||||
|
with get_db() as c:
|
||||||
|
return _query(c)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_assignments_bundle(conn: Any) -> tuple[set[str], dict[str, list[str]]]:
|
||||||
|
"""
|
||||||
|
Alle Custom-Marker und Junction-Zeilen für Admin-GET (ein Roundtrip).
|
||||||
|
Returns (custom_widget_ids, feature_ids_by_widget).
|
||||||
|
"""
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute("SELECT widget_id FROM dashboard_widget_requirement_custom")
|
||||||
|
custom_ids = {row["widget_id"] for row in cur.fetchall()}
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT widget_id, feature_id FROM widget_feature_requirements
|
||||||
|
ORDER BY widget_id, feature_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
by_w: dict[str, list[str]] = {}
|
||||||
|
for row in cur.fetchall():
|
||||||
|
by_w.setdefault(row["widget_id"], []).append(row["feature_id"])
|
||||||
|
return custom_ids, by_w
|
||||||
|
|
||||||
|
|
||||||
|
def set_custom_requirements(conn: Any, widget_id: str, feature_ids: list[str]) -> None:
|
||||||
|
"""Custom aktivieren und Anforderungen ersetzen (dedupliziert)."""
|
||||||
|
seen: set[str] = set()
|
||||||
|
unique: list[str] = []
|
||||||
|
for fid in feature_ids:
|
||||||
|
if fid and fid not in seen:
|
||||||
|
seen.add(fid)
|
||||||
|
unique.append(fid)
|
||||||
|
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO dashboard_widget_requirement_custom (widget_id)
|
||||||
|
VALUES (%s)
|
||||||
|
ON CONFLICT (widget_id) DO UPDATE SET updated_at = CURRENT_TIMESTAMP
|
||||||
|
""",
|
||||||
|
(widget_id,),
|
||||||
|
)
|
||||||
|
cur.execute("DELETE FROM widget_feature_requirements WHERE widget_id = %s", (widget_id,))
|
||||||
|
for fid in unique:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO widget_feature_requirements (widget_id, feature_id)
|
||||||
|
VALUES (%s, %s)
|
||||||
|
""",
|
||||||
|
(widget_id, fid),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_custom_requirements(conn: Any, widget_id: str) -> None:
|
||||||
|
"""Zurück auf Katalog-Fallback."""
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM dashboard_widget_requirement_custom WHERE widget_id = %s",
|
||||||
|
(widget_id,),
|
||||||
|
)
|
||||||
|
|
@ -40,6 +40,7 @@ import AdminPromptsPage from './pages/AdminPromptsPage'
|
||||||
import AdminGoalTypesPage from './pages/AdminGoalTypesPage'
|
import AdminGoalTypesPage from './pages/AdminGoalTypesPage'
|
||||||
import AdminFocusAreasPage from './pages/AdminFocusAreasPage'
|
import AdminFocusAreasPage from './pages/AdminFocusAreasPage'
|
||||||
import AdminReferenceValueTypesPage from './pages/AdminReferenceValueTypesPage'
|
import AdminReferenceValueTypesPage from './pages/AdminReferenceValueTypesPage'
|
||||||
|
import AdminWidgetFeatureAssignmentsPage from './pages/AdminWidgetFeatureAssignmentsPage'
|
||||||
import AdminHomePage from './pages/AdminHomePage'
|
import AdminHomePage from './pages/AdminHomePage'
|
||||||
import AdminUsersPage from './pages/AdminUsersPage'
|
import AdminUsersPage from './pages/AdminUsersPage'
|
||||||
import AdminSystemPage from './pages/AdminSystemPage'
|
import AdminSystemPage from './pages/AdminSystemPage'
|
||||||
|
|
@ -247,6 +248,7 @@ function AppShell() {
|
||||||
<Route path="tiers" element={<AdminTiersPage/>}/>
|
<Route path="tiers" element={<AdminTiersPage/>}/>
|
||||||
<Route path="coupons" element={<AdminCouponsPage/>}/>
|
<Route path="coupons" element={<AdminCouponsPage/>}/>
|
||||||
<Route path="user-restrictions" element={<AdminUserRestrictionsPage/>}/>
|
<Route path="user-restrictions" element={<AdminUserRestrictionsPage/>}/>
|
||||||
|
<Route path="widget-features" element={<AdminWidgetFeatureAssignmentsPage />} />
|
||||||
<Route path="training-types" element={<AdminTrainingTypesPage/>}/>
|
<Route path="training-types" element={<AdminTrainingTypesPage/>}/>
|
||||||
<Route path="activity-mappings" element={<AdminActivityMappingsPage/>}/>
|
<Route path="activity-mappings" element={<AdminActivityMappingsPage/>}/>
|
||||||
<Route path="training-profiles" element={<AdminTrainingProfiles/>}/>
|
<Route path="training-profiles" element={<AdminTrainingProfiles/>}/>
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,11 @@ export const ADMIN_GROUPS = [
|
||||||
label: 'User-Overrides',
|
label: 'User-Overrides',
|
||||||
description: 'Individuelle Feature-Limits setzen.',
|
description: 'Individuelle Feature-Limits setzen.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/admin/widget-features',
|
||||||
|
label: 'Widgets × Features',
|
||||||
|
description: 'Dashboard-Widgets den Registry-Features zuordnen (AND).',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
224
frontend/src/pages/AdminWidgetFeatureAssignmentsPage.jsx
Normal file
224
frontend/src/pages/AdminWidgetFeatureAssignmentsPage.jsx
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
import { Fragment, useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { LayoutGrid } from 'lucide-react'
|
||||||
|
import { api, formatFastApiDetail } from '../utils/api'
|
||||||
|
|
||||||
|
export default function AdminWidgetFeatureAssignmentsPage() {
|
||||||
|
const [bundle, setBundle] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState('')
|
||||||
|
const [expandedId, setExpandedId] = useState(null)
|
||||||
|
const [localFeatureIds, setLocalFeatureIds] = useState([])
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const d = await api.adminGetWidgetFeatureAssignments()
|
||||||
|
setBundle(d)
|
||||||
|
} catch (e) {
|
||||||
|
setError(formatFastApiDetail(null, e.message))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const openRow = (w) => {
|
||||||
|
setExpandedId(w.id)
|
||||||
|
setSuccess('')
|
||||||
|
setError('')
|
||||||
|
const base = w.uses_custom_requirements ? w.feature_ids : w.catalog_feature_ids
|
||||||
|
setLocalFeatureIds([...(base || [])])
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleFeature = (fid) => {
|
||||||
|
setLocalFeatureIds((prev) => {
|
||||||
|
const s = new Set(prev)
|
||||||
|
if (s.has(fid)) s.delete(fid)
|
||||||
|
else s.add(fid)
|
||||||
|
return [...s].sort()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveCatalog = async (widgetId) => {
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
setSuccess('')
|
||||||
|
try {
|
||||||
|
await api.adminPutWidgetFeatureAssignment(widgetId, { mode: 'catalog' })
|
||||||
|
setSuccess('Auf Katalog-Fallback zurückgesetzt.')
|
||||||
|
setExpandedId(null)
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
setError(formatFastApiDetail(null, e.message))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveCustom = async (widgetId) => {
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
setSuccess('')
|
||||||
|
try {
|
||||||
|
await api.adminPutWidgetFeatureAssignment(widgetId, {
|
||||||
|
mode: 'custom',
|
||||||
|
feature_ids: localFeatureIds,
|
||||||
|
})
|
||||||
|
setSuccess('Feature-Zuordnung gespeichert (AND: alle müssen erlaubt sein).')
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
setError(formatFastApiDetail(null, e.message))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bundle && !error) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 48, textAlign: 'center' }}>
|
||||||
|
<div className="spinner" style={{ width: 32, height: 32, margin: '0 auto' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '0 16px 96px', maxWidth: 920, margin: '0 auto', textAlign: 'left' }}>
|
||||||
|
<Link
|
||||||
|
to="/admin/g/features"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ display: 'inline-flex', marginBottom: 12, textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
← Features (Admin)
|
||||||
|
</Link>
|
||||||
|
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<LayoutGrid size={26} color="var(--accent)" />
|
||||||
|
Widgets × Features
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 16 }}>
|
||||||
|
Ordnet jedes Dashboard-Widget einer oder mehreren Features aus der Registry zu. Ohne Eintrag gilt der
|
||||||
|
Vorgabewert aus dem Code-Katalog (<code>requires_feature</code>). Mit <strong>Custom</strong> müssen{' '}
|
||||||
|
<strong>alle</strong> gewählten Features für den Nutzer erlaubt sein. Leere Auswahl = Widget ohne
|
||||||
|
Feature-Gate.
|
||||||
|
</p>
|
||||||
|
{error && (
|
||||||
|
<p className="card section-gap" style={{ color: '#D85A30', padding: 12 }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<p className="card section-gap" style={{ color: 'var(--accent)', padding: 12 }}>
|
||||||
|
{success}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card section-gap" style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<th style={{ padding: '10px 8px' }}>Widget</th>
|
||||||
|
<th style={{ padding: '10px 8px' }}>Modus / Anforderungen</th>
|
||||||
|
<th style={{ padding: '10px 8px' }} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{bundle?.widgets?.map((w) => {
|
||||||
|
const expanded = expandedId === w.id
|
||||||
|
const summary = w.uses_custom_requirements
|
||||||
|
? `Custom: ${w.feature_ids.length ? w.feature_ids.join(', ') : '— (kein Feature)'}`
|
||||||
|
: `Katalog: ${w.catalog_feature_ids.length ? w.catalog_feature_ids.join(', ') : '—'}`
|
||||||
|
return (
|
||||||
|
<Fragment key={w.id}>
|
||||||
|
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<td style={{ padding: '12px 8px', verticalAlign: 'top' }}>
|
||||||
|
<div style={{ fontWeight: 600 }}>{w.title}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>{w.id}</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 8px', verticalAlign: 'top' }}>
|
||||||
|
<div style={{ fontSize: 12 }}>{summary}</div>
|
||||||
|
{w.description && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 6 }}>{w.description}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px 8px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
onClick={() => (expanded ? setExpandedId(null) : openRow(w))}
|
||||||
|
>
|
||||||
|
{expanded ? 'Schließen' : 'Bearbeiten'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{expanded && (
|
||||||
|
<tr style={{ background: 'var(--surface2)' }}>
|
||||||
|
<td colSpan={3} style={{ padding: 16 }}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 10 }}>Features (mehrfach, AND)</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(bundle.features || []).map((f) => (
|
||||||
|
<label
|
||||||
|
key={f.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={localFeatureIds.includes(f.id)}
|
||||||
|
onChange={() => toggleFeature(f.id)}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span style={{ fontWeight: 500 }}>{f.name}</span>
|
||||||
|
<span style={{ color: 'var(--text3)', marginLeft: 6 }}>({f.id})</span>
|
||||||
|
{!f.active && (
|
||||||
|
<span style={{ color: 'var(--text3)', display: 'block' }}>inaktiv</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => saveCustom(w.id)}
|
||||||
|
>
|
||||||
|
Custom speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => saveCatalog(w.id)}
|
||||||
|
>
|
||||||
|
Katalog-Fallback
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -83,6 +83,10 @@ export const api = {
|
||||||
adminDeleteDashboardProductDefault: () =>
|
adminDeleteDashboardProductDefault: () =>
|
||||||
req('/admin/dashboard-product-default', { method: 'DELETE' }),
|
req('/admin/dashboard-product-default', { method: 'DELETE' }),
|
||||||
|
|
||||||
|
adminGetWidgetFeatureAssignments: () => req('/admin/widget-feature-assignments'),
|
||||||
|
adminPutWidgetFeatureAssignment: (widgetId, body) =>
|
||||||
|
req(`/admin/widget-feature-assignments/${encodeURIComponent(widgetId)}`, jput(body)),
|
||||||
|
|
||||||
// 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