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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute(
|
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,),
|
(report_id,),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
|
|
@ -724,6 +724,7 @@ def patch_content_report(
|
||||||
raise HTTPException(status_code=404, detail="Meldung nicht gefunden")
|
raise HTTPException(status_code=404, detail="Meldung nicht gefunden")
|
||||||
old_report = r2d(row)
|
old_report = r2d(row)
|
||||||
old_status = old_report["status"]
|
old_status = old_report["status"]
|
||||||
|
old_note = (old_report.get("resolution_note") or "").strip()
|
||||||
|
|
||||||
updates = ["updated_at = NOW()"]
|
updates = ["updated_at = NOW()"]
|
||||||
params = []
|
params = []
|
||||||
|
|
@ -735,6 +736,10 @@ def patch_content_report(
|
||||||
updates.append("reviewed_by_profile_id = %s")
|
updates.append("reviewed_by_profile_id = %s")
|
||||||
updates.append("reviewed_at = NOW()")
|
updates.append("reviewed_at = NOW()")
|
||||||
params.append(pid)
|
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:
|
if body.resolution_note is not None:
|
||||||
updates.append("resolution_note = %s")
|
updates.append("resolution_note = %s")
|
||||||
|
|
@ -755,21 +760,34 @@ def patch_content_report(
|
||||||
updated = r2d(cur.fetchone())
|
updated = r2d(cur.fetchone())
|
||||||
new_status = updated["status"]
|
new_status = updated["status"]
|
||||||
|
|
||||||
# Audit-Log-Eintrag bei Status-Aenderung auf media_asset-Meldungen
|
is_media = old_report["target_type"] == "media_asset"
|
||||||
if body.status is not None and old_status != new_status and old_report["target_type"] == "media_asset":
|
if is_media:
|
||||||
resolution = (body.resolution_note or "").strip() or None
|
new_note = (body.resolution_note or "").strip() if body.resolution_note is not None else old_note
|
||||||
write_audit_log_entry(
|
|
||||||
cur,
|
# Audit-Log: Statuswechsel
|
||||||
int(old_report["target_id"]),
|
if body.status is not None and old_status != new_status:
|
||||||
pid,
|
write_audit_log_entry(
|
||||||
"content_report_filed",
|
cur,
|
||||||
{"status": STATUS_LABELS_DE.get(old_status, old_status)},
|
int(old_report["target_id"]),
|
||||||
{
|
pid,
|
||||||
"content_report_id": report_id,
|
"content_report_filed",
|
||||||
"status": STATUS_LABELS_DE.get(new_status, new_status),
|
{"status": STATUS_LABELS_DE.get(old_status, old_status)},
|
||||||
**({"begründung": resolution} if resolution else {}),
|
{
|
||||||
},
|
"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()
|
conn.commit()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.91"
|
APP_VERSION = "0.8.92"
|
||||||
BUILD_DATE = "2026-05-11"
|
BUILD_DATE = "2026-05-11"
|
||||||
DB_SCHEMA_VERSION = "20260511053"
|
DB_SCHEMA_VERSION = "20260511053"
|
||||||
|
|
||||||
|
|
@ -30,10 +30,22 @@ MODULE_VERSIONS = {
|
||||||
"membership": "1.0.0",
|
"membership": "1.0.0",
|
||||||
"catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012)
|
"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
|
"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 = [
|
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",
|
"version": "0.8.91",
|
||||||
"date": "2026-05-11",
|
"date": "2026-05-11",
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ const REASON_OPTIONS = [
|
||||||
{ value: 'other', label: 'Sonstiges' },
|
{ 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 { user } = useAuth()
|
||||||
|
|
||||||
const [reason, setReason] = useState('')
|
const [reason, setReason] = useState('')
|
||||||
|
|
@ -55,6 +55,7 @@ export default function ReportContentModal({ targetType, targetId, targetLabel,
|
||||||
good_faith_confirmed: true,
|
good_faith_confirmed: true,
|
||||||
})
|
})
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
|
if (onSuccess) onSuccess()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || String(err))
|
setError(err.message || String(err))
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export function notifyOrgInboxChanged() {
|
||||||
export function OrgInboxProvider({ user, children }) {
|
export function OrgInboxProvider({ user, children }) {
|
||||||
const [items, setItems] = useState([])
|
const [items, setItems] = useState([])
|
||||||
const [contentReports, setContentReports] = useState([])
|
const [contentReports, setContentReports] = useState([])
|
||||||
|
const [contentReportsError, setContentReportsError] = useState(null)
|
||||||
const canAccess = useMemo(() => canAccessOrgInbox(user), [user])
|
const canAccess = useMemo(() => canAccessOrgInbox(user), [user])
|
||||||
const canAccessReports = useMemo(() => canSeeContentReports(user), [user])
|
const canAccessReports = useMemo(() => canSeeContentReports(user), [user])
|
||||||
|
|
||||||
|
|
@ -47,12 +48,15 @@ export function OrgInboxProvider({ user, children }) {
|
||||||
|
|
||||||
if (!canAccessReports) {
|
if (!canAccessReports) {
|
||||||
setContentReports([])
|
setContentReports([])
|
||||||
|
setContentReportsError(null)
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const data = await api.getInboxContentReports()
|
const data = await api.getInboxContentReports()
|
||||||
setContentReports(Array.isArray(data) ? data : [])
|
setContentReports(Array.isArray(data) ? data : [])
|
||||||
} catch {
|
setContentReportsError(null)
|
||||||
|
} catch (err) {
|
||||||
setContentReports([])
|
setContentReports([])
|
||||||
|
setContentReportsError(err?.message || String(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [canAccess, canAccessReports])
|
}, [canAccess, canAccessReports])
|
||||||
|
|
@ -61,6 +65,7 @@ export function OrgInboxProvider({ user, children }) {
|
||||||
if (!canAccess && !canAccessReports) {
|
if (!canAccess && !canAccessReports) {
|
||||||
setItems([])
|
setItems([])
|
||||||
setContentReports([])
|
setContentReports([])
|
||||||
|
setContentReportsError(null)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
@ -76,9 +81,15 @@ export function OrgInboxProvider({ user, children }) {
|
||||||
if (canAccessReports) {
|
if (canAccessReports) {
|
||||||
try {
|
try {
|
||||||
const data = await api.getInboxContentReports()
|
const data = await api.getInboxContentReports()
|
||||||
if (!cancelled) setContentReports(Array.isArray(data) ? data : [])
|
if (!cancelled) {
|
||||||
} catch {
|
setContentReports(Array.isArray(data) ? data : [])
|
||||||
if (!cancelled) setContentReports([])
|
setContentReportsError(null)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setContentReports([])
|
||||||
|
setContentReportsError(err?.message || String(err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
@ -99,12 +110,13 @@ export function OrgInboxProvider({ user, children }) {
|
||||||
inboxCount: items.length,
|
inboxCount: items.length,
|
||||||
contentReports,
|
contentReports,
|
||||||
contentReportCount: contentReports.filter((r) => r.status === 'submitted').length,
|
contentReportCount: contentReports.filter((r) => r.status === 'submitted').length,
|
||||||
|
contentReportsError,
|
||||||
refreshOrgInbox: refresh,
|
refreshOrgInbox: refresh,
|
||||||
canAccessOrgInbox: canAccess,
|
canAccessOrgInbox: canAccess,
|
||||||
canAccessContentReports: canAccessReports,
|
canAccessContentReports: canAccessReports,
|
||||||
isSuperadmin: user?.role === 'superadmin',
|
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>
|
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 }) {
|
function ReportDetailModal({ report, onClose, onRefresh, isSuperadmin }) {
|
||||||
const [resolutionNote, setResolutionNote] = useState('')
|
const [resolutionNote, setResolutionNote] = useState(report.resolution_note || '')
|
||||||
const [legalHoldNote, setLegalHoldNote] = useState('')
|
const [legalHoldNote, setLegalHoldNote] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [showLegalHoldForm, setShowLegalHoldForm] = useState(false)
|
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) {
|
async function handleStatus(status) {
|
||||||
if ((status === 'resolved_no_action' || status === 'rejected_invalid') && !resolutionNote.trim()) {
|
if ((status === 'resolved_no_action' || status === 'rejected_invalid') && !resolutionNote.trim()) {
|
||||||
setError('Bitte eine Begründung eingeben.')
|
setError('Bitte eine Begründung eingeben.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSaving(true)
|
await patchAndClose({ status, resolution_note: resolutionNote.trim() || undefined })
|
||||||
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() {
|
async function handleSaveNote() {
|
||||||
setSaving(true)
|
if (!resolutionNote.trim()) { setError('Notiz ist leer.'); return }
|
||||||
setError(null)
|
await patchAndClose({ resolution_note: resolutionNote.trim() })
|
||||||
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() {
|
async function handleLegalHold() {
|
||||||
if (!legalHoldNote.trim()) {
|
if (!legalHoldNote.trim()) { setError('Begründung für Legal Hold erforderlich.'); return }
|
||||||
setError('Begründung für Legal Hold erforderlich.')
|
await patchAndClose({ legalHoldNote })
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
async function handleLegalHoldSubmit() {
|
||||||
|
if (!legalHoldNote.trim()) { setError('Begründung für Legal Hold erforderlich.'); return }
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
|
|
@ -126,190 +160,160 @@ function ReportDetailModal({ report, onClose, onRefresh, isSuperadmin }) {
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || String(err))
|
setError(err.message || String(err))
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||||
top: 0,
|
background: 'rgba(0,0,0,0.55)', display: 'flex', alignItems: 'flex-start',
|
||||||
left: 0,
|
justifyContent: 'center', zIndex: 1100, padding: '1rem', overflowY: 'auto',
|
||||||
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() }}
|
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||||
>
|
>
|
||||||
<div
|
<div style={{
|
||||||
style={{
|
background: 'var(--surface)', borderRadius: '12px', padding: '1.5rem',
|
||||||
background: 'var(--surface)',
|
maxWidth: '580px', width: '100%', marginTop: '2rem', marginBottom: '2rem',
|
||||||
borderRadius: '12px',
|
}}>
|
||||||
padding: '1.5rem',
|
{/* Header */}
|
||||||
maxWidth: '560px',
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1.25rem' }}>
|
||||||
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>
|
<div>
|
||||||
<span className="muted" style={{ fontSize: '0.8rem' }}>Status</span>
|
<h2 style={{ margin: 0, fontSize: '1.1rem' }}>
|
||||||
<div style={{ fontWeight: 600, color: STATUS_COLORS[report.status] }}>
|
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}
|
{STATUS_LABELS[report.status] || report.status}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ padding: '4px 10px' }} onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Workflow Bar */}
|
||||||
<span className="muted" style={{ fontSize: '0.8rem' }}>Ziel</span>
|
<WorkflowBar status={report.status} />
|
||||||
<div>
|
|
||||||
{report.target_type === 'media_asset' ? 'Medium' : 'Übung'} #{report.target_id}
|
{/* Details */}
|
||||||
{report.target_name ? ` – ${report.target_name}` : ''}
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.55rem', marginBottom: '1.25rem', fontSize: '0.9rem' }}>
|
||||||
</div>
|
<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>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
<div>
|
<span className="muted" style={{ minWidth: 110 }}>Meldegrund</span>
|
||||||
<span className="muted" style={{ fontSize: '0.8rem' }}>Meldegrund</span>
|
<span>{REASON_LABELS[report.report_reason] || report.report_reason}</span>
|
||||||
<div>{REASON_LABELS[report.report_reason] || report.report_reason}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
<div>
|
<span className="muted" style={{ minWidth: 110 }}>Beschreibung</span>
|
||||||
<span className="muted" style={{ fontSize: '0.8rem' }}>Beschreibung</span>
|
<span style={{ whiteSpace: 'pre-wrap', flex: 1 }}>{report.report_description}</span>
|
||||||
<div style={{ whiteSpace: 'pre-wrap' }}>{report.report_description}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
<div>
|
<span className="muted" style={{ minWidth: 110 }}>Meldende Person</span>
|
||||||
<span className="muted" style={{ fontSize: '0.8rem' }}>Meldende Person</span>
|
<span>
|
||||||
<div>
|
|
||||||
{report.reporter_name}
|
{report.reporter_name}
|
||||||
{report.reporter_email ? ` · ${report.reporter_email}` : ''}
|
{report.reporter_email ? ` · ${report.reporter_email}` : ''}
|
||||||
{report.reporter_profile_id ? ` · Profil #${report.reporter_profile_id}` : ' · anonym'}
|
{report.reporter_profile_id ? ` · Profil #${report.reporter_profile_id}` : ' · anonym'}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
<div>
|
<span className="muted" style={{ minWidth: 110 }}>Eingegangen</span>
|
||||||
<span className="muted" style={{ fontSize: '0.8rem' }}>Eingegangen</span>
|
<span>{formatWhen(report.submitted_at || report.created_at)}</span>
|
||||||
<div>{formatWhen(report.submitted_at || report.created_at)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{report.reviewed_by_name && (
|
||||||
{report.resolution_note && (
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
<div>
|
<span className="muted" style={{ minWidth: 110 }}>Geprüft von</span>
|
||||||
<span className="muted" style={{ fontSize: '0.8rem' }}>Begründung</span>
|
<span>{report.reviewed_by_name}{report.reviewed_at ? ` · ${formatWhen(report.reviewed_at)}` : ''}</span>
|
||||||
<div style={{ whiteSpace: 'pre-wrap' }}>{report.resolution_note}</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{report.target_legal_hold_active && (
|
||||||
{report.legal_hold_active && (
|
<div style={{ background: 'rgba(216,90,48,0.1)', borderRadius: '8px', padding: '0.5rem 0.75rem' }}>
|
||||||
<div style={{ background: 'rgba(216,90,48,0.1)', borderRadius: '8px', padding: '0.6rem 0.8rem' }}>
|
<span style={{ color: 'var(--danger)', fontWeight: 600, fontSize: '0.88rem' }}>Legal Hold aktiv auf diesem Medium</span>
|
||||||
<span style={{ color: 'var(--danger)', fontWeight: 600 }}>Legal Hold aktiv</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 && (
|
{error && (
|
||||||
<div style={{ color: 'var(--danger)', marginBottom: '0.75rem', fontSize: '0.9rem' }}>{error}</div>
|
<div style={{ color: 'var(--danger)', marginBottom: '0.75rem', fontSize: '0.9rem' }}>{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
|
||||||
{report.status === 'submitted' && (
|
{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
|
In Bearbeitung nehmen
|
||||||
</button>
|
</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' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
<button
|
<button type="button" className="btn btn-secondary" onClick={() => handleStatus('resolved_no_action')} disabled={saving}>
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
onClick={() => handleStatus('resolved_no_action')}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
Kein Handlungsbedarf
|
Kein Handlungsbedarf
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button type="button" className="btn btn-secondary" onClick={() => handleStatus('rejected_invalid')} disabled={saving}>
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
onClick={() => handleStatus('rejected_invalid')}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
Meldung abweisen
|
Meldung abweisen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSuperadmin && !showLegalHoldForm && (
|
{isSuperadmin && !showLegalHoldForm && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button" className="btn"
|
||||||
className="btn"
|
|
||||||
style={{ background: 'rgba(216,90,48,0.12)', color: 'var(--danger)', border: '1px solid var(--danger)' }}
|
style={{ background: 'rgba(216,90,48,0.12)', color: 'var(--danger)', border: '1px solid var(--danger)' }}
|
||||||
onClick={() => setShowLegalHoldForm(true)}
|
onClick={() => setShowLegalHoldForm(true)}
|
||||||
>
|
>
|
||||||
Legal Hold setzen (Superadmin)
|
Legal Hold setzen (Superadmin)
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isSuperadmin && showLegalHoldForm && (
|
{isSuperadmin && showLegalHoldForm && (
|
||||||
<div style={{ border: '1px solid var(--danger)', borderRadius: '8px', padding: '0.75rem' }}>
|
<div style={{ border: '1px solid var(--danger)', borderRadius: '8px', padding: '0.75rem' }}>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label" style={{ color: 'var(--danger)' }}>Begründung Legal Hold *</label>
|
<label className="form-label" style={{ color: 'var(--danger)' }}>Begründung Legal Hold *</label>
|
||||||
<textarea
|
<textarea className="form-input" rows={2} value={legalHoldNote} onChange={(e) => setLegalHoldNote(e.target.value)} placeholder="Rechtliche Begründung…" />
|
||||||
className="form-input"
|
|
||||||
rows={2}
|
|
||||||
value={legalHoldNote}
|
|
||||||
onChange={(e) => setLegalHoldNote(e.target.value)}
|
|
||||||
placeholder="Rechtliche Begründung für den Legal Hold…"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
|
||||||
<button
|
<button type="button" className="btn" style={{ background: 'var(--danger)', color: '#fff', flex: 1 }} onClick={handleLegalHoldSubmit} disabled={saving}>
|
||||||
type="button"
|
|
||||||
className="btn"
|
|
||||||
style={{ background: 'var(--danger)', color: '#fff', flex: 1 }}
|
|
||||||
onClick={handleLegalHold}
|
|
||||||
disabled={saving}
|
|
||||||
>
|
|
||||||
Legal Hold bestätigen
|
Legal Hold bestätigen
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => setShowLegalHoldForm(false)}>
|
<button type="button" className="btn btn-secondary" onClick={() => setShowLegalHoldForm(false)}>Abbrechen</button>
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOpen && (
|
{isClosed && (
|
||||||
<p className="muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
<button
|
||||||
Diese Meldung ist bereits abgeschlossen.
|
type="button" className="btn btn-secondary"
|
||||||
</p>
|
style={{ width: '100%' }}
|
||||||
|
onClick={() => patchAndClose({ status: 'submitted' })}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Meldung wieder öffnen (zurück auf Eingegangen)
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -325,6 +329,7 @@ export default function InboxPage() {
|
||||||
inboxJoinRequests,
|
inboxJoinRequests,
|
||||||
contentReports,
|
contentReports,
|
||||||
contentReportCount,
|
contentReportCount,
|
||||||
|
contentReportsError,
|
||||||
} = useOrgInbox()
|
} = useOrgInbox()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [acceptModal, setAcceptModal] = useState(null)
|
const [acceptModal, setAcceptModal] = useState(null)
|
||||||
|
|
@ -487,7 +492,16 @@ export default function InboxPage() {
|
||||||
)}
|
)}
|
||||||
</h2>
|
</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' }}>
|
<div className="card" style={{ padding: '1.25rem' }}>
|
||||||
<p style={{ margin: 0 }} className="muted">Keine Inhaltsmeldungen.</p>
|
<p style={{ margin: 0 }} className="muted">Keine Inhaltsmeldungen.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2023,6 +2023,12 @@ export default function MediaLibraryPage() {
|
||||||
targetId={reportTarget.id}
|
targetId={reportTarget.id}
|
||||||
targetLabel={reportTarget.original_filename || `Medium #${reportTarget.id}`}
|
targetLabel={reportTarget.original_filename || `Medium #${reportTarget.id}`}
|
||||||
onClose={() => setReportTarget(null)}
|
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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Shinkan Jinkendo Frontend Version
|
// 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 BUILD_DATE = "2026-05-11"
|
||||||
|
|
||||||
export const PAGE_VERSIONS = {
|
export const PAGE_VERSIONS = {
|
||||||
|
|
@ -27,8 +27,8 @@ export const PAGE_VERSIONS = {
|
||||||
ExerciseMediaEmbed: "1.1.0", // P-11: Legal-Hold-Placeholder statt Datei
|
ExerciseMediaEmbed: "1.1.0", // P-11: Legal-Hold-Placeholder statt Datei
|
||||||
ExerciseMediaThumbTile: "1.1.0", // P-11: Legal-Hold-Kachel statt Datei-Vorschau
|
ExerciseMediaThumbTile: "1.1.0", // P-11: Legal-Hold-Kachel statt Datei-Vorschau
|
||||||
ExerciseAttachmentMediaStrip: "1.2.0", // P-13: MediaPreviewModal (geteilt) + Melden im Viewer
|
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
|
InboxPage: "2.2.0", // P-13: Workflow-Balken, Wieder-öffnen, Kommentar, Fehleranzeigezeige
|
||||||
OrgInboxContext: "1.1.0", // P-13: canSeeContentReports schliesst Club-Admins ein
|
OrgInboxContext: "1.2.0", // P-13: contentReportsError exposed
|
||||||
MediaPreviewModal: "1.0.0", // P-13: geteilter Medienvorschau-Dialog (Melden + Bearbeiten optional)
|
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