2026-05-09 09:13:38 +02:00
|
|
|
|
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' },
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-05-11 17:54:53 +02:00
|
|
|
|
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)',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 09:13:38 +02:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-11 17:54:53 +02:00
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-11 21:32:44 +02:00
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-11 17:54:53 +02:00
|
|
|
|
function ReportDetailModal({ report, onClose, onRefresh, isSuperadmin }) {
|
2026-05-11 21:32:44 +02:00
|
|
|
|
const [resolutionNote, setResolutionNote] = useState(report.resolution_note || '')
|
2026-05-11 17:54:53 +02:00
|
|
|
|
const [legalHoldNote, setLegalHoldNote] = useState('')
|
|
|
|
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
|
|
const [error, setError] = useState(null)
|
|
|
|
|
|
const [showLegalHoldForm, setShowLegalHoldForm] = useState(false)
|
|
|
|
|
|
|
2026-05-11 21:32:44 +02:00
|
|
|
|
const isClosed = ['resolved_no_action', 'resolved_legal_hold', 'rejected_invalid'].includes(report.status)
|
|
|
|
|
|
const isOpen = !isClosed
|
2026-05-11 17:54:53 +02:00
|
|
|
|
|
2026-05-11 21:32:44 +02:00
|
|
|
|
async function patchAndClose(body) {
|
2026-05-11 17:54:53 +02:00
|
|
|
|
setSaving(true)
|
|
|
|
|
|
setError(null)
|
|
|
|
|
|
try {
|
2026-05-11 21:32:44 +02:00
|
|
|
|
await api.patchContentReport(report.id, body)
|
2026-05-11 17:54:53 +02:00
|
|
|
|
notifyOrgInboxChanged()
|
|
|
|
|
|
onRefresh()
|
|
|
|
|
|
onClose()
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err.message || String(err))
|
|
|
|
|
|
setSaving(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-11 21:32:44 +02:00
|
|
|
|
async function handleStatus(status) {
|
|
|
|
|
|
if ((status === 'resolved_no_action' || status === 'rejected_invalid') && !resolutionNote.trim()) {
|
|
|
|
|
|
setError('Bitte eine Begründung eingeben.')
|
|
|
|
|
|
return
|
2026-05-11 17:54:53 +02:00
|
|
|
|
}
|
2026-05-11 21:32:44 +02:00
|
|
|
|
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() })
|
2026-05-11 17:54:53 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleLegalHold() {
|
2026-05-11 21:32:44 +02:00
|
|
|
|
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 }
|
2026-05-11 17:54:53 +02:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-11 21:32:44 +02:00
|
|
|
|
const targetLabel = report.target_filename || report.target_exercise_name
|
|
|
|
|
|
? `${report.target_filename || report.target_exercise_name} (#${report.target_id})`
|
|
|
|
|
|
: `#${report.target_id}`
|
|
|
|
|
|
|
2026-05-11 17:54:53 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
2026-05-11 21:32:44 +02:00
|
|
|
|
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',
|
2026-05-11 17:54:53 +02:00
|
|
|
|
}}
|
|
|
|
|
|
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
|
|
|
|
|
>
|
2026-05-11 21:32:44 +02:00
|
|
|
|
<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' }}>
|
2026-05-11 17:54:53 +02:00
|
|
|
|
<div>
|
2026-05-11 21:32:44 +02:00
|
|
|
|
<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 }}>
|
2026-05-11 17:54:53 +02:00
|
|
|
|
{STATUS_LABELS[report.status] || report.status}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-11 21:32:44 +02:00
|
|
|
|
<button type="button" className="btn btn-secondary" style={{ padding: '4px 10px' }} onClick={onClose}>✕</button>
|
|
|
|
|
|
</div>
|
2026-05-11 17:54:53 +02:00
|
|
|
|
|
2026-05-11 21:32:44 +02:00
|
|
|
|
{/* Workflow Bar */}
|
|
|
|
|
|
<WorkflowBar status={report.status} />
|
2026-05-11 17:54:53 +02:00
|
|
|
|
|
2026-05-11 21:32:44 +02:00
|
|
|
|
{/* 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>
|
2026-05-11 17:54:53 +02:00
|
|
|
|
</div>
|
2026-05-11 21:32:44 +02:00
|
|
|
|
<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>
|
2026-05-11 17:54:53 +02:00
|
|
|
|
</div>
|
2026-05-11 21:32:44 +02:00
|
|
|
|
<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>
|
2026-05-11 17:54:53 +02:00
|
|
|
|
{report.reporter_name}
|
|
|
|
|
|
{report.reporter_email ? ` · ${report.reporter_email}` : ''}
|
|
|
|
|
|
{report.reporter_profile_id ? ` · Profil #${report.reporter_profile_id}` : ' · anonym'}
|
2026-05-11 21:32:44 +02:00
|
|
|
|
</span>
|
2026-05-11 17:54:53 +02:00
|
|
|
|
</div>
|
2026-05-11 21:32:44 +02:00
|
|
|
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
|
|
|
|
<span className="muted" style={{ minWidth: 110 }}>Eingegangen</span>
|
|
|
|
|
|
<span>{formatWhen(report.submitted_at || report.created_at)}</span>
|
2026-05-11 17:54:53 +02:00
|
|
|
|
</div>
|
2026-05-11 21:32:44 +02:00
|
|
|
|
{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>
|
2026-05-11 17:54:53 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-05-11 21:32:44 +02:00
|
|
|
|
{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>
|
2026-05-11 17:54:53 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-11 21:32:44 +02:00
|
|
|
|
{/* 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>
|
|
|
|
|
|
|
2026-05-11 17:54:53 +02:00
|
|
|
|
{error && (
|
|
|
|
|
|
<div style={{ color: 'var(--danger)', marginBottom: '0.75rem', fontSize: '0.9rem' }}>{error}</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-11 21:32:44 +02:00
|
|
|
|
{/* Actions */}
|
2026-05-11 17:54:53 +02:00
|
|
|
|
{isOpen && (
|
2026-05-11 21:32:44 +02:00
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
2026-05-11 17:54:53 +02:00
|
|
|
|
{report.status === 'submitted' && (
|
2026-05-11 21:32:44 +02:00
|
|
|
|
<button type="button" className="btn btn-primary" onClick={() => patchAndClose({ status: 'under_review' })} disabled={saving}>
|
2026-05-11 17:54:53 +02:00
|
|
|
|
In Bearbeitung nehmen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
2026-05-11 21:32:44 +02:00
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => handleStatus('resolved_no_action')} disabled={saving}>
|
2026-05-11 17:54:53 +02:00
|
|
|
|
Kein Handlungsbedarf
|
|
|
|
|
|
</button>
|
2026-05-11 21:32:44 +02:00
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => handleStatus('rejected_invalid')} disabled={saving}>
|
2026-05-11 17:54:53 +02:00
|
|
|
|
Meldung abweisen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{isSuperadmin && !showLegalHoldForm && (
|
|
|
|
|
|
<button
|
2026-05-11 21:32:44 +02:00
|
|
|
|
type="button" className="btn"
|
2026-05-11 17:54:53 +02:00
|
|
|
|
style={{ background: 'rgba(216,90,48,0.12)', color: 'var(--danger)', border: '1px solid var(--danger)' }}
|
|
|
|
|
|
onClick={() => setShowLegalHoldForm(true)}
|
|
|
|
|
|
>
|
|
|
|
|
|
Legal Hold setzen (Superadmin)
|
|
|
|
|
|
</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>
|
2026-05-11 21:32:44 +02:00
|
|
|
|
<textarea className="form-input" rows={2} value={legalHoldNote} onChange={(e) => setLegalHoldNote(e.target.value)} placeholder="Rechtliche Begründung…" />
|
2026-05-11 17:54:53 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
|
2026-05-11 21:32:44 +02:00
|
|
|
|
<button type="button" className="btn" style={{ background: 'var(--danger)', color: '#fff', flex: 1 }} onClick={handleLegalHoldSubmit} disabled={saving}>
|
2026-05-11 17:54:53 +02:00
|
|
|
|
Legal Hold bestätigen
|
|
|
|
|
|
</button>
|
2026-05-11 21:32:44 +02:00
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => setShowLegalHoldForm(false)}>Abbrechen</button>
|
2026-05-11 17:54:53 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-11 21:32:44 +02:00
|
|
|
|
{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>
|
2026-05-11 17:54:53 +02:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-09 09:13:38 +02:00
|
|
|
|
export default function InboxPage() {
|
2026-05-11 17:54:53 +02:00
|
|
|
|
const {
|
|
|
|
|
|
canAccessOrgInbox,
|
|
|
|
|
|
canAccessContentReports,
|
|
|
|
|
|
isSuperadmin,
|
|
|
|
|
|
refreshOrgInbox,
|
|
|
|
|
|
inboxJoinRequests,
|
|
|
|
|
|
contentReports,
|
|
|
|
|
|
contentReportCount,
|
2026-05-11 21:32:44 +02:00
|
|
|
|
contentReportsError,
|
2026-05-11 17:54:53 +02:00
|
|
|
|
} = useOrgInbox()
|
2026-05-09 09:13:38 +02:00
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
|
|
const [acceptModal, setAcceptModal] = useState(null)
|
2026-05-11 17:54:53 +02:00
|
|
|
|
const [reportModal, setReportModal] = useState(null)
|
2026-05-09 09:13:38 +02:00
|
|
|
|
|
|
|
|
|
|
const load = useCallback(async () => {
|
2026-05-11 17:54:53 +02:00
|
|
|
|
if (!canAccessOrgInbox && !canAccessContentReports) {
|
2026-05-09 09:13:38 +02:00
|
|
|
|
setLoading(false)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
setLoading(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
await refreshOrgInbox()
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
}
|
2026-05-11 17:54:53 +02:00
|
|
|
|
}, [canAccessOrgInbox, canAccessContentReports, refreshOrgInbox])
|
2026-05-09 09:13:38 +02:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
load()
|
|
|
|
|
|
}, [load])
|
|
|
|
|
|
|
2026-05-11 17:54:53 +02:00
|
|
|
|
if (!canAccessOrgInbox && !canAccessContentReports) {
|
2026-05-09 09:13:38 +02:00
|
|
|
|
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 }}>
|
2026-05-11 17:54:53 +02:00
|
|
|
|
Beitrittsanträge und Inhaltsmeldungen für deine Zuständigkeitsbereiche.
|
2026-05-09 09:13:38 +02:00
|
|
|
|
</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>
|
|
|
|
|
|
) : (
|
2026-05-11 17:54:53 +02:00
|
|
|
|
<>
|
|
|
|
|
|
{/* 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>
|
2026-05-09 09:13:38 +02:00
|
|
|
|
</div>
|
2026-05-11 17:54:53 +02:00
|
|
|
|
) : (
|
|
|
|
|
|
<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>
|
|
|
|
|
|
))}
|
2026-05-09 09:13:38 +02:00
|
|
|
|
</div>
|
2026-05-11 17:54:53 +02:00
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Abschnitt 2: Inhaltsmeldungen (nur Plattform-Admins) */}
|
|
|
|
|
|
{canAccessContentReports && (
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-05-11 21:32:44 +02:00
|
|
|
|
{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>
|
|
|
|
|
|
) : contentReports.length === 0 ? (
|
2026-05-11 17:54:53 +02:00
|
|
|
|
<div className="card" style={{ padding: '1.25rem' }}>
|
|
|
|
|
|
<p style={{ margin: 0 }} className="muted">Keine Inhaltsmeldungen.</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
|
|
|
|
{contentReports.map((rep) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={rep.id}
|
|
|
|
|
|
className="card"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
padding: '1rem 1.25rem',
|
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
|
borderLeft: rep.priority === 'high' ? '3px solid var(--danger)' : '3px solid transparent',
|
|
|
|
|
|
}}
|
|
|
|
|
|
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}
|
2026-05-11 19:36:23 +02:00
|
|
|
|
{rep.target_filename || rep.target_exercise_name ? ` – ${rep.target_filename || rep.target_exercise_name}` : ''}
|
2026-05-11 17:54:53 +02:00
|
|
|
|
</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>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
2026-05-09 09:13:38 +02:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-05-11 17:54:53 +02:00
|
|
|
|
|
|
|
|
|
|
{reportModal && (
|
|
|
|
|
|
<ReportDetailModal
|
|
|
|
|
|
report={reportModal}
|
|
|
|
|
|
isSuperadmin={isSuperadmin}
|
|
|
|
|
|
onClose={() => setReportModal(null)}
|
|
|
|
|
|
onRefresh={load}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-05-09 09:13:38 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|