- 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.
225 lines
8.5 KiB
JavaScript
225 lines
8.5 KiB
JavaScript
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>
|
||
)
|
||
}
|