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
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:
parent
59fb8a5527
commit
58a38702b9
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
57
frontend/src/components/DashboardOrgInboxWidget.jsx
Normal file
57
frontend/src/components/DashboardOrgInboxWidget.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
87
frontend/src/context/OrgInboxContext.jsx
Normal file
87
frontend/src/context/OrgInboxContext.jsx
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
222
frontend/src/pages/InboxPage.jsx
Normal file
222
frontend/src/pages/InboxPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user