mitai-jinkendo/frontend/src/pages/AdminWidgetFeatureAssignmentsPage.jsx
Lars 24daeeb83c
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s
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.
2026-04-08 12:26:28 +02:00

225 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}