feat(org-inbox): implement join request inbox for platform and club admins
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 30s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 24s

- Added new API endpoint to retrieve join requests accessible by platform admins and club admins.
- Implemented frontend components to display join requests in the inbox, including navigation updates and badge notifications.
- Enhanced sidebar and navigation to conditionally show inbox based on user permissions.
- Updated styles for inbox components and added responsive design for dashboard integration.
- Introduced context management for inbox state and notifications on join request actions.
This commit is contained in:
Lars 2026-05-09 09:13:38 +02:00
parent 59fb8a5527
commit 58a38702b9
11 changed files with 631 additions and 20 deletions

View File

@ -6,7 +6,7 @@ from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from club_tenancy import can_manage_club_org from club_tenancy import can_manage_club_org, is_platform_admin
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context from tenant_context import TenantContext, get_tenant_context
@ -106,6 +106,72 @@ def _response_one(cur, req_id: int, viewer_profile_id: int) -> Dict[str, Any]:
return r2d(row) return r2d(row)
def _can_access_org_inbox(cur, profile_id: int, global_role: Optional[str]) -> bool:
"""Posteingang (Beitrittsanträge bearbeiten): Plattform-Admin oder Vereinsadmin in mind. einem Verein."""
if is_platform_admin(global_role):
return True
cur.execute(
"""
SELECT 1
FROM club_members cm
INNER JOIN club_member_roles r ON r.club_member_id = cm.id AND r.role_code = 'club_admin'
WHERE cm.profile_id = %s AND cm.status = 'active'
LIMIT 1
""",
(profile_id,),
)
return cur.fetchone() is not None
def _club_ids_manageable_by_user(cur, profile_id: int, global_role: Optional[str]) -> List[int]:
"""Club-IDs, für die der Nutzer Beitrittsanträge sehen darf."""
if is_platform_admin(global_role):
cur.execute("SELECT id FROM clubs WHERE status = 'active' ORDER BY name")
return [int(r["id"]) for r in cur.fetchall()]
cur.execute(
"""
SELECT DISTINCT cm.club_id
FROM club_members cm
INNER JOIN club_member_roles r ON r.club_member_id = cm.id AND r.role_code = 'club_admin'
WHERE cm.profile_id = %s AND cm.status = 'active'
""",
(profile_id,),
)
return [int(r["club_id"]) for r in cur.fetchall()]
@router.get("/me/inbox/join-requests")
def list_inbox_join_requests(tenant: TenantContext = Depends(get_tenant_context)):
"""
Alle offenen Beitrittsanträge, die der Nutzer bearbeiten darf:
Plattform-Admin: alle Vereine; sonst nur Vereine mit Rolle club_admin.
"""
pid = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
if not _can_access_org_inbox(cur, pid, role):
raise HTTPException(status_code=403, detail="Kein Zugriff auf den Organisations-Posteingang")
club_ids = _club_ids_manageable_by_user(cur, pid, role)
if not club_ids:
return []
cur.execute(
"""
SELECT r.id, r.profile_id, r.club_id, r.status, r.message, r.created_at,
c.name AS club_name, c.abbreviation AS club_abbreviation,
p.name AS applicant_name, p.email AS applicant_email
FROM club_membership_requests r
INNER JOIN clubs c ON c.id = r.club_id
INNER JOIN profiles p ON p.id = r.profile_id
WHERE r.status = 'pending'
AND r.club_id = ANY(%s)
ORDER BY r.created_at ASC
""",
(club_ids,),
)
return [r2d(row) for row in cur.fetchall()]
@router.get("/me/club-join-requests") @router.get("/me/club-join-requests")
def get_my_join_requests(tenant: TenantContext = Depends(get_tenant_context)): def get_my_join_requests(tenant: TenantContext = Depends(get_tenant_context)):
pid = tenant.profile_id pid = tenant.profile_id

View File

@ -9,6 +9,7 @@ import {
Outlet, Outlet,
} from 'react-router-dom' } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext' import { AuthProvider, useAuth } from './context/AuthContext'
import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext'
import DesktopSidebar from './components/DesktopSidebar' import DesktopSidebar from './components/DesktopSidebar'
import { getMainNavItems } from './config/appNav' import { getMainNavItems } from './config/appNav'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
@ -20,6 +21,7 @@ import ExercisesListPage from './pages/ExercisesListPage'
import ExerciseDetailPage from './pages/ExerciseDetailPage' import ExerciseDetailPage from './pages/ExerciseDetailPage'
import ExerciseFormPage from './pages/ExerciseFormPage' import ExerciseFormPage from './pages/ExerciseFormPage'
import ClubsPage from './pages/ClubsPage' import ClubsPage from './pages/ClubsPage'
import InboxPage from './pages/InboxPage'
import SkillsPage from './pages/SkillsPage' import SkillsPage from './pages/SkillsPage'
import TrainingPlanningPage from './pages/TrainingPlanningPage' import TrainingPlanningPage from './pages/TrainingPlanningPage'
import TrainingFrameworkProgramsListPage from './pages/TrainingFrameworkProgramsListPage' import TrainingFrameworkProgramsListPage from './pages/TrainingFrameworkProgramsListPage'
@ -38,7 +40,8 @@ import './app.css'
// Bottom Navigation (Mobile) // Bottom Navigation (Mobile)
function Nav({ isAdmin }) { function Nav({ isAdmin }) {
const items = getMainNavItems(isAdmin) const { canAccessOrgInbox, inboxCount } = useOrgInbox()
const items = getMainNavItems(isAdmin, { showInbox: canAccessOrgInbox })
const loc = useLocation() const loc = useLocation()
const navItemActive = (pathname, item, routerIsActive) => { const navItemActive = (pathname, item, routerIsActive) => {
@ -58,6 +61,11 @@ function Nav({ isAdmin }) {
} }
> >
<item.Icon size={26} strokeWidth={2} /> <item.Icon size={26} strokeWidth={2} />
{item.to === '/inbox' && inboxCount > 0 ? (
<span className="nav-item__badge" aria-label={`${inboxCount} offen`}>
{inboxCount > 99 ? '99+' : inboxCount}
</span>
) : null}
<span>{item.shortLabel || item.label}</span> <span>{item.shortLabel || item.label}</span>
</NavLink> </NavLink>
))} ))}
@ -98,7 +106,7 @@ function ProtectedLayout() {
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin' const isAdmin = user?.role === 'admin' || user?.role === 'superadmin'
return ( return (
<> <OrgInboxProvider user={user}>
<DesktopSidebar isAdmin={isAdmin} user={user} onLogout={handleLogout} /> <DesktopSidebar isAdmin={isAdmin} user={user} onLogout={handleLogout} />
<div className="app-shell"> <div className="app-shell">
<div className="app-shell__column"> <div className="app-shell__column">
@ -114,7 +122,7 @@ function ProtectedLayout() {
<Nav isAdmin={isAdmin} /> <Nav isAdmin={isAdmin} />
</div> </div>
</div> </div>
</> </OrgInboxProvider>
) )
} }
@ -167,6 +175,7 @@ function AppRoutes() {
<Route path=":id" element={<ExerciseDetailPage />} /> <Route path=":id" element={<ExerciseDetailPage />} />
</Route> </Route>
<Route path="clubs" element={<ClubsPage />} /> <Route path="clubs" element={<ClubsPage />} />
<Route path="inbox" element={<InboxPage />} />
<Route path="skills" element={<SkillsPage />} /> <Route path="skills" element={<SkillsPage />} />
<Route path="planning/framework-programs/new" element={<TrainingFrameworkProgramEditPage />} /> <Route path="planning/framework-programs/new" element={<TrainingFrameworkProgramEditPage />} />
<Route path="planning/framework-programs/:id" element={<TrainingFrameworkProgramEditPage />} /> <Route path="planning/framework-programs/:id" element={<TrainingFrameworkProgramEditPage />} />

View File

@ -198,6 +198,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
min-width: 68px; min-width: 68px;
max-width: 108px; max-width: 108px;
min-height: 48px; min-height: 48px;
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -6376,3 +6377,156 @@ a.analysis-split__nav-item {
.media-library__preview-fallback .btn { .media-library__preview-fallback .btn {
margin-top: 12px; margin-top: 12px;
} }
/* Organisation: Posteingang (Nav-Badge, Sidebar, Dashboard-Widget) */
.nav-item__badge {
position: absolute;
top: 2px;
right: 6px;
min-width: 1.1rem;
padding: 1px 5px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
line-height: 1.2;
background: var(--accent);
color: #fff;
box-shadow: 0 0 0 1px var(--surface);
}
.desktop-sidebar__badge {
margin-left: auto;
flex-shrink: 0;
min-width: 1.25rem;
padding: 2px 6px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
line-height: 1.2;
background: var(--accent);
color: #fff;
}
.desktop-sidebar__link > span:not(.desktop-sidebar__badge) {
flex: 1;
min-width: 0;
}
.dashboard-org-inbox-widget {
display: none;
}
@media (min-width: 1024px) {
.dashboard-org-inbox-widget {
display: block;
margin-bottom: 1.25rem;
}
}
.dashboard-org-inbox-widget__inner {
padding: 1rem 1.1rem;
}
.dashboard-org-inbox-widget__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.35rem;
}
.dashboard-org-inbox-widget__title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
font-weight: 600;
margin: 0;
}
.dashboard-org-inbox-widget__icon {
color: var(--accent);
flex-shrink: 0;
}
.dashboard-org-inbox-widget__badge {
font-size: 0.8rem;
font-weight: 700;
min-width: 1.5rem;
text-align: center;
padding: 2px 8px;
border-radius: 999px;
background: var(--accent);
color: #fff;
}
.dashboard-org-inbox-widget__lead {
margin: 0 0 0.65rem;
font-size: 0.92rem;
}
.dashboard-org-inbox-widget__list {
list-style: none;
margin: 0 0 0.85rem;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.dashboard-org-inbox-widget__item {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.75rem;
font-size: 0.9rem;
padding: 0.35rem 0;
border-bottom: 1px solid var(--border);
}
.dashboard-org-inbox-widget__item:last-child {
border-bottom: none;
}
.dashboard-org-inbox-widget__club {
font-weight: 600;
color: var(--text1);
}
.dashboard-org-inbox-widget__applicant {
color: var(--text2);
}
.dashboard-org-inbox-widget__footer {
margin-top: 0.25rem;
}
.inbox-page__header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.inbox-page__list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.inbox-request-card {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: space-between;
align-items: flex-start;
}
.inbox-request-card__club {
font-size: 0.85rem;
font-weight: 600;
color: var(--accent);
margin-bottom: 0.2rem;
}
.inbox-request-card__applicant {
display: block;
font-size: 1.02rem;
}
.inbox-request-card__meta {
font-size: 0.86rem;
margin-top: 0.25rem;
}
.inbox-request-card__message {
margin: 0.5rem 0 0;
font-size: 0.92rem;
line-height: 1.45;
}
.inbox-request-card__actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
flex-shrink: 0;
}

View File

@ -0,0 +1,57 @@
import { Link } from 'react-router-dom'
import { Inbox } from 'lucide-react'
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()
if (!canAccessOrgInbox) return null
const preview = (inboxJoinRequests || []).slice(0, 5)
return (
<section
className="dashboard-org-inbox-widget"
aria-labelledby="dash-org-inbox-title"
>
<div className="dashboard-org-inbox-widget__inner card">
<div className="dashboard-org-inbox-widget__head">
<h2 id="dash-org-inbox-title" className="dashboard-org-inbox-widget__title">
<Inbox size={20} strokeWidth={2} aria-hidden className="dashboard-org-inbox-widget__icon" />
Posteingang
</h2>
{inboxCount > 0 ? (
<span className="dashboard-org-inbox-widget__badge" aria-live="polite">
{inboxCount}
</span>
) : null}
</div>
<p className="muted dashboard-org-inbox-widget__lead">
{inboxCount === 0
? 'Keine offenen Beitrittsanträge.'
: `${inboxCount} offene Beitrittsantrag${inboxCount === 1 ? '' : 'e'}.`}
</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'}
</span>
</li>
))}
</ul>
) : null}
<div className="dashboard-org-inbox-widget__footer">
<Link to="/inbox" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
Zum Posteingang
</Link>
</div>
</div>
</section>
)
}

View File

@ -1,6 +1,7 @@
import { NavLink, useLocation } from 'react-router-dom' import { NavLink, useLocation } from 'react-router-dom'
import { LogOut } from 'lucide-react' import { LogOut } from 'lucide-react'
import { getMainNavItems } from '../config/appNav' import { getMainNavItems } from '../config/appNav'
import { useOrgInbox } from '../context/OrgInboxContext'
import ActiveClubSwitcher from './ActiveClubSwitcher' import ActiveClubSwitcher from './ActiveClubSwitcher'
function sidebarLinkActive(pathname, item, routerIsActive) { function sidebarLinkActive(pathname, item, routerIsActive) {
@ -17,7 +18,8 @@ export default function DesktopSidebar({
onLogout onLogout
}) { }) {
const loc = useLocation() const loc = useLocation()
const items = getMainNavItems(isAdmin) const { canAccessOrgInbox, inboxCount } = useOrgInbox()
const items = getMainNavItems(isAdmin, { showInbox: canAccessOrgInbox })
const tier = user?.tier || '' const tier = user?.tier || ''
return ( return (
@ -42,6 +44,11 @@ export default function DesktopSidebar({
> >
<item.Icon size={20} strokeWidth={2} /> <item.Icon size={20} strokeWidth={2} />
<span>{item.label}</span> <span>{item.label}</span>
{item.to === '/inbox' && inboxCount > 0 ? (
<span className="desktop-sidebar__badge" aria-label={`${inboxCount} offen`}>
{inboxCount > 99 ? '99+' : inboxCount}
</span>
) : null}
</NavLink> </NavLink>
))} ))}
</nav> </nav>

View File

@ -5,7 +5,8 @@ import {
Building2, Building2,
Settings, Settings,
Shield, Shield,
Target Target,
Inbox
} from 'lucide-react' } from 'lucide-react'
/** /**
@ -15,30 +16,27 @@ import {
* @typedef {{ to: string, label: string, shortLabel?: string, end?: boolean, Icon: import('react').ForwardRefExoticComponent }} AppNavItem * @typedef {{ to: string, label: string, shortLabel?: string, end?: boolean, Icon: import('react').ForwardRefExoticComponent }} AppNavItem
*/ */
/** @returns {Omit<AppNavItem, 'Icon'>[]} */ /** @param {{ showInbox?: boolean }} opts */
function baseItems() { function baseItems(opts = {}) {
return [ const showInbox = !!opts.showInbox
const items = [
{ to: '/', label: 'Übersicht', end: true }, { to: '/', label: 'Übersicht', end: true },
...(showInbox ? [{ to: '/inbox', label: 'Posteingang', shortLabel: 'Post' }] : []),
{ to: '/exercises', label: 'Übungen', shortLabel: 'Übungen' }, { to: '/exercises', label: 'Übungen', shortLabel: 'Übungen' },
{ to: '/planning', label: 'Planung' }, { to: '/planning', label: 'Planung' },
{ to: '/clubs', label: 'Vereine' }, { to: '/clubs', label: 'Vereine' },
{ to: '/trainer-contexts', label: 'Meine Bereiche', shortLabel: 'Bereiche' }, { to: '/trainer-contexts', label: 'Meine Bereiche', shortLabel: 'Bereiche' },
{ to: '/settings', label: 'Einstellungen', shortLabel: 'Einst.' } { to: '/settings', label: 'Einstellungen', shortLabel: 'Einst.' }
] ]
return items
} }
/** @param {boolean} isAdmin */ /** @param {boolean} isAdmin @param {{ showInbox?: boolean }} opts */
export function getMainNavItems(isAdmin) { export function getMainNavItems(isAdmin, opts = {}) {
const icons = [ const showInbox = !!opts.showInbox
LayoutDashboard, const icons = [LayoutDashboard, ...(showInbox ? [Inbox] : []), BookOpen, Calendar, Building2, Target, Settings]
BookOpen,
Calendar,
Building2,
Target,
Settings
]
const raw = baseItems().map((item, i) => ({ const raw = baseItems(opts).map((item, i) => ({
...item, ...item,
Icon: icons[i] Icon: icons[i]
})) }))

View File

@ -0,0 +1,87 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import api from '../utils/api'
const OrgInboxContext = createContext(null)
export function canAccessOrgInbox(user) {
if (!user?.id) return false
if (user.role === 'admin' || user.role === 'superadmin') return true
return (user.clubs || []).some((c) => (c.roles || []).includes('club_admin'))
}
/** Nach Annahme/Ablehnung: Posteingang-Badges & Widget aktualisieren */
export function notifyOrgInboxChanged() {
window.dispatchEvent(new Event('shinkan:inbox-changed'))
}
export function OrgInboxProvider({ user, children }) {
const [items, setItems] = useState([])
const canAccess = useMemo(() => canAccessOrgInbox(user), [user])
const refresh = useCallback(async () => {
if (!canAccess) {
setItems([])
return
}
try {
const data = await api.getInboxJoinRequests()
setItems(Array.isArray(data) ? data : [])
} catch {
setItems([])
}
}, [canAccess])
useEffect(() => {
if (!canAccess) {
setItems([])
return undefined
}
let cancelled = false
;(async () => {
try {
const data = await api.getInboxJoinRequests()
if (!cancelled) setItems(Array.isArray(data) ? data : [])
} catch {
if (!cancelled) setItems([])
}
})()
return () => {
cancelled = true
}
}, [canAccess, user?.id])
useEffect(() => {
const onChange = () => {
refresh()
}
window.addEventListener('shinkan:inbox-changed', onChange)
return () => window.removeEventListener('shinkan:inbox-changed', onChange)
}, [refresh])
const value = useMemo(
() => ({
inboxJoinRequests: items,
inboxCount: items.length,
refreshOrgInbox: refresh,
canAccessOrgInbox: canAccess,
}),
[items, refresh, canAccess]
)
return <OrgInboxContext.Provider value={value}>{children}</OrgInboxContext.Provider>
}
export function useOrgInbox() {
const ctx = useContext(OrgInboxContext)
if (!ctx) {
throw new Error('useOrgInbox must be used within OrgInboxProvider')
}
return ctx
}

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect, useMemo } from 'react' import React, { useState, useEffect, useMemo } from 'react'
import api from '../utils/api' import api from '../utils/api'
import { notifyOrgInboxChanged } from '../context/OrgInboxContext'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import PageSectionNav from '../components/PageSectionNav' import PageSectionNav from '../components/PageSectionNav'
@ -647,6 +648,7 @@ function ClubsPage() {
if (!confirm('Antrag ablehnen?')) return if (!confirm('Antrag ablehnen?')) return
try { try {
await api.rejectClubJoinRequest(membersAdminClubId, req.id) await api.rejectClubJoinRequest(membersAdminClubId, req.id)
notifyOrgInboxChanged()
await reloadMembersAdmin() await reloadMembersAdmin()
} catch (err) { } catch (err) {
alert(err.message || String(err)) alert(err.message || String(err))
@ -1255,6 +1257,7 @@ function ClubsPage() {
acceptJoinModal.id, acceptJoinModal.id,
acceptJoinModal.roles.length ? acceptJoinModal.roles : ['trainer'] acceptJoinModal.roles.length ? acceptJoinModal.roles : ['trainer']
) )
notifyOrgInboxChanged()
setAcceptJoinModal(null) setAcceptJoinModal(null)
await reloadMembersAdmin() await reloadMembersAdmin()
await loadData() await loadData()

View File

@ -5,6 +5,7 @@ import { useAuth } from '../context/AuthContext'
import api from '../utils/api' import api from '../utils/api'
import EmailVerificationBanner from '../components/EmailVerificationBanner' import EmailVerificationBanner from '../components/EmailVerificationBanner'
import DashboardTrainingVisibilityWidget from '../components/DashboardTrainingVisibilityWidget' import DashboardTrainingVisibilityWidget from '../components/DashboardTrainingVisibilityWidget'
import DashboardOrgInboxWidget from '../components/DashboardOrgInboxWidget'
function unitWhenLabel(u) { function unitWhenLabel(u) {
const d = u.planned_date ? String(u.planned_date).slice(0, 10) : '' const d = u.planned_date ? String(u.planned_date).slice(0, 10) : ''
@ -183,6 +184,7 @@ function Dashboard() {
{user?.id ? ( {user?.id ? (
<> <>
<DashboardOrgInboxWidget />
<section className="dashboard-section" aria-labelledby="dash-phase0-title"> <section className="dashboard-section" aria-labelledby="dash-phase0-title">
<div className="dashboard-section__header"> <div className="dashboard-section__header">
<div className="dashboard-section__headline"> <div className="dashboard-section__headline">

View File

@ -0,0 +1,222 @@
import React, { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { notifyOrgInboxChanged, useOrgInbox } from '../context/OrgInboxContext'
const CLUB_ROLE_OPTIONS = [
{ code: 'club_admin', label: 'Vereinsadmin' },
{ code: 'trainer', label: 'Trainer' },
{ code: 'division_lead', label: 'Spartenleitung' },
{ code: 'content_editor', label: 'Inhalte bearbeiten' },
]
function formatWhen(iso) {
if (!iso) return ''
const s = String(iso)
const d = s.includes('T') ? s.split('T')[0] : s.slice(0, 10)
const t = s.includes('T') ? s.split('T')[1] : ''
const time = t ? t.slice(0, 5) : ''
return time ? `${d} · ${time}` : d
}
export default function InboxPage() {
const { canAccessOrgInbox, refreshOrgInbox, inboxJoinRequests } = useOrgInbox()
const [loading, setLoading] = useState(true)
const [acceptModal, setAcceptModal] = useState(null)
const load = useCallback(async () => {
if (!canAccessOrgInbox) {
setLoading(false)
return
}
setLoading(true)
try {
await refreshOrgInbox()
} finally {
setLoading(false)
}
}, [canAccessOrgInbox, refreshOrgInbox])
useEffect(() => {
load()
}, [load])
if (!canAccessOrgInbox) {
return (
<div className="app-page">
<h1 className="page-title">Posteingang</h1>
<p className="muted">Kein Zugriff. Nur Plattform-Admins und Vereinsadmins sehen den Posteingang.</p>
<p>
<Link to="/">Zur Übersicht</Link>
</p>
</div>
)
}
return (
<div className="app-page inbox-page">
<div className="inbox-page__header">
<div>
<h1 className="page-title" style={{ marginBottom: '6px' }}>
Posteingang
</h1>
<p className="muted" style={{ marginTop: 0 }}>
Offene Beitrittsanträge zu Vereinen, für die du zuständig bist.
</p>
</div>
<button type="button" className="btn btn-secondary" onClick={() => load()} disabled={loading}>
Aktualisieren
</button>
</div>
{loading ? (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner" />
</div>
) : inboxJoinRequests.length === 0 ? (
<div className="card" style={{ padding: '1.25rem' }}>
<p style={{ margin: 0 }} className="muted">
Keine offenen Beitrittsanträge.
</p>
</div>
) : (
<div className="inbox-page__list">
{inboxJoinRequests.map((req) => (
<div key={`${req.club_id}-${req.id}`} className="card inbox-request-card">
<div className="inbox-request-card__main">
<div className="inbox-request-card__club">
{req.club_name || 'Verein'}
{req.club_abbreviation ? (
<span className="muted" style={{ marginLeft: '0.35rem' }}>
({req.club_abbreviation})
</span>
) : null}
</div>
<strong className="inbox-request-card__applicant">
{req.applicant_name || req.applicant_email || 'Bewerber/in'}
</strong>
<div className="muted inbox-request-card__meta">
{req.applicant_email} · Profil #{req.profile_id} · {formatWhen(req.created_at)}
</div>
{req.message ? <p className="inbox-request-card__message">{req.message}</p> : null}
</div>
<div className="inbox-request-card__actions">
<button
type="button"
className="btn btn-primary"
onClick={() =>
setAcceptModal({
id: req.id,
club_id: req.club_id,
label: req.applicant_name || req.applicant_email,
roles: ['trainer'],
})
}
>
Annehmen
</button>
<button
type="button"
className="btn btn-secondary"
onClick={async () => {
if (!confirm('Antrag ablehnen?')) return
try {
await api.rejectClubJoinRequest(req.club_id, req.id)
notifyOrgInboxChanged()
await load()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Ablehnen
</button>
</div>
</div>
))}
</div>
)}
{acceptModal && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1100,
padding: '1rem',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '480px',
width: '100%',
}}
>
<h2 style={{ marginTop: 0 }}>Antrag annehmen</h2>
<p style={{ color: 'var(--text2)' }}>{acceptModal.label}</p>
<div className="form-row">
<span className="form-label">Rollen bei Aufnahme</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
{CLUB_ROLE_OPTIONS.map((opt) => (
<label key={opt.code} style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<input
type="checkbox"
checked={acceptModal.roles.includes(opt.code)}
onChange={() => {
setAcceptModal((prev) => {
if (!prev) return prev
const set = new Set(prev.roles)
if (set.has(opt.code)) set.delete(opt.code)
else set.add(opt.code)
const roles = Array.from(set)
return { ...prev, roles: roles.length ? roles : ['trainer'] }
})
}}
/>
{opt.label}
</label>
))}
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button
type="button"
className="btn btn-primary"
style={{ flex: 1 }}
onClick={async () => {
try {
await api.acceptClubJoinRequest(
acceptModal.club_id,
acceptModal.id,
acceptModal.roles.length ? acceptModal.roles : ['trainer']
)
setAcceptModal(null)
notifyOrgInboxChanged()
await load()
} catch (err) {
alert(err.message || String(err))
}
}}
>
Aufnehmen
</button>
<button type="button" className="btn btn-secondary" onClick={() => setAcceptModal(null)}>
Abbrechen
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -264,6 +264,11 @@ export async function rejectClubJoinRequest(clubId, requestId) {
}) })
} }
/** Aggregierter Posteingang: offene Beitrittsanträge für Vereins-/Plattform-Admins. */
export async function getInboxJoinRequests() {
return request('/api/me/inbox/join-requests')
}
export async function listDivisions(clubId) { export async function listDivisions(clubId) {
const query = clubId ? `?club_id=${clubId}` : '' const query = clubId ? `?club_id=${clubId}` : ''
return request(`/api/divisions${query}`) return request(`/api/divisions${query}`)
@ -1326,6 +1331,7 @@ export const api = {
listClubJoinRequests, listClubJoinRequests,
acceptClubJoinRequest, acceptClubJoinRequest,
rejectClubJoinRequest, rejectClubJoinRequest,
getInboxJoinRequests,
listDivisions, listDivisions,
createDivision, createDivision,
updateDivision, updateDivision,