All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 35s
Test Suite / playwright-tests (push) Successful in 1m18s
Test Suite / pytest-backend (pull_request) Successful in 38s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 13s
Test Suite / k6 /health Baseline (pull_request) Successful in 38s
Test Suite / playwright-tests (pull_request) Successful in 1m12s
- Introduced a new admin user content management endpoint for superadmins, allowing for moderation of user-generated content. - Updated the backend to include new API functions for retrieving, patching, and deleting user content items. - Enhanced the frontend with a new Admin User Content page and navigation link for easy access to user content management. - Updated access layer documentation to reflect the new endpoint and its exempt status. - Incremented version to 0.8.191 and updated changelog to document these additions in admin functionality.
682 lines
23 KiB
JavaScript
682 lines
23 KiB
JavaScript
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { Link, Navigate } from 'react-router-dom'
|
|
import { useAuth } from '../context/AuthContext'
|
|
import api from '../utils/api'
|
|
import AdminPageNav from '../components/AdminPageNav'
|
|
|
|
const VISIBILITY_OPTIONS = [
|
|
{ value: 'all', label: 'Alle Sichtbarkeiten' },
|
|
{ value: 'private', label: 'Privat' },
|
|
{ value: 'club', label: 'Verein' },
|
|
{ value: 'official', label: 'Offiziell' },
|
|
]
|
|
|
|
const VISIBILITY_LABEL = {
|
|
private: 'Privat',
|
|
club: 'Verein',
|
|
official: 'Offiziell',
|
|
}
|
|
|
|
const STATUS_LABELS = {
|
|
draft: 'Entwurf',
|
|
in_review: 'In Prüfung',
|
|
approved: 'Freigegeben',
|
|
archived: 'Archiviert',
|
|
active: 'Aktiv',
|
|
legacy_unreviewed: 'Rechte ungeprüft',
|
|
declared: 'Rechte erklärt',
|
|
blocked: 'Gesperrt',
|
|
}
|
|
|
|
const LIFECYCLE_LABELS = {
|
|
active: 'Aktiv',
|
|
trash_soft: 'Papierkorb (soft)',
|
|
trash_hidden: 'Papierkorb (hidden)',
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) return '—'
|
|
try {
|
|
return new Date(value).toLocaleString('de-DE', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
} catch {
|
|
return String(value)
|
|
}
|
|
}
|
|
|
|
function contentLink(item) {
|
|
const id = item.id
|
|
switch (item.content_type) {
|
|
case 'exercise':
|
|
return `/exercises/${id}`
|
|
case 'training_module':
|
|
return `/planning/training-modules/${id}`
|
|
case 'framework_program':
|
|
return `/planning/framework-programs/${id}`
|
|
case 'plan_template':
|
|
return `/planning/plan-templates/${id}`
|
|
case 'maturity_model':
|
|
return '/admin/maturity-models'
|
|
case 'media_asset':
|
|
return '/media'
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
function statusOptionsForType(meta, contentType) {
|
|
const t = meta?.content_types?.find((x) => x.id === contentType)
|
|
return (t?.status_values || []).map((v) => ({
|
|
value: v,
|
|
label: STATUS_LABELS[v] || v,
|
|
}))
|
|
}
|
|
|
|
function EditModal({ open, item, meta, onClose, onSaved }) {
|
|
const [status, setStatus] = useState('')
|
|
const [visibility, setVisibility] = useState('')
|
|
const [lifecycle, setLifecycle] = useState('')
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState('')
|
|
|
|
useEffect(() => {
|
|
if (!item) return
|
|
setStatus(item.status || '')
|
|
setVisibility(item.visibility || '')
|
|
setLifecycle(item.extra_status || 'active')
|
|
setError('')
|
|
}, [item])
|
|
|
|
if (!open || !item) return null
|
|
|
|
const typeMeta = meta?.content_types?.find((x) => x.id === item.content_type)
|
|
const statusOpts = statusOptionsForType(meta, item.content_type)
|
|
|
|
const submit = async () => {
|
|
setSaving(true)
|
|
setError('')
|
|
try {
|
|
const body = {}
|
|
if (typeMeta?.has_status && status && status !== item.status) body.status = status
|
|
if (typeMeta?.has_visibility && visibility && visibility !== item.visibility) {
|
|
body.visibility = visibility
|
|
}
|
|
if (item.content_type === 'media_asset' && lifecycle && lifecycle !== item.extra_status) {
|
|
body.lifecycle_state = lifecycle
|
|
}
|
|
if (!Object.keys(body).length) {
|
|
onClose()
|
|
return
|
|
}
|
|
await api.patchAdminUserContentItem(item.content_type, item.id, body)
|
|
await onSaved()
|
|
onClose()
|
|
} catch (e) {
|
|
setError(e.message || String(e))
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="modal-overlay" role="dialog" aria-modal="true">
|
|
<div className="card modal-card" style={{ maxWidth: 480, width: '100%' }}>
|
|
<h2 style={{ marginTop: 0 }}>Inhalt bearbeiten</h2>
|
|
<p className="text-muted" style={{ marginTop: 0 }}>
|
|
{item.type_label} · #{item.id}
|
|
</p>
|
|
<p style={{ fontWeight: 600 }}>{item.title || '—'}</p>
|
|
|
|
{typeMeta?.has_status ? (
|
|
<div className="form-row" style={{ marginTop: 16 }}>
|
|
<label className="form-label" htmlFor="uc-status">
|
|
Status
|
|
</label>
|
|
<select
|
|
id="uc-status"
|
|
className="form-input"
|
|
value={status}
|
|
onChange={(e) => setStatus(e.target.value)}
|
|
>
|
|
{statusOpts.map((o) => (
|
|
<option key={o.value} value={o.value}>
|
|
{o.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
) : null}
|
|
|
|
{typeMeta?.has_visibility ? (
|
|
<div className="form-row">
|
|
<label className="form-label" htmlFor="uc-vis">
|
|
Sichtbarkeit
|
|
</label>
|
|
<select
|
|
id="uc-vis"
|
|
className="form-input"
|
|
value={visibility}
|
|
onChange={(e) => setVisibility(e.target.value)}
|
|
>
|
|
{VISIBILITY_OPTIONS.filter((o) => o.value !== 'all').map((o) => (
|
|
<option key={o.value} value={o.value}>
|
|
{o.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
) : null}
|
|
|
|
{item.content_type === 'media_asset' ? (
|
|
<div className="form-row">
|
|
<label className="form-label" htmlFor="uc-lc">
|
|
Lifecycle
|
|
</label>
|
|
<select
|
|
id="uc-lc"
|
|
className="form-input"
|
|
value={lifecycle}
|
|
onChange={(e) => setLifecycle(e.target.value)}
|
|
>
|
|
{Object.entries(LIFECYCLE_LABELS).map(([v, label]) => (
|
|
<option key={v} value={v}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
) : null}
|
|
|
|
{error ? (
|
|
<p style={{ color: 'var(--danger)', marginTop: 12 }} role="alert">
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 20, justifyContent: 'flex-end' }}>
|
|
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={saving}>
|
|
Abbrechen
|
|
</button>
|
|
<button type="button" className="btn btn-primary" onClick={submit} disabled={saving}>
|
|
{saving ? 'Speichern…' : 'Speichern'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function AdminUserContentPage() {
|
|
const { user } = useAuth()
|
|
const isSuperadmin = user?.role === 'superadmin'
|
|
|
|
const [meta, setMeta] = useState(null)
|
|
const [userSummary, setUserSummary] = useState([])
|
|
const [items, setItems] = useState([])
|
|
const [total, setTotal] = useState(0)
|
|
const [loading, setLoading] = useState(true)
|
|
const [itemsLoading, setItemsLoading] = useState(false)
|
|
const [error, setError] = useState('')
|
|
|
|
const [profileId, setProfileId] = useState('')
|
|
const [contentType, setContentType] = useState('all')
|
|
const [visibility, setVisibility] = useState('all')
|
|
const [status, setStatus] = useState('')
|
|
const [search, setSearch] = useState('')
|
|
const [offset, setOffset] = useState(0)
|
|
const limit = 50
|
|
|
|
const [editItem, setEditItem] = useState(null)
|
|
|
|
const contentTypeOptions = useMemo(() => {
|
|
const base = [{ value: 'all', label: 'Alle Typen' }]
|
|
for (const t of meta?.content_types || []) {
|
|
base.push({ value: t.id, label: t.label })
|
|
}
|
|
return base
|
|
}, [meta])
|
|
|
|
const statusFilterOptions = useMemo(() => {
|
|
if (contentType === 'all') {
|
|
return [
|
|
{ value: '', label: 'Beliebiger Status' },
|
|
{ value: 'draft', label: STATUS_LABELS.draft },
|
|
{ value: 'in_review', label: STATUS_LABELS.in_review },
|
|
{ value: 'approved', label: STATUS_LABELS.approved },
|
|
{ value: 'archived', label: STATUS_LABELS.archived },
|
|
{ value: 'active', label: STATUS_LABELS.active },
|
|
{ value: 'legacy_unreviewed', label: STATUS_LABELS.legacy_unreviewed },
|
|
]
|
|
}
|
|
return [
|
|
{ value: '', label: 'Beliebiger Status' },
|
|
...statusOptionsForType(meta, contentType),
|
|
]
|
|
}, [contentType, meta])
|
|
|
|
const loadSummary = useCallback(async () => {
|
|
const [m, s] = await Promise.all([api.getAdminUserContentMeta(), api.listAdminUserContentSummary()])
|
|
setMeta(m)
|
|
setUserSummary(Array.isArray(s) ? s : [])
|
|
}, [])
|
|
|
|
const loadItems = useCallback(async (forcedOffset) => {
|
|
setItemsLoading(true)
|
|
try {
|
|
const params = {
|
|
content_type: contentType,
|
|
visibility,
|
|
limit,
|
|
offset: forcedOffset ?? offset,
|
|
}
|
|
if (profileId) params.profile_id = Number(profileId)
|
|
if (status) params.status = status
|
|
if (search.trim()) params.search = search.trim()
|
|
const res = await api.listAdminUserContentItems(params)
|
|
setItems(Array.isArray(res?.items) ? res.items : [])
|
|
setTotal(Number(res?.total) || 0)
|
|
} finally {
|
|
setItemsLoading(false)
|
|
}
|
|
}, [contentType, visibility, status, search, profileId, offset])
|
|
|
|
useEffect(() => {
|
|
if (!isSuperadmin) return
|
|
let cancelled = false
|
|
;(async () => {
|
|
setLoading(true)
|
|
setError('')
|
|
try {
|
|
await loadSummary()
|
|
} catch (e) {
|
|
if (!cancelled) setError(e.message || String(e))
|
|
} finally {
|
|
if (!cancelled) setLoading(false)
|
|
}
|
|
})()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [isSuperadmin, loadSummary])
|
|
|
|
useEffect(() => {
|
|
if (!isSuperadmin) return
|
|
let cancelled = false
|
|
;(async () => {
|
|
try {
|
|
await loadItems()
|
|
} catch (e) {
|
|
if (!cancelled) setError(e.message || String(e))
|
|
}
|
|
})()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [isSuperadmin, loadItems])
|
|
|
|
const applyFilters = () => {
|
|
setOffset(0)
|
|
loadItems(0)
|
|
}
|
|
|
|
const handleDelete = async (item) => {
|
|
const label = item.title || `${item.type_label} #${item.id}`
|
|
if (
|
|
!confirm(
|
|
`„${label}" wirklich endgültig löschen?\n\nDiese Aktion kann nicht rückgängig gemacht werden.`,
|
|
)
|
|
) {
|
|
return
|
|
}
|
|
try {
|
|
await api.deleteAdminUserContentItem(item.content_type, item.id)
|
|
await Promise.all([loadItems(), loadSummary()])
|
|
} catch (e) {
|
|
alert(e.message || String(e))
|
|
}
|
|
}
|
|
|
|
if (!isSuperadmin) return <Navigate to="/" replace />
|
|
|
|
return (
|
|
<div className="page" style={{ paddingBottom: 96 }}>
|
|
<AdminPageNav />
|
|
<header style={{ marginBottom: 20 }}>
|
|
<h1 style={{ margin: '0 0 8px' }}>Nutzer-Inhalte</h1>
|
|
<p className="text-muted" style={{ margin: 0 }}>
|
|
Aktivitäten aller Nutzer einsehen — inklusive privater Inhalte. Status setzen oder Inhalte
|
|
löschen (nur Superadmin).
|
|
</p>
|
|
</header>
|
|
|
|
{error ? (
|
|
<div className="card" style={{ borderColor: 'var(--danger)', marginBottom: 16 }}>
|
|
<p style={{ margin: 0, color: 'var(--danger)' }}>{error}</p>
|
|
</div>
|
|
) : null}
|
|
|
|
{loading ? (
|
|
<div className="spinner" aria-label="Laden" />
|
|
) : (
|
|
<>
|
|
<section className="card" style={{ marginBottom: 16 }}>
|
|
<h2 style={{ marginTop: 0, fontSize: '1.05rem' }}>Aktivität je Nutzer</h2>
|
|
{userSummary.length === 0 ? (
|
|
<p className="text-muted" style={{ margin: 0 }}>
|
|
Noch keine nutzerangelegten Inhalte.
|
|
</p>
|
|
) : (
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<table className="data-table" style={{ width: '100%', minWidth: 640 }}>
|
|
<thead>
|
|
<tr>
|
|
<th>Nutzer</th>
|
|
<th>Gesamt</th>
|
|
{(meta?.content_types || []).map((t) => (
|
|
<th key={t.id}>{t.label}</th>
|
|
))}
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{userSummary.map((u) => (
|
|
<tr key={u.id}>
|
|
<td>
|
|
<div style={{ fontWeight: 600 }}>{u.name || `Profil #${u.id}`}</div>
|
|
<div className="text-muted" style={{ fontSize: '0.85rem' }}>
|
|
{u.email || '—'}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<strong>{u.total_count}</strong>
|
|
</td>
|
|
{(meta?.content_types || []).map((t) => (
|
|
<td key={t.id}>{u.counts_by_type?.[t.id] ?? 0}</td>
|
|
))}
|
|
<td>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
style={{ fontSize: '0.85rem', padding: '4px 10px' }}
|
|
onClick={() => {
|
|
setProfileId(String(u.id))
|
|
setOffset(0)
|
|
}}
|
|
>
|
|
Filtern
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="card">
|
|
<h2 style={{ marginTop: 0, fontSize: '1.05rem' }}>Inhalte</h2>
|
|
|
|
<div
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))',
|
|
gap: 12,
|
|
marginBottom: 16,
|
|
}}
|
|
>
|
|
<div>
|
|
<label className="form-label" htmlFor="uc-user">
|
|
Nutzer
|
|
</label>
|
|
<select
|
|
id="uc-user"
|
|
className="form-input"
|
|
value={profileId}
|
|
onChange={(e) => {
|
|
setProfileId(e.target.value)
|
|
setOffset(0)
|
|
}}
|
|
>
|
|
<option value="">Alle Nutzer</option>
|
|
{userSummary.map((u) => (
|
|
<option key={u.id} value={u.id}>
|
|
{u.name || u.email || `#${u.id}`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="form-label" htmlFor="uc-type">
|
|
Typ
|
|
</label>
|
|
<select
|
|
id="uc-type"
|
|
className="form-input"
|
|
value={contentType}
|
|
onChange={(e) => {
|
|
setContentType(e.target.value)
|
|
setStatus('')
|
|
setOffset(0)
|
|
}}
|
|
>
|
|
{contentTypeOptions.map((o) => (
|
|
<option key={o.value} value={o.value}>
|
|
{o.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="form-label" htmlFor="uc-vis-filter">
|
|
Sichtbarkeit
|
|
</label>
|
|
<select
|
|
id="uc-vis-filter"
|
|
className="form-input"
|
|
value={visibility}
|
|
onChange={(e) => {
|
|
setVisibility(e.target.value)
|
|
setOffset(0)
|
|
}}
|
|
>
|
|
{VISIBILITY_OPTIONS.map((o) => (
|
|
<option key={o.value} value={o.value}>
|
|
{o.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="form-label" htmlFor="uc-status-filter">
|
|
Status
|
|
</label>
|
|
<select
|
|
id="uc-status-filter"
|
|
className="form-input"
|
|
value={status}
|
|
onChange={(e) => {
|
|
setStatus(e.target.value)
|
|
setOffset(0)
|
|
}}
|
|
>
|
|
{statusFilterOptions.map((o) => (
|
|
<option key={o.value || '_any'} value={o.value}>
|
|
{o.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div style={{ gridColumn: 'span 2' }}>
|
|
<label className="form-label" htmlFor="uc-search">
|
|
Suche (Titel)
|
|
</label>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<input
|
|
id="uc-search"
|
|
className="form-input"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
|
|
placeholder="Titel oder Dateiname…"
|
|
/>
|
|
<button type="button" className="btn btn-primary" onClick={applyFilters}>
|
|
Suchen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{itemsLoading ? (
|
|
<div className="spinner" aria-label="Inhalte laden" />
|
|
) : items.length === 0 ? (
|
|
<p className="text-muted">Keine Inhalte für die aktuellen Filter.</p>
|
|
) : (
|
|
<div style={{ overflowX: 'auto' }}>
|
|
<table className="data-table" style={{ width: '100%', minWidth: 900 }}>
|
|
<thead>
|
|
<tr>
|
|
<th>Typ</th>
|
|
<th>Titel</th>
|
|
<th>Nutzer</th>
|
|
<th>Sichtbarkeit</th>
|
|
<th>Status</th>
|
|
<th>Aktualisiert</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{items.map((item) => {
|
|
const href = contentLink(item)
|
|
return (
|
|
<tr key={`${item.content_type}-${item.id}`}>
|
|
<td>{item.type_label}</td>
|
|
<td>
|
|
<div style={{ fontWeight: 600, maxWidth: 280 }}>
|
|
{href ? (
|
|
<Link to={href}>{item.title || '—'}</Link>
|
|
) : (
|
|
item.title || '—'
|
|
)}
|
|
</div>
|
|
<div className="text-muted" style={{ fontSize: '0.8rem' }}>
|
|
#{item.id}
|
|
{item.club_name ? ` · ${item.club_name}` : ''}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div>{item.profile_name || '—'}</div>
|
|
<div className="text-muted" style={{ fontSize: '0.8rem' }}>
|
|
{item.profile_email || (item.profile_id ? `#${item.profile_id}` : '—')}
|
|
</div>
|
|
</td>
|
|
<td>
|
|
{item.visibility ? (
|
|
<span
|
|
style={{
|
|
color:
|
|
item.visibility === 'private' ? 'var(--text2)' : 'var(--accent)',
|
|
}}
|
|
>
|
|
{VISIBILITY_LABEL[item.visibility] || item.visibility}
|
|
</span>
|
|
) : (
|
|
'—'
|
|
)}
|
|
</td>
|
|
<td>
|
|
{item.status ? STATUS_LABELS[item.status] || item.status : '—'}
|
|
{item.extra_status && item.extra_status !== 'active' ? (
|
|
<div className="text-muted" style={{ fontSize: '0.8rem' }}>
|
|
{LIFECYCLE_LABELS[item.extra_status] || item.extra_status}
|
|
</div>
|
|
) : null}
|
|
</td>
|
|
<td style={{ whiteSpace: 'nowrap' }}>{formatDate(item.updated_at)}</td>
|
|
<td>
|
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
style={{ fontSize: '0.8rem', padding: '4px 8px' }}
|
|
onClick={() => setEditItem(item)}
|
|
>
|
|
Bearbeiten
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
style={{
|
|
fontSize: '0.8rem',
|
|
padding: '4px 8px',
|
|
color: 'var(--danger)',
|
|
}}
|
|
onClick={() => handleDelete(item)}
|
|
>
|
|
Löschen
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{total > limit ? (
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginTop: 16,
|
|
}}
|
|
>
|
|
<span className="text-muted" style={{ fontSize: '0.9rem' }}>
|
|
{total} Einträge · Seite {Math.floor(offset / limit) + 1} von{' '}
|
|
{Math.ceil(total / limit)}
|
|
</span>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
disabled={offset <= 0}
|
|
onClick={() => setOffset(Math.max(0, offset - limit))}
|
|
>
|
|
Zurück
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
disabled={offset + limit >= total}
|
|
onClick={() => setOffset(offset + limit)}
|
|
>
|
|
Weiter
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
</>
|
|
)}
|
|
|
|
<EditModal
|
|
open={!!editItem}
|
|
item={editItem}
|
|
meta={meta}
|
|
onClose={() => setEditItem(null)}
|
|
onSaved={async () => {
|
|
await Promise.all([loadItems(), loadSummary()])
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|