shinkan-jinkendo/frontend/src/pages/InboxPage.jsx
Lars 5cf61289ec
Some checks failed
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Failing after 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 10s
Test Suite / playwright-tests (push) Successful in 1m3s
feat(P-13): Club-Admin Bearbeitung + Archiv-Trennung in Inbox
Berechtigungen:
- Club-Admins koennen Meldungen zu Vereinsmedien bearbeiten (PATCH + GET-Detail)
- Club-Admins koennen Legal Hold auf Vereinsmedien (visibility != 'official') setzen
- Neue Helfer: _assert_can_manage_report, _assert_can_set_legal_hold_from_report
- set_legal_hold_from_report: Superadmin-Only aufgehoben fuer Vereinsebene

Inbox UI:
- Offene Meldungen (submitted/under_review) im Hauptbereich
- Abgeschlossene Meldungen im kollabierbaren Archiv (standardmaessig zugeklappt)
- Legal-Hold-Button sichtbar fuer Club-Admins bei nicht-offiziellen Medien
- isClubAdmin + isPlatformAdmin aus OrgInboxContext verfuegbar

version: 0.8.93

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 22:15:29 +02:00

675 lines
27 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 React, { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { notifyOrgInboxChanged, useOrgInbox } from '../context/OrgInboxContext'
const CLUB_ROLE_OPTIONS = [
{ code: 'club_admin', label: 'Vereinsadmin' },
{ code: 'trainer', label: 'Trainer' },
{ code: 'division_lead', label: 'Spartenleitung' },
{ code: 'content_editor', label: 'Inhalte bearbeiten' },
]
const REASON_LABELS = {
copyright: 'Urheberrecht',
image_rights: 'Bildrechte',
privacy: 'Datenschutz / Persönlichkeitsrecht',
minors: 'Minderjährige',
illegal_content: 'Rechtswidriger Inhalt',
youth_protection: 'Jugendschutz',
offensive_content: 'Beleidigender Inhalt',
other: 'Sonstiges',
}
const STATUS_LABELS = {
submitted: 'Eingegangen',
under_review: 'In Bearbeitung',
resolved_no_action: 'Abgeschlossen (kein Handlungsbedarf)',
resolved_legal_hold: 'Abgeschlossen (Legal Hold)',
rejected_invalid: 'Abgewiesen (ungültig)',
}
const STATUS_COLORS = {
submitted: 'var(--accent)',
under_review: '#e8960a',
resolved_no_action: 'var(--text3)',
resolved_legal_hold: 'var(--danger)',
rejected_invalid: 'var(--text3)',
}
function formatWhen(iso) {
if (!iso) return ''
const s = String(iso)
const d = s.includes('T') ? s.split('T')[0] : s.slice(0, 10)
const t = s.includes('T') ? s.split('T')[1] : ''
const time = t ? t.slice(0, 5) : ''
return time ? `${d} · ${time}` : d
}
function PriorityBadge({ priority }) {
if (priority !== 'high') return null
return (
<span
style={{
background: 'var(--danger)',
color: '#fff',
borderRadius: '4px',
padding: '2px 7px',
fontSize: '0.72rem',
fontWeight: 600,
marginLeft: '0.4rem',
verticalAlign: 'middle',
}}
>
DRINGEND
</span>
)
}
const WORKFLOW_STEPS = [
{ key: 'submitted', label: 'Eingegangen' },
{ key: 'under_review', label: 'In Bearbeitung' },
{ key: 'closed', label: 'Abgeschlossen' },
]
function WorkflowBar({ status }) {
const closed = ['resolved_no_action', 'resolved_legal_hold', 'rejected_invalid'].includes(status)
const step = closed ? 2 : status === 'under_review' ? 1 : 0
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 0, marginBottom: '1.25rem' }}>
{WORKFLOW_STEPS.map((s, i) => {
const active = i === step
const done = i < step
return (
<React.Fragment key={s.key}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: 0, flex: 1 }}>
<div style={{
width: 28, height: 28, borderRadius: '50%',
background: active ? 'var(--accent)' : done ? 'var(--accent-dark)' : 'var(--surface2)',
border: active ? '2px solid var(--accent)' : done ? '2px solid var(--accent-dark)' : '2px solid var(--border)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: (active || done) ? '#fff' : 'var(--text3)',
fontSize: '0.75rem', fontWeight: 700,
}}>
{done ? '✓' : i + 1}
</div>
<span style={{ fontSize: '0.7rem', marginTop: 4, color: active ? 'var(--accent)' : 'var(--text3)', fontWeight: active ? 600 : 400, whiteSpace: 'nowrap' }}>
{s.label}
</span>
</div>
{i < WORKFLOW_STEPS.length - 1 && (
<div style={{ flex: 1, height: 2, background: i < step ? 'var(--accent-dark)' : 'var(--border)', marginBottom: 18 }} />
)}
</React.Fragment>
)
})}
</div>
)
}
function ReportDetailModal({ report, onClose, onRefresh, isSuperadmin, isClubAdmin }) {
const [resolutionNote, setResolutionNote] = useState(report.resolution_note || '')
const [legalHoldNote, setLegalHoldNote] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [showLegalHoldForm, setShowLegalHoldForm] = useState(false)
const isClosed = ['resolved_no_action', 'resolved_legal_hold', 'rejected_invalid'].includes(report.status)
const isOpen = !isClosed
async function patchAndClose(body) {
setSaving(true)
setError(null)
try {
await api.patchContentReport(report.id, body)
notifyOrgInboxChanged()
onRefresh()
onClose()
} catch (err) {
setError(err.message || String(err))
setSaving(false)
}
}
async function handleStatus(status) {
if ((status === 'resolved_no_action' || status === 'rejected_invalid') && !resolutionNote.trim()) {
setError('Bitte eine Begründung eingeben.')
return
}
await patchAndClose({ status, resolution_note: resolutionNote.trim() || undefined })
}
async function handleSaveNote() {
if (!resolutionNote.trim()) { setError('Notiz ist leer.'); return }
await patchAndClose({ resolution_note: resolutionNote.trim() })
}
async function handleLegalHold() {
if (!legalHoldNote.trim()) { setError('Begründung für Legal Hold erforderlich.'); return }
await patchAndClose({ legalHoldNote })
}
async function handleLegalHoldSubmit() {
if (!legalHoldNote.trim()) { setError('Begründung für Legal Hold erforderlich.'); return }
setSaving(true)
setError(null)
try {
await api.setLegalHoldFromReport(report.id, { reason_note: legalHoldNote.trim() })
notifyOrgInboxChanged()
onRefresh()
onClose()
} catch (err) {
setError(err.message || String(err))
setSaving(false)
}
}
const targetLabel = report.target_filename || report.target_exercise_name
? `${report.target_filename || report.target_exercise_name} (#${report.target_id})`
: `#${report.target_id}`
return (
<div
style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.55)', display: 'flex', alignItems: 'flex-start',
justifyContent: 'center', zIndex: 1100, padding: '1rem', overflowY: 'auto',
}}
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div style={{
background: 'var(--surface)', borderRadius: '12px', padding: '1.5rem',
maxWidth: '580px', width: '100%', marginTop: '2rem', marginBottom: '2rem',
}}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1.25rem' }}>
<div>
<h2 style={{ margin: 0, fontSize: '1.1rem' }}>
Meldung #{report.id}
<PriorityBadge priority={report.priority} />
</h2>
<div style={{ marginTop: 4, fontSize: '0.82rem', color: STATUS_COLORS[report.status], fontWeight: 600 }}>
{STATUS_LABELS[report.status] || report.status}
</div>
</div>
<button type="button" className="btn btn-secondary" style={{ padding: '4px 10px' }} onClick={onClose}></button>
</div>
{/* Workflow Bar */}
<WorkflowBar status={report.status} />
{/* Details */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.55rem', marginBottom: '1.25rem', fontSize: '0.9rem' }}>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span className="muted" style={{ minWidth: 110 }}>Ziel</span>
<span>{report.target_type === 'media_asset' ? 'Medium' : 'Übung'} {targetLabel}</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span className="muted" style={{ minWidth: 110 }}>Meldegrund</span>
<span>{REASON_LABELS[report.report_reason] || report.report_reason}</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span className="muted" style={{ minWidth: 110 }}>Beschreibung</span>
<span style={{ whiteSpace: 'pre-wrap', flex: 1 }}>{report.report_description}</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span className="muted" style={{ minWidth: 110 }}>Meldende Person</span>
<span>
{report.reporter_name}
{report.reporter_email ? ` · ${report.reporter_email}` : ''}
{report.reporter_profile_id ? ` · Profil #${report.reporter_profile_id}` : ' · anonym'}
</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span className="muted" style={{ minWidth: 110 }}>Eingegangen</span>
<span>{formatWhen(report.submitted_at || report.created_at)}</span>
</div>
{report.reviewed_by_name && (
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span className="muted" style={{ minWidth: 110 }}>Geprüft von</span>
<span>{report.reviewed_by_name}{report.reviewed_at ? ` · ${formatWhen(report.reviewed_at)}` : ''}</span>
</div>
)}
{report.target_legal_hold_active && (
<div style={{ background: 'rgba(216,90,48,0.1)', borderRadius: '8px', padding: '0.5rem 0.75rem' }}>
<span style={{ color: 'var(--danger)', fontWeight: 600, fontSize: '0.88rem' }}>Legal Hold aktiv auf diesem Medium</span>
</div>
)}
</div>
{/* Resolution Note (Bearbeitungskommentar) */}
<div className="form-row" style={{ marginBottom: '1rem' }}>
<label className="form-label">
Bearbeitungskommentar
{(report.status === 'resolved_no_action' || report.status === 'rejected_invalid') && ' *'}
</label>
<textarea
className="form-input"
rows={3}
value={resolutionNote}
onChange={(e) => setResolutionNote(e.target.value)}
placeholder="Dokumentation der Entscheidung, interne Notizen…"
readOnly={isClosed}
style={isClosed ? { background: 'var(--surface2)', color: 'var(--text2)' } : undefined}
/>
{!isClosed && resolutionNote.trim() && resolutionNote.trim() !== (report.resolution_note || '').trim() && (
<button type="button" className="btn btn-secondary" style={{ marginTop: '0.4rem', fontSize: '0.82rem' }} onClick={handleSaveNote} disabled={saving}>
Kommentar speichern
</button>
)}
</div>
{error && (
<div style={{ color: 'var(--danger)', marginBottom: '0.75rem', fontSize: '0.9rem' }}>{error}</div>
)}
{/* Actions */}
{isOpen && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{report.status === 'submitted' && (
<button type="button" className="btn btn-primary" onClick={() => patchAndClose({ status: 'under_review' })} disabled={saving}>
In Bearbeitung nehmen
</button>
)}
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-secondary" onClick={() => handleStatus('resolved_no_action')} disabled={saving}>
Kein Handlungsbedarf
</button>
<button type="button" className="btn btn-secondary" onClick={() => handleStatus('rejected_invalid')} disabled={saving}>
Meldung abweisen
</button>
</div>
{(isSuperadmin || (isClubAdmin && report.target_visibility !== 'official')) && !showLegalHoldForm && (
<button
type="button" className="btn"
style={{ background: 'rgba(216,90,48,0.12)', color: 'var(--danger)', border: '1px solid var(--danger)' }}
onClick={() => setShowLegalHoldForm(true)}
>
Legal Hold setzen {isSuperadmin ? '(Superadmin)' : '(Vereinsadmin)'}
</button>
)}
{isSuperadmin && showLegalHoldForm && (
<div style={{ border: '1px solid var(--danger)', borderRadius: '8px', padding: '0.75rem' }}>
<div className="form-row">
<label className="form-label" style={{ color: 'var(--danger)' }}>Begründung Legal Hold *</label>
<textarea className="form-input" rows={2} value={legalHoldNote} onChange={(e) => setLegalHoldNote(e.target.value)} placeholder="Rechtliche Begründung…" />
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
<button type="button" className="btn" style={{ background: 'var(--danger)', color: '#fff', flex: 1 }} onClick={handleLegalHoldSubmit} disabled={saving}>
Legal Hold bestätigen
</button>
<button type="button" className="btn btn-secondary" onClick={() => setShowLegalHoldForm(false)}>Abbrechen</button>
</div>
</div>
)}
</div>
)}
{isClosed && (
<button
type="button" className="btn btn-secondary"
style={{ width: '100%' }}
onClick={() => patchAndClose({ status: 'submitted' })}
disabled={saving}
>
Meldung wieder öffnen (zurück auf Eingegangen)
</button>
)}
</div>
</div>
)
}
export default function InboxPage() {
const {
canAccessOrgInbox,
canAccessContentReports,
isSuperadmin,
isPlatformAdmin,
isClubAdmin,
refreshOrgInbox,
inboxJoinRequests,
contentReports,
contentReportCount,
contentReportsError,
} = useOrgInbox()
const [loading, setLoading] = useState(true)
const [acceptModal, setAcceptModal] = useState(null)
const [reportModal, setReportModal] = useState(null)
const [showArchive, setShowArchive] = useState(false)
const load = useCallback(async () => {
if (!canAccessOrgInbox && !canAccessContentReports) {
setLoading(false)
return
}
setLoading(true)
try {
await refreshOrgInbox()
} finally {
setLoading(false)
}
}, [canAccessOrgInbox, canAccessContentReports, refreshOrgInbox])
useEffect(() => {
load()
}, [load])
if (!canAccessOrgInbox && !canAccessContentReports) {
return (
<div className="app-page">
<h1 className="page-title">Posteingang</h1>
<p className="muted">Kein Zugriff. Nur Plattform-Admins und Vereinsadmins sehen den Posteingang.</p>
<p>
<Link to="/">Zur Übersicht</Link>
</p>
</div>
)
}
return (
<div className="app-page inbox-page">
<div className="inbox-page__header">
<div>
<h1 className="page-title" style={{ marginBottom: '6px' }}>
Posteingang
</h1>
<p className="muted" style={{ marginTop: 0 }}>
Beitrittsanträge und Inhaltsmeldungen für deine Zuständigkeitsbereiche.
</p>
</div>
<button type="button" className="btn btn-secondary" onClick={() => load()} disabled={loading}>
Aktualisieren
</button>
</div>
{loading ? (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner" />
</div>
) : (
<>
{/* Abschnitt 1: Beitrittsanträge */}
{canAccessOrgInbox && (
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text2)' }}>
Beitrittsanträge
{inboxJoinRequests.length > 0 && (
<span
style={{
background: 'var(--accent)',
color: '#fff',
borderRadius: '12px',
padding: '1px 8px',
fontSize: '0.75rem',
marginLeft: '0.5rem',
}}
>
{inboxJoinRequests.length}
</span>
)}
</h2>
{inboxJoinRequests.length === 0 ? (
<div className="card" style={{ padding: '1.25rem' }}>
<p style={{ margin: 0 }} className="muted">Keine offenen Beitrittsanträge.</p>
</div>
) : (
<div className="inbox-page__list">
{inboxJoinRequests.map((req) => (
<div key={`${req.club_id}-${req.id}`} className="card inbox-request-card">
<div className="inbox-request-card__main">
<div className="inbox-request-card__club">
{req.club_name || 'Verein'}
{req.club_abbreviation ? (
<span className="muted" style={{ marginLeft: '0.35rem' }}>
({req.club_abbreviation})
</span>
) : null}
</div>
<strong className="inbox-request-card__applicant">
{req.applicant_name || req.applicant_email || 'Bewerber/in'}
</strong>
<div className="muted inbox-request-card__meta">
{req.applicant_email} · Profil #{req.profile_id} · {formatWhen(req.created_at)}
</div>
{req.message ? <p className="inbox-request-card__message">{req.message}</p> : null}
</div>
<div className="inbox-request-card__actions">
<button
type="button"
className="btn btn-primary"
onClick={() =>
setAcceptModal({
id: req.id,
club_id: req.club_id,
label: req.applicant_name || req.applicant_email,
roles: ['trainer'],
})
}
>
Annehmen
</button>
<button
type="button"
className="btn btn-secondary"
onClick={async () => {
if (!confirm('Antrag ablehnen?')) return
try {
await api.rejectClubJoinRequest(req.club_id, req.id)
notifyOrgInboxChanged()
await load()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Ablehnen
</button>
</div>
</div>
))}
</div>
)}
</section>
)}
{/* Abschnitt 2: Inhaltsmeldungen */}
{canAccessContentReports && (() => {
const openReports = contentReports.filter((r) => r.status === 'submitted' || r.status === 'under_review')
const archivedReports = contentReports.filter((r) => r.status !== 'submitted' && r.status !== 'under_review')
function ReportCard({ rep }) {
return (
<div
key={rep.id}
className="card"
style={{
padding: '1rem 1.25rem',
cursor: 'pointer',
borderLeft: rep.priority === 'high' ? '3px solid var(--danger)' : '3px solid transparent',
opacity: rep.status !== 'submitted' && rep.status !== 'under_review' ? 0.75 : 1,
}}
onClick={() => setReportModal(rep)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem' }}>
<div>
<span style={{ fontWeight: 600 }}>
Meldung #{rep.id}
<PriorityBadge priority={rep.priority} />
</span>
<span className="muted" style={{ marginLeft: '0.75rem', fontSize: '0.85rem' }}>
{rep.target_type === 'media_asset' ? 'Medium' : 'Übung'} #{rep.target_id}
{rep.target_filename || rep.target_exercise_name ? ` ${rep.target_filename || rep.target_exercise_name}` : ''}
</span>
</div>
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: STATUS_COLORS[rep.status], whiteSpace: 'nowrap' }}>
{STATUS_LABELS[rep.status] || rep.status}
</span>
</div>
<div className="muted" style={{ fontSize: '0.85rem', marginTop: '0.3rem' }}>
{REASON_LABELS[rep.report_reason] || rep.report_reason}
{' · '}
{rep.reporter_name}
{rep.reporter_profile_id ? ` (Profil #${rep.reporter_profile_id})` : ' (anonym)'}
{' · '}
{formatWhen(rep.submitted_at || rep.created_at)}
</div>
</div>
)
}
return (
<section>
<h2 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text2)' }}>
Inhaltsmeldungen
{contentReportCount > 0 && (
<span style={{ background: 'var(--danger)', color: '#fff', borderRadius: '12px', padding: '1px 8px', fontSize: '0.75rem', marginLeft: '0.5rem' }}>
{contentReportCount} neu
</span>
)}
</h2>
{contentReportsError ? (
<div className="card" style={{ padding: '1.25rem', borderLeft: '3px solid var(--danger)' }}>
<p style={{ margin: 0, color: 'var(--danger)', fontWeight: 600, fontSize: '0.88rem' }}>
Fehler beim Laden: {contentReportsError}
</p>
<button type="button" className="btn btn-secondary" style={{ marginTop: '0.5rem', fontSize: '0.82rem' }} onClick={load}>
Erneut versuchen
</button>
</div>
) : (
<>
{openReports.length === 0 ? (
<div className="card" style={{ padding: '1.25rem', marginBottom: '0.75rem' }}>
<p style={{ margin: 0 }} className="muted">Keine offenen Inhaltsmeldungen.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginBottom: '0.75rem' }}>
{openReports.map((rep) => <ReportCard key={rep.id} rep={rep} />)}
</div>
)}
{archivedReports.length > 0 && (
<div>
<button
type="button"
style={{
background: 'none', border: 'none', cursor: 'pointer', padding: '0.4rem 0',
color: 'var(--text2)', fontSize: '0.88rem', display: 'flex', alignItems: 'center', gap: '0.35rem',
}}
onClick={() => setShowArchive((v) => !v)}
>
<span style={{ fontSize: '0.75rem' }}>{showArchive ? '▼' : '▶'}</span>
Archiv ({archivedReports.length} abgeschlossene Meldung{archivedReports.length !== 1 ? 'en' : ''})
</button>
{showArchive && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '0.5rem' }}>
{archivedReports.map((rep) => <ReportCard key={rep.id} rep={rep} />)}
</div>
)}
</div>
)}
</>
)}
</section>
)
})()}
</>
)}
{acceptModal && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1100,
padding: '1rem',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '480px',
width: '100%',
}}
>
<h2 style={{ marginTop: 0 }}>Antrag annehmen</h2>
<p style={{ color: 'var(--text2)' }}>{acceptModal.label}</p>
<div className="form-row">
<span className="form-label">Rollen bei Aufnahme</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
{CLUB_ROLE_OPTIONS.map((opt) => (
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<input
type="checkbox"
checked={acceptModal.roles.includes(opt.code)}
onChange={() => {
setAcceptModal((prev) => {
if (!prev) return prev
const set = new Set(prev.roles)
if (set.has(opt.code)) set.delete(opt.code)
else set.add(opt.code)
const roles = Array.from(set)
return { ...prev, roles: roles.length ? roles : ['trainer'] }
})
}}
/>
{opt.label}
</label>
))}
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button
type="button"
className="btn btn-primary"
style={{ flex: 1 }}
onClick={async () => {
try {
await api.acceptClubJoinRequest(
acceptModal.club_id,
acceptModal.id,
acceptModal.roles.length ? acceptModal.roles : ['trainer']
)
setAcceptModal(null)
notifyOrgInboxChanged()
await load()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Aufnehmen
</button>
<button type="button" className="btn btn-secondary" onClick={() => setAcceptModal(null)}>
Abbrechen
</button>
</div>
</div>
</div>
)}
{reportModal && (
<ReportDetailModal
report={reportModal}
isSuperadmin={isSuperadmin}
isClubAdmin={isClubAdmin && !isPlatformAdmin}
onClose={() => setReportModal(null)}
onRefresh={load}
/>
)}
</div>
)
}