Refactor Org Inbox Context and Enhance Club Creation Management
Some checks failed
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 42s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Failing after 1m21s

- Updated the OrgInboxContext to include handling for club creation requests, allowing for better management of inbox items.
- Refactored components to utilize the new `canShowInboxNav` and `canAccessClubCreationInbox` flags for improved access control.
- Enhanced the InboxPage to display club creation requests with appropriate actions for approval and rejection.
- Updated the DashboardOrgInboxWidget to show both club creation and join requests, improving the user interface for managing inbox items.
This commit is contained in:
Lars 2026-06-07 07:18:43 +02:00
parent 8ee8f52e0f
commit 37785135b1
5 changed files with 206 additions and 28 deletions

View File

@ -88,9 +88,9 @@ function AppRouteFallback() {
// Bottom Navigation (Mobile)
function Nav({ showAdminNav, onboardingOnly }) {
const { canAccessOrgInbox, inboxCount } = useOrgInbox()
const { canShowInboxNav, inboxCount } = useOrgInbox()
const items = getMainNavItems(showAdminNav, {
showInbox: canAccessOrgInbox,
showInbox: canShowInboxNav,
onboardingOnly,
})
const loc = useLocation()

View File

@ -6,11 +6,30 @@ import { useOrgInbox } from '../context/OrgInboxContext'
* Desktop-Dashboard: Hinweis auf offene Beitrittsanträge (nur ab 1024px sichtbar via CSS).
*/
export default function DashboardOrgInboxWidget() {
const { canAccessOrgInbox, inboxJoinRequests, inboxCount } = useOrgInbox()
const {
canShowInboxNav,
inboxJoinRequests,
inboxClubCreationRequests,
clubCreationRequestCount,
inboxCount,
} = useOrgInbox()
if (!canAccessOrgInbox) return null
if (!canShowInboxNav) return null
const preview = (inboxJoinRequests || []).slice(0, 5)
const preview = [
...(inboxClubCreationRequests || []).map((req) => ({
key: `creation-${req.id}`,
club: req.proposed_name || 'Neuer Verein',
applicant: req.applicant_name || req.applicant_email || 'Antragsteller/in',
kind: 'creation',
})),
...(inboxJoinRequests || []).map((req) => ({
key: `${req.club_id}-${req.id}`,
club: req.club_name || 'Verein',
applicant: req.applicant_name || req.applicant_email || 'Bewerber/in',
kind: 'join',
})),
].slice(0, 5)
return (
<section
@ -31,17 +50,27 @@ export default function DashboardOrgInboxWidget() {
</div>
<p className="muted dashboard-org-inbox-widget__lead">
{inboxCount === 0
? 'Keine offenen Beitrittsanträge.'
: `${inboxCount} offene Beitrittsantrag${inboxCount === 1 ? '' : 'e'}.`}
? 'Keine offenen Anträge.'
: [
clubCreationRequestCount > 0
? `${clubCreationRequestCount} Gründungsantrag${clubCreationRequestCount === 1 ? '' : 'e'}`
: null,
(inboxJoinRequests || []).length > 0
? `${(inboxJoinRequests || []).length} Beitrittsantrag${(inboxJoinRequests || []).length === 1 ? '' : 'e'}`
: null,
]
.filter(Boolean)
.join(' · ')}
</p>
{preview.length > 0 ? (
<ul className="dashboard-org-inbox-widget__list">
{preview.map((req) => (
<li key={`${req.club_id}-${req.id}`} className="dashboard-org-inbox-widget__item">
<span className="dashboard-org-inbox-widget__club">{req.club_name || 'Verein'}</span>
<span className="dashboard-org-inbox-widget__applicant">
{req.applicant_name || req.applicant_email || 'Bewerber/in'}
<li key={req.key} className="dashboard-org-inbox-widget__item">
<span className="dashboard-org-inbox-widget__club">
{req.kind === 'creation' ? 'Gründung: ' : ''}
{req.club}
</span>
<span className="dashboard-org-inbox-widget__applicant">{req.applicant}</span>
</li>
))}
</ul>

View File

@ -19,9 +19,9 @@ export default function DesktopSidebar({
onLogout
}) {
const loc = useLocation()
const { canAccessOrgInbox, inboxCount } = useOrgInbox()
const { canShowInboxNav, inboxCount } = useOrgInbox()
const items = getMainNavItems(showAdminNav, {
showInbox: canAccessOrgInbox,
showInbox: canShowInboxNav,
onboardingOnly,
})
const tier = user?.tier || ''

View File

@ -17,6 +17,11 @@ export function canAccessOrgInbox(user) {
return activeClubMemberships(user.clubs).some((c) => (c.roles || []).includes('club_admin'))
}
/** Gründungsanträge freigeben — aktuell nur Superadmin (platform.club_creation.approve). */
export function canAccessClubCreationInbox(user) {
return user?.role === 'superadmin'
}
function canSeeContentReports(user) {
if (user?.role === 'admin' || user?.role === 'superadmin') return true
return activeClubMemberships(user?.clubs || []).some((c) => (c.roles || []).includes('club_admin'))
@ -28,8 +33,13 @@ export function notifyOrgInboxChanged() {
}
/** Eine konsistente Ladepfad-Logik für Join-Requests + Content-Reports (ein Codepfad für Mount + refresh). */
async function fetchOrgInboxSnapshot(canAccess, canAccessReports) {
const out = { items: [], contentReports: [], contentReportsError: null }
async function fetchOrgInboxSnapshot(canAccess, canAccessReports, canAccessClubCreation) {
const out = {
items: [],
clubCreationRequests: [],
contentReports: [],
contentReportsError: null,
}
if (canAccess) {
try {
const data = await api.getInboxJoinRequests()
@ -38,6 +48,14 @@ async function fetchOrgInboxSnapshot(canAccess, canAccessReports) {
out.items = []
}
}
if (canAccessClubCreation) {
try {
const data = await api.listAdminClubCreationRequests()
out.clubCreationRequests = Array.isArray(data) ? data : []
} catch {
out.clubCreationRequests = []
}
}
if (canAccessReports) {
try {
const data = await api.getInboxContentReports()
@ -52,27 +70,33 @@ async function fetchOrgInboxSnapshot(canAccess, canAccessReports) {
export function OrgInboxProvider({ user, children }) {
const [items, setItems] = useState([])
const [clubCreationRequests, setClubCreationRequests] = useState([])
const [contentReports, setContentReports] = useState([])
const [contentReportsError, setContentReportsError] = useState(null)
const canAccess = useMemo(() => canAccessOrgInbox(user), [user])
const canAccessReports = useMemo(() => canSeeContentReports(user), [user])
const canAccessClubCreation = useMemo(() => canAccessClubCreationInbox(user), [user])
const hasInboxAccess = canAccess || canAccessReports || canAccessClubCreation
const refresh = useCallback(async () => {
if (!canAccess && !canAccessReports) {
if (!hasInboxAccess) {
setItems([])
setClubCreationRequests([])
setContentReports([])
setContentReportsError(null)
return
}
const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports)
const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports, canAccessClubCreation)
setItems(snap.items)
setClubCreationRequests(snap.clubCreationRequests)
setContentReports(snap.contentReports)
setContentReportsError(canAccessReports ? snap.contentReportsError : null)
}, [canAccess, canAccessReports])
}, [hasInboxAccess, canAccess, canAccessReports, canAccessClubCreation])
useEffect(() => {
if (!canAccess && !canAccessReports) {
if (!hasInboxAccess) {
setItems([])
setClubCreationRequests([])
setContentReports([])
setContentReportsError(null)
return undefined
@ -82,9 +106,10 @@ export function OrgInboxProvider({ user, children }) {
let timeoutId = null
const load = async () => {
const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports)
const snap = await fetchOrgInboxSnapshot(canAccess, canAccessReports, canAccessClubCreation)
if (cancelled) return
setItems(snap.items)
setClubCreationRequests(snap.clubCreationRequests)
setContentReports(snap.contentReports)
setContentReportsError(canAccessReports ? snap.contentReportsError : null)
}
@ -116,7 +141,7 @@ export function OrgInboxProvider({ user, children }) {
}
if (timeoutId != null) window.clearTimeout(timeoutId)
}
}, [canAccess, canAccessReports, user?.id])
}, [hasInboxAccess, canAccess, canAccessReports, canAccessClubCreation, user?.id])
useEffect(() => {
const onChange = () => { refresh() }
@ -124,21 +149,42 @@ export function OrgInboxProvider({ user, children }) {
return () => window.removeEventListener('shinkan:inbox-changed', onChange)
}, [refresh])
const clubCreationCount = clubCreationRequests.length
const joinCount = items.length
const value = useMemo(
() => ({
inboxJoinRequests: items,
inboxCount: items.length,
inboxClubCreationRequests: clubCreationRequests,
clubCreationRequestCount: clubCreationCount,
inboxCount: joinCount + clubCreationCount,
contentReports,
contentReportCount: contentReports.filter((r) => r.status === 'submitted').length,
contentReportsError,
refreshOrgInbox: refresh,
canAccessOrgInbox: canAccess,
canAccessContentReports: canAccessReports,
canAccessClubCreationInbox: canAccessClubCreation,
canShowInboxNav: hasInboxAccess,
isSuperadmin: user?.role === 'superadmin',
isPlatformAdmin: user?.role === 'admin' || user?.role === 'superadmin',
isClubAdmin: activeClubMemberships(user?.clubs || []).some((c) => (c.roles || []).includes('club_admin')),
}),
[items, contentReports, contentReportsError, refresh, canAccess, canAccessReports, user?.role, user?.clubs]
[
items,
clubCreationRequests,
clubCreationCount,
joinCount,
contentReports,
contentReportsError,
refresh,
canAccess,
canAccessReports,
canAccessClubCreation,
hasInboxAccess,
user?.role,
user?.clubs,
]
)
return <OrgInboxContext.Provider value={value}>{children}</OrgInboxContext.Provider>

View File

@ -324,11 +324,14 @@ export default function InboxPage() {
const {
canAccessOrgInbox,
canAccessContentReports,
canAccessClubCreationInbox,
canShowInboxNav,
isSuperadmin,
isPlatformAdmin,
isClubAdmin,
refreshOrgInbox,
inboxJoinRequests,
inboxClubCreationRequests,
contentReports,
contentReportCount,
contentReportsError,
@ -339,7 +342,7 @@ export default function InboxPage() {
const [showArchive, setShowArchive] = useState(false)
const load = useCallback(async () => {
if (!canAccessOrgInbox && !canAccessContentReports) {
if (!canShowInboxNav) {
setLoading(false)
return
}
@ -349,13 +352,13 @@ export default function InboxPage() {
} finally {
setLoading(false)
}
}, [canAccessOrgInbox, canAccessContentReports, refreshOrgInbox])
}, [canShowInboxNav, refreshOrgInbox])
useEffect(() => {
load()
}, [load])
if (!canAccessOrgInbox && !canAccessContentReports) {
if (!canShowInboxNav) {
return (
<div className="app-page">
<h1 className="page-title">Posteingang</h1>
@ -375,7 +378,7 @@ export default function InboxPage() {
Posteingang
</h1>
<p className="muted" style={{ marginTop: 0 }}>
Beitrittsanträge und Inhaltsmeldungen für deine Zuständigkeitsbereiche.
Beitrittsanträge, Vereinsgründungen und Inhaltsmeldungen für deine Zuständigkeitsbereiche.
</p>
</div>
<button type="button" className="btn btn-secondary" onClick={() => load()} disabled={loading}>
@ -389,7 +392,107 @@ export default function InboxPage() {
</div>
) : (
<>
{/* Abschnitt 1: Beitrittsanträge */}
{/* Abschnitt: Vereinsgründungen (nur Superadmin) */}
{canAccessClubCreationInbox && (
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text2)' }}>
Vereinsgründungen
{inboxClubCreationRequests.length > 0 && (
<span
style={{
background: 'var(--accent)',
color: '#fff',
borderRadius: '12px',
padding: '1px 8px',
fontSize: '0.75rem',
marginLeft: '0.5rem',
}}
>
{inboxClubCreationRequests.length}
</span>
)}
</h2>
{inboxClubCreationRequests.length === 0 ? (
<div className="card" style={{ padding: '1.25rem' }}>
<p style={{ margin: 0 }} className="muted">Keine offenen Gründungsanträge.</p>
</div>
) : (
<div className="inbox-page__list">
{inboxClubCreationRequests.map((req) => (
<div key={`creation-${req.id}`} className="card inbox-request-card">
<div className="inbox-request-card__main">
<div className="inbox-request-card__club">
{req.proposed_name}
{req.proposed_abbreviation ? (
<span className="muted" style={{ marginLeft: '0.35rem' }}>
({req.proposed_abbreviation})
</span>
) : null}
</div>
<strong className="inbox-request-card__applicant">
{req.applicant_name || req.applicant_email || 'Antragsteller/in'}
</strong>
<div className="muted inbox-request-card__meta">
{req.applicant_email} · Profil #{req.profile_id} · {formatWhen(req.created_at)}
</div>
{req.proposed_description ? (
<p className="inbox-request-card__message">{req.proposed_description}</p>
) : null}
{req.message ? (
<p className="inbox-request-card__message" style={{ fontStyle: 'italic' }}>
Nachricht: {req.message}
</p>
) : null}
</div>
<div className="inbox-request-card__actions">
<button
type="button"
className="btn btn-primary"
onClick={async () => {
if (
!confirm(
'Verein anlegen und Antragsteller als Hauptverwalter eintragen?'
)
) {
return
}
try {
await api.approveClubCreationRequest(req.id)
notifyOrgInboxChanged()
await load()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Freigeben
</button>
<button
type="button"
className="btn btn-secondary"
onClick={async () => {
if (!confirm('Gründungsantrag ablehnen?')) return
try {
await api.rejectClubCreationRequest(req.id)
notifyOrgInboxChanged()
await load()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Ablehnen
</button>
</div>
</div>
))}
</div>
)}
</section>
)}
{/* Abschnitt: Beitrittsanträge */}
{canAccessOrgInbox && (
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text2)' }}>