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