From 34e93101f199a91512d1b36e982bf7f50ce70fe9 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 11 May 2026 21:32:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(P-13):=20Workflow-Management,=20Fehleranze?= =?UTF-8?q?ige,=20Badge-Update,=20Wieder-=C3=B6ffnen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/routers/content_reports.py | 50 ++- backend/version.py | 16 +- .../src/components/ReportContentModal.jsx | 3 +- frontend/src/context/OrgInboxContext.jsx | 22 +- frontend/src/pages/InboxPage.jsx | 320 +++++++++--------- frontend/src/pages/MediaLibraryPage.jsx | 6 + frontend/src/version.js | 8 +- 7 files changed, 244 insertions(+), 181 deletions(-) diff --git a/backend/routers/content_reports.py b/backend/routers/content_reports.py index 454e444..a922996 100644 --- a/backend/routers/content_reports.py +++ b/backend/routers/content_reports.py @@ -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() diff --git a/backend/version.py b/backend/version.py index b3b3770..720f0cc 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/frontend/src/components/ReportContentModal.jsx b/frontend/src/components/ReportContentModal.jsx index 0476560..a01b1bd 100644 --- a/frontend/src/components/ReportContentModal.jsx +++ b/frontend/src/components/ReportContentModal.jsx @@ -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 { diff --git a/frontend/src/context/OrgInboxContext.jsx b/frontend/src/context/OrgInboxContext.jsx index 59e82de..84e994a 100644 --- a/frontend/src/context/OrgInboxContext.jsx +++ b/frontend/src/context/OrgInboxContext.jsx @@ -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 {children} diff --git a/frontend/src/pages/InboxPage.jsx b/frontend/src/pages/InboxPage.jsx index 4537d8e..8c2492c 100644 --- a/frontend/src/pages/InboxPage.jsx +++ b/frontend/src/pages/InboxPage.jsx @@ -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 ( +
+ {WORKFLOW_STEPS.map((s, i) => { + const active = i === step + const done = i < step + return ( + +
+
+ {done ? '✓' : i + 1} +
+ + {s.label} + +
+ {i < WORKFLOW_STEPS.length - 1 && ( +
+ )} + + ) + })} +
+ ) +} + 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 (
{ if (e.target === e.currentTarget) onClose() }} > -
-
-

- Meldung #{report.id} - -

- -
- -
+
+ {/* Header */} +
- Status -
+

+ Meldung #{report.id} + +

+
{STATUS_LABELS[report.status] || report.status}
+ +
-
- Ziel -
- {report.target_type === 'media_asset' ? 'Medium' : 'Übung'} #{report.target_id} - {report.target_name ? ` – ${report.target_name}` : ''} -
+ {/* Workflow Bar */} + + + {/* Details */} +
+
+ Ziel + {report.target_type === 'media_asset' ? 'Medium' : 'Übung'} {targetLabel}
- -
- Meldegrund -
{REASON_LABELS[report.report_reason] || report.report_reason}
+
+ Meldegrund + {REASON_LABELS[report.report_reason] || report.report_reason}
- -
- Beschreibung -
{report.report_description}
+
+ Beschreibung + {report.report_description}
- -
- Meldende Person -
+
+ Meldende Person + {report.reporter_name} {report.reporter_email ? ` · ${report.reporter_email}` : ''} {report.reporter_profile_id ? ` · Profil #${report.reporter_profile_id}` : ' · anonym'} -
+
- -
- Eingegangen -
{formatWhen(report.submitted_at || report.created_at)}
+
+ Eingegangen + {formatWhen(report.submitted_at || report.created_at)}
- - {report.resolution_note && ( -
- Begründung -
{report.resolution_note}
+ {report.reviewed_by_name && ( +
+ Geprüft von + {report.reviewed_by_name}{report.reviewed_at ? ` · ${formatWhen(report.reviewed_at)}` : ''}
)} - - {report.legal_hold_active && ( -
- Legal Hold aktiv + {report.target_legal_hold_active && ( +
+ Legal Hold aktiv auf diesem Medium
)}
+ {/* Resolution Note (Bearbeitungskommentar) */} +
+ +