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 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:
|
||||
|
|
@ -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:
|
||||
entry = _WIDGET_ENTRY_BY_ID.get(widget_id)
|
||||
if entry is None:
|
||||
if _WIDGET_ENTRY_BY_ID.get(widget_id) is None:
|
||||
return False
|
||||
fid = entry.get("requires_feature")
|
||||
if not fid:
|
||||
fids = get_widget_required_feature_ids(widget_id, conn)
|
||||
if not fids:
|
||||
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]:
|
||||
|
|
@ -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)."""
|
||||
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"])
|
||||
allowed = widget_id_allowed(e["id"], profile_id, conn)
|
||||
out.append(_public_row(e, allowed=allowed))
|
||||
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 auth import require_admin, hash_pin
|
||||
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 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 (
|
||||
delete_product_default_override,
|
||||
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)
|
||||
layout = get_product_default_base_dict(conn)
|
||||
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"
|
||||
BUILD_DATE = "2026-04-05"
|
||||
DB_SCHEMA_VERSION = "20260406d" # Migration 040
|
||||
DB_SCHEMA_VERSION = "20260406e" # Migration 041
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"auth": "1.2.0",
|
||||
|
|
@ -24,13 +24,13 @@ MODULE_VERSIONS = {
|
|||
"photos": "1.0.0",
|
||||
"insights": "1.3.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",
|
||||
"exportdata": "1.1.0",
|
||||
"importdata": "1.0.0",
|
||||
"membership": "2.1.0",
|
||||
"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 = [
|
||||
|
|
@ -38,6 +38,7 @@ CHANGELOG = [
|
|||
"version": "0.9n",
|
||||
"date": "2026-04-06",
|
||||
"changes": [
|
||||
"Admin: Widgets × Features (Migration 041, AND-Gates, Hybrid mit widget_catalog)",
|
||||
"Admin: Produkt-Dashboard-Systemstandard (Migration 040 system_config, API, UI)",
|
||||
"Phase 4: End Node Template Engine",
|
||||
"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 AdminFocusAreasPage from './pages/AdminFocusAreasPage'
|
||||
import AdminReferenceValueTypesPage from './pages/AdminReferenceValueTypesPage'
|
||||
import AdminWidgetFeatureAssignmentsPage from './pages/AdminWidgetFeatureAssignmentsPage'
|
||||
import AdminHomePage from './pages/AdminHomePage'
|
||||
import AdminUsersPage from './pages/AdminUsersPage'
|
||||
import AdminSystemPage from './pages/AdminSystemPage'
|
||||
|
|
@ -247,6 +248,7 @@ function AppShell() {
|
|||
<Route path="tiers" element={<AdminTiersPage/>}/>
|
||||
<Route path="coupons" element={<AdminCouponsPage/>}/>
|
||||
<Route path="user-restrictions" element={<AdminUserRestrictionsPage/>}/>
|
||||
<Route path="widget-features" element={<AdminWidgetFeatureAssignmentsPage />} />
|
||||
<Route path="training-types" element={<AdminTrainingTypesPage/>}/>
|
||||
<Route path="activity-mappings" element={<AdminActivityMappingsPage/>}/>
|
||||
<Route path="training-profiles" element={<AdminTrainingProfiles/>}/>
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ export const ADMIN_GROUPS = [
|
|||
label: 'User-Overrides',
|
||||
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: () =>
|
||||
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)
|
||||
listReferenceValueTypes: () => req('/reference-value-types'),
|
||||
listReferenceValueMetaEnums: () => req('/reference-value-meta/enums'),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user