shinkan-jinkendo/frontend/src/pages/InboxPage.jsx

638 lines
22 KiB
React
Raw Normal View History

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>
)
}
function ReportDetailModal({ report, onClose, onRefresh, isSuperadmin }) {
const [resolutionNote, setResolutionNote] = useState('')
const [legalHoldNote, setLegalHoldNote] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [showLegalHoldForm, setShowLegalHoldForm] = useState(false)
const isOpen = report.status === 'submitted' || report.status === 'under_review'
async function handleStatus(status) {
if ((status === 'resolved_no_action' || status === 'rejected_invalid') && !resolutionNote.trim()) {
setError('Bitte eine Begründung eingeben.')
return
}
setSaving(true)
setError(null)
try {
await api.patchContentReport(report.id, {
status,
resolution_note: resolutionNote.trim() || undefined,
})
notifyOrgInboxChanged()
onRefresh()
onClose()
} catch (err) {
setError(err.message || String(err))
} finally {
setSaving(false)
}
}
async function handleUnderReview() {
setSaving(true)
setError(null)
try {
await api.patchContentReport(report.id, { status: 'under_review' })
notifyOrgInboxChanged()
onRefresh()
onClose()
} catch (err) {
setError(err.message || String(err))
} finally {
setSaving(false)
}
}
async function handleLegalHold() {
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))
} finally {
setSaving(false)
}
}
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: '560px',
width: '100%',
marginTop: '2rem',
marginBottom: '2rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1rem' }}>
<h2 style={{ margin: 0 }}>
Meldung #{report.id}
<PriorityBadge priority={report.priority} />
</h2>
<button type="button" className="btn btn-secondary" style={{ padding: '4px 10px' }} onClick={onClose}>✕</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem', marginBottom: '1.2rem' }}>
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Status</span>
<div style={{ fontWeight: 600, color: STATUS_COLORS[report.status] }}>
{STATUS_LABELS[report.status] || report.status}
</div>
</div>
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Ziel</span>
<div>
{report.target_type === 'media_asset' ? 'Medium' : 'Übung'} #{report.target_id}
{report.target_name ? ` – ${report.target_name}` : ''}
</div>
</div>
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Meldegrund</span>
<div>{REASON_LABELS[report.report_reason] || report.report_reason}</div>
</div>
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Beschreibung</span>
<div style={{ whiteSpace: 'pre-wrap' }}>{report.report_description}</div>
</div>
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Meldende Person</span>
<div>
{report.reporter_name}
{report.reporter_email ? ` · ${report.reporter_email}` : ''}
{report.reporter_profile_id ? ` · Profil #${report.reporter_profile_id}` : ' · anonym'}
</div>
</div>
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Eingegangen</span>
<div>{formatWhen(report.submitted_at || report.created_at)}</div>
</div>
{report.resolution_note && (
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Begründung</span>
<div style={{ whiteSpace: 'pre-wrap' }}>{report.resolution_note}</div>
</div>
)}
{report.legal_hold_active && (
<div style={{ background: 'rgba(216,90,48,0.1)', borderRadius: '8px', padding: '0.6rem 0.8rem' }}>
<span style={{ color: 'var(--danger)', fontWeight: 600 }}>Legal Hold aktiv</span>
</div>
)}
</div>
{error && (
<div style={{ color: 'var(--danger)', marginBottom: '0.75rem', fontSize: '0.9rem' }}>{error}</div>
)}
{isOpen && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{report.status === 'submitted' && (
<button type="button" className="btn btn-primary" onClick={handleUnderReview} disabled={saving}>
In Bearbeitung nehmen
</button>
)}
<div className="form-row">
<label className="form-label">Begründung (erforderlich für Abschluss/Abweisung)</label>
<textarea
className="form-input"
rows={2}
value={resolutionNote}
onChange={(e) => setResolutionNote(e.target.value)}
placeholder="Kurze Begründung der Entscheidung…"
/>
</div>
<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 && !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 (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>
<textarea
className="form-input"
rows={2}
value={legalHoldNote}
onChange={(e) => setLegalHoldNote(e.target.value)}
placeholder="Rechtliche Begründung für den Legal Hold…"
/>
</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={handleLegalHold}
disabled={saving}
>
Legal Hold bestätigen
</button>
<button type="button" className="btn btn-secondary" onClick={() => setShowLegalHoldForm(false)}>
Abbrechen
</button>
</div>
</div>
)}
</div>
)}
{!isOpen && (
<p className="muted" style={{ margin: 0, fontSize: '0.9rem' }}>
Diese Meldung ist bereits abgeschlossen.
</p>
)}
</div>
</div>
)
}
export default function InboxPage() {
const {
canAccessOrgInbox,
canAccessContentReports,
isSuperadmin,
refreshOrgInbox,
inboxJoinRequests,
contentReports,
contentReportCount,
} = useOrgInbox()
const [loading, setLoading] = useState(true)
const [acceptModal, setAcceptModal] = useState(null)
const [reportModal, setReportModal] = useState(null)
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 (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>
{contentReports.length === 0 ? (
<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}
{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>
))}
</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}
onClose={() => setReportModal(null)}
onRefresh={load}
/>
)}
</div>
)
}