mitai-jinkendo/frontend/src/pages/AdminWidgetFeatureAssignmentsPage.jsx

225 lines
8.5 KiB
React
Raw Normal View History

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