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
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:
parent
bb4d927090
commit
34e93101f1
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user