feat(P-13): Workflow-Management, Fehleranzeige, Badge-Update, Wieder-öffnen
Some checks failed
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Failing after 41s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 11s
Test Suite / playwright-tests (push) Successful in 56s

- InboxPage: Workflow-Balken (Eingegangen > In Bearbeitung > Abgeschlossen)
- InboxPage: Meldungen können nach Abschluss wieder geöffnet werden (PATCH status=submitted)
- InboxPage: Bearbeitungskommentar separat speicherbar; Reviewer + Datum sichtbar
- InboxPage: Fehler beim Laden von Meldungen wird angezeigt statt leerem Bereich
- OrgInboxContext: contentReportsError State exposed
- ReportContentModal: onSuccess Callback -> Badge in Medienbibliothek sofort aktuell
- content_reports PATCH: Reviewer-Felder werden beim Wieder-öffnen zurückgesetzt
- content_reports PATCH: Kommentar-Änderungen ohne Statuswechsel werden im Audit-Log protokolliert

version: 0.8.92

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-11 21:32:44 +02:00
parent bb4d927090
commit 34e93101f1
7 changed files with 244 additions and 181 deletions

View File

@ -716,7 +716,7 @@ def patch_content_report(
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT id, status, target_type, target_id FROM content_reports WHERE id = %s",
"SELECT id, status, resolution_note, target_type, target_id FROM content_reports WHERE id = %s",
(report_id,),
)
row = cur.fetchone()
@ -724,6 +724,7 @@ def patch_content_report(
raise HTTPException(status_code=404, detail="Meldung nicht gefunden")
old_report = r2d(row)
old_status = old_report["status"]
old_note = (old_report.get("resolution_note") or "").strip()
updates = ["updated_at = NOW()"]
params = []
@ -735,6 +736,10 @@ def patch_content_report(
updates.append("reviewed_by_profile_id = %s")
updates.append("reviewed_at = NOW()")
params.append(pid)
elif body.status == "submitted":
# Wieder öffnen: Prüferfelder zurücksetzen
updates.append("reviewed_by_profile_id = NULL")
updates.append("reviewed_at = NULL")
if body.resolution_note is not None:
updates.append("resolution_note = %s")
@ -755,21 +760,34 @@ def patch_content_report(
updated = r2d(cur.fetchone())
new_status = updated["status"]
# Audit-Log-Eintrag bei Status-Aenderung auf media_asset-Meldungen
if body.status is not None and old_status != new_status and old_report["target_type"] == "media_asset":
resolution = (body.resolution_note or "").strip() or None
write_audit_log_entry(
cur,
int(old_report["target_id"]),
pid,
"content_report_filed",
{"status": STATUS_LABELS_DE.get(old_status, old_status)},
{
"content_report_id": report_id,
"status": STATUS_LABELS_DE.get(new_status, new_status),
**({"begründung": resolution} if resolution else {}),
},
)
is_media = old_report["target_type"] == "media_asset"
if is_media:
new_note = (body.resolution_note or "").strip() if body.resolution_note is not None else old_note
# Audit-Log: Statuswechsel
if body.status is not None and old_status != new_status:
write_audit_log_entry(
cur,
int(old_report["target_id"]),
pid,
"content_report_filed",
{"status": STATUS_LABELS_DE.get(old_status, old_status)},
{
"content_report_id": report_id,
"status": STATUS_LABELS_DE.get(new_status, new_status),
**({"begründung": new_note} if new_note else {}),
},
)
# Audit-Log: reine Notizänderung (ohne Statuswechsel)
elif body.resolution_note is not None and new_note != old_note:
write_audit_log_entry(
cur,
int(old_report["target_id"]),
pid,
"content_report_filed",
{"content_report_id": report_id, "begründung": old_note or None},
{"content_report_id": report_id, "begründung": new_note or None},
)
conn.commit()

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.91"
APP_VERSION = "0.8.92"
BUILD_DATE = "2026-05-11"
DB_SCHEMA_VERSION = "20260511053"
@ -30,10 +30,22 @@ MODULE_VERSIONS = {
"membership": "1.0.0",
"catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012)
"maturity_models": "1.4.0", # matrix_stack_bundle: vollständiger Katalog+Modelle+Bindings Export/Import
"content_reports": "1.3.0", # P-13: Club-Admin-Zugriff; Audit-Log; E-Mail (Admin + Melder); target_name-Fix
"content_reports": "1.4.0", # P-13: Workflow-Reset (wieder öffnen), Kommentar-Audit-Log, PATCH-Verbesserungen
}
CHANGELOG = [
{
"version": "0.8.92",
"date": "2026-05-11",
"changes": [
"Fix P-13: Badge in Medienbibliothek aktualisiert sich sofort nach Einreichen einer Meldung.",
"Fix P-13: Inbox zeigt Fehlermeldung statt leerem Bereich wenn Backend-Fehler auftritt.",
"Fix P-13: Meldungen lassen sich nach Abschluss wieder öffnen (Wieder-öffnen-Button).",
"Fix P-13: Bearbeitungskommentare werden separat im Audit-Log protokolliert.",
"Fix P-13: Reviewer-Felder werden beim Wieder-öffnen einer Meldung zurückgesetzt.",
"Fix P-13: Workflow-Balken im Meldungs-Detail zeigt aktuellen Bearbeitungsstand.",
],
},
{
"version": "0.8.91",
"date": "2026-05-11",

View File

@ -22,7 +22,7 @@ const REASON_OPTIONS = [
{ value: 'other', label: 'Sonstiges' },
]
export default function ReportContentModal({ targetType, targetId, targetLabel, onClose }) {
export default function ReportContentModal({ targetType, targetId, targetLabel, onClose, onSuccess }) {
const { user } = useAuth()
const [reason, setReason] = useState('')
@ -55,6 +55,7 @@ export default function ReportContentModal({ targetType, targetId, targetLabel,
good_faith_confirmed: true,
})
setSuccess(true)
if (onSuccess) onSuccess()
} catch (err) {
setError(err.message || String(err))
} finally {

View File

@ -30,6 +30,7 @@ export function notifyOrgInboxChanged() {
export function OrgInboxProvider({ user, children }) {
const [items, setItems] = useState([])
const [contentReports, setContentReports] = useState([])
const [contentReportsError, setContentReportsError] = useState(null)
const canAccess = useMemo(() => canAccessOrgInbox(user), [user])
const canAccessReports = useMemo(() => canSeeContentReports(user), [user])
@ -47,12 +48,15 @@ export function OrgInboxProvider({ user, children }) {
if (!canAccessReports) {
setContentReports([])
setContentReportsError(null)
} else {
try {
const data = await api.getInboxContentReports()
setContentReports(Array.isArray(data) ? data : [])
} catch {
setContentReportsError(null)
} catch (err) {
setContentReports([])
setContentReportsError(err?.message || String(err))
}
}
}, [canAccess, canAccessReports])
@ -61,6 +65,7 @@ export function OrgInboxProvider({ user, children }) {
if (!canAccess && !canAccessReports) {
setItems([])
setContentReports([])
setContentReportsError(null)
return undefined
}
let cancelled = false
@ -76,9 +81,15 @@ export function OrgInboxProvider({ user, children }) {
if (canAccessReports) {
try {
const data = await api.getInboxContentReports()
if (!cancelled) setContentReports(Array.isArray(data) ? data : [])
} catch {
if (!cancelled) setContentReports([])
if (!cancelled) {
setContentReports(Array.isArray(data) ? data : [])
setContentReportsError(null)
}
} catch (err) {
if (!cancelled) {
setContentReports([])
setContentReportsError(err?.message || String(err))
}
}
}
})()
@ -99,12 +110,13 @@ export function OrgInboxProvider({ user, children }) {
inboxCount: items.length,
contentReports,
contentReportCount: contentReports.filter((r) => r.status === 'submitted').length,
contentReportsError,
refreshOrgInbox: refresh,
canAccessOrgInbox: canAccess,
canAccessContentReports: canAccessReports,
isSuperadmin: user?.role === 'superadmin',
}),
[items, contentReports, refresh, canAccess, canAccessReports, user?.role]
[items, contentReports, contentReportsError, refresh, canAccess, canAccessReports, user?.role]
)
return <OrgInboxContext.Provider value={value}>{children}</OrgInboxContext.Provider>

View File

@ -66,57 +66,91 @@ function PriorityBadge({ priority }) {
)
}
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 }) {
const [resolutionNote, setResolutionNote] = useState('')
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 isOpen = report.status === 'submitted' || report.status === 'under_review'
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
}
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)
}
await patchAndClose({ status, resolution_note: resolutionNote.trim() || undefined })
}
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 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
}
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 {
@ -126,190 +160,160 @@ function ReportDetailModal({ report, onClose, onRefresh, isSuperadmin }) {
onClose()
} catch (err) {
setError(err.message || String(err))
} finally {
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',
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 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>
<span className="muted" style={{ fontSize: '0.8rem' }}>Status</span>
<div style={{ fontWeight: 600, color: STATUS_COLORS[report.status] }}>
<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>
<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>
{/* 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>
<span className="muted" style={{ fontSize: '0.8rem' }}>Meldegrund</span>
<div>{REASON_LABELS[report.report_reason] || report.report_reason}</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>
<span className="muted" style={{ fontSize: '0.8rem' }}>Beschreibung</span>
<div style={{ whiteSpace: 'pre-wrap' }}>{report.report_description}</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>
<span className="muted" style={{ fontSize: '0.8rem' }}>Meldende Person</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'}
</div>
</span>
</div>
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Eingegangen</span>
<div>{formatWhen(report.submitted_at || report.created_at)}</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.resolution_note && (
<div>
<span className="muted" style={{ fontSize: '0.8rem' }}>Begründung</span>
<div style={{ whiteSpace: 'pre-wrap' }}>{report.resolution_note}</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.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>
{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.75rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{report.status === 'submitted' && (
<button type="button" className="btn btn-primary" onClick={handleUnderReview} disabled={saving}>
<button type="button" className="btn btn-primary" onClick={() => patchAndClose({ status: 'under_review' })} 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}
>
<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}
>
<button type="button" className="btn btn-secondary" onClick={() => handleStatus('rejected_invalid')} disabled={saving}>
Meldung abweisen
</button>
</div>
{isSuperadmin && !showLegalHoldForm && (
<button
type="button"
className="btn"
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…"
/>
<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={handleLegalHold}
disabled={saving}
>
<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>
<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>
{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>
@ -325,6 +329,7 @@ export default function InboxPage() {
inboxJoinRequests,
contentReports,
contentReportCount,
contentReportsError,
} = useOrgInbox()
const [loading, setLoading] = useState(true)
const [acceptModal, setAcceptModal] = useState(null)
@ -487,7 +492,16 @@ export default function InboxPage() {
)}
</h2>
{contentReports.length === 0 ? (
{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 ? (
<div className="card" style={{ padding: '1.25rem' }}>
<p style={{ margin: 0 }} className="muted">Keine Inhaltsmeldungen.</p>
</div>

View File

@ -2023,6 +2023,12 @@ export default function MediaLibraryPage() {
targetId={reportTarget.id}
targetLabel={reportTarget.original_filename || `Medium #${reportTarget.id}`}
onClose={() => setReportTarget(null)}
onSuccess={() => {
const id = reportTarget.id
setItems((prev) => prev.map((it) =>
it.id === id ? { ...it, open_report_count: (it.open_report_count || 0) + 1 } : it
))
}}
/>
)}
</div>

View File

@ -1,6 +1,6 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.91"
export const APP_VERSION = "0.8.92"
export const BUILD_DATE = "2026-05-11"
export const PAGE_VERSIONS = {
@ -27,8 +27,8 @@ export const PAGE_VERSIONS = {
ExerciseMediaEmbed: "1.1.0", // P-11: Legal-Hold-Placeholder statt Datei
ExerciseMediaThumbTile: "1.1.0", // P-11: Legal-Hold-Kachel statt Datei-Vorschau
ExerciseAttachmentMediaStrip: "1.2.0", // P-13: MediaPreviewModal (geteilt) + Melden im Viewer
InboxPage: "2.1.0", // P-13: target_filename/target_exercise_name fix; Club-Admin-Sicht
OrgInboxContext: "1.1.0", // P-13: canSeeContentReports schliesst Club-Admins ein
InboxPage: "2.2.0", // P-13: Workflow-Balken, Wieder-öffnen, Kommentar, Fehleranzeigezeige
OrgInboxContext: "1.2.0", // P-13: contentReportsError exposed
MediaPreviewModal: "1.0.0", // P-13: geteilter Medienvorschau-Dialog (Melden + Bearbeiten optional)
ReportContentModal: "1.1.0", // P-13: Name/E-Mail readOnly fuer eingeloggte Nutzer
ReportContentModal: "1.2.0", // P-13: onSuccess callback fuer Badge-Update
}