From 58a38702b933ff6b7763567ede7633851457613c Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 9 May 2026 09:13:38 +0200 Subject: [PATCH] feat(org-inbox): implement join request inbox for platform and club admins - 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. --- backend/routers/club_join_requests.py | 68 +++++- frontend/src/App.jsx | 15 +- frontend/src/app.css | 154 ++++++++++++ .../components/DashboardOrgInboxWidget.jsx | 57 +++++ frontend/src/components/DesktopSidebar.jsx | 9 +- frontend/src/config/appNav.js | 28 +-- frontend/src/context/OrgInboxContext.jsx | 87 +++++++ frontend/src/pages/ClubsPage.jsx | 3 + frontend/src/pages/Dashboard.jsx | 2 + frontend/src/pages/InboxPage.jsx | 222 ++++++++++++++++++ frontend/src/utils/api.js | 6 + 11 files changed, 631 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/DashboardOrgInboxWidget.jsx create mode 100644 frontend/src/context/OrgInboxContext.jsx create mode 100644 frontend/src/pages/InboxPage.jsx diff --git a/backend/routers/club_join_requests.py b/backend/routers/club_join_requests.py index dae7767..415fd29 100644 --- a/backend/routers/club_join_requests.py +++ b/backend/routers/club_join_requests.py @@ -6,7 +6,7 @@ from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException 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 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) +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") def get_my_join_requests(tenant: TenantContext = Depends(get_tenant_context)): pid = tenant.profile_id diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 1c68048..e416967 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,6 +9,7 @@ import { Outlet, } from 'react-router-dom' import { AuthProvider, useAuth } from './context/AuthContext' +import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext' import DesktopSidebar from './components/DesktopSidebar' import { getMainNavItems } from './config/appNav' import LoginPage from './pages/LoginPage' @@ -20,6 +21,7 @@ import ExercisesListPage from './pages/ExercisesListPage' import ExerciseDetailPage from './pages/ExerciseDetailPage' import ExerciseFormPage from './pages/ExerciseFormPage' import ClubsPage from './pages/ClubsPage' +import InboxPage from './pages/InboxPage' import SkillsPage from './pages/SkillsPage' import TrainingPlanningPage from './pages/TrainingPlanningPage' import TrainingFrameworkProgramsListPage from './pages/TrainingFrameworkProgramsListPage' @@ -38,7 +40,8 @@ import './app.css' // Bottom Navigation (Mobile) function Nav({ isAdmin }) { - const items = getMainNavItems(isAdmin) + const { canAccessOrgInbox, inboxCount } = useOrgInbox() + const items = getMainNavItems(isAdmin, { showInbox: canAccessOrgInbox }) const loc = useLocation() const navItemActive = (pathname, item, routerIsActive) => { @@ -58,6 +61,11 @@ function Nav({ isAdmin }) { } > + {item.to === '/inbox' && inboxCount > 0 ? ( + + {inboxCount > 99 ? '99+' : inboxCount} + + ) : null} {item.shortLabel || item.label} ))} @@ -98,7 +106,7 @@ function ProtectedLayout() { const isAdmin = user?.role === 'admin' || user?.role === 'superadmin' return ( - <> +
@@ -114,7 +122,7 @@ function ProtectedLayout() {
- +
) } @@ -167,6 +175,7 @@ function AppRoutes() { } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/app.css b/frontend/src/app.css index bccdf36..7faf38b 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -198,6 +198,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we min-width: 68px; max-width: 108px; min-height: 48px; + position: relative; display: flex; flex-direction: column; align-items: center; @@ -6376,3 +6377,156 @@ a.analysis-split__nav-item { .media-library__preview-fallback .btn { 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; +} diff --git a/frontend/src/components/DashboardOrgInboxWidget.jsx b/frontend/src/components/DashboardOrgInboxWidget.jsx new file mode 100644 index 0000000..eafb532 --- /dev/null +++ b/frontend/src/components/DashboardOrgInboxWidget.jsx @@ -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 ( +
+
+
+

+ + Posteingang +

+ {inboxCount > 0 ? ( + + {inboxCount} + + ) : null} +
+

+ {inboxCount === 0 + ? 'Keine offenen Beitrittsanträge.' + : `${inboxCount} offene Beitrittsantrag${inboxCount === 1 ? '' : 'e'}.`} +

+ {preview.length > 0 ? ( +
    + {preview.map((req) => ( +
  • + {req.club_name || 'Verein'} + + {req.applicant_name || req.applicant_email || 'Bewerber/in'} + +
  • + ))} +
+ ) : null} +
+ + Zum Posteingang + +
+
+
+ ) +} diff --git a/frontend/src/components/DesktopSidebar.jsx b/frontend/src/components/DesktopSidebar.jsx index a918e17..781b25f 100644 --- a/frontend/src/components/DesktopSidebar.jsx +++ b/frontend/src/components/DesktopSidebar.jsx @@ -1,6 +1,7 @@ import { NavLink, useLocation } from 'react-router-dom' import { LogOut } from 'lucide-react' import { getMainNavItems } from '../config/appNav' +import { useOrgInbox } from '../context/OrgInboxContext' import ActiveClubSwitcher from './ActiveClubSwitcher' function sidebarLinkActive(pathname, item, routerIsActive) { @@ -17,7 +18,8 @@ export default function DesktopSidebar({ onLogout }) { const loc = useLocation() - const items = getMainNavItems(isAdmin) + const { canAccessOrgInbox, inboxCount } = useOrgInbox() + const items = getMainNavItems(isAdmin, { showInbox: canAccessOrgInbox }) const tier = user?.tier || '' return ( @@ -42,6 +44,11 @@ export default function DesktopSidebar({ > {item.label} + {item.to === '/inbox' && inboxCount > 0 ? ( + + {inboxCount > 99 ? '99+' : inboxCount} + + ) : null} ))} diff --git a/frontend/src/config/appNav.js b/frontend/src/config/appNav.js index ea87dd6..667faeb 100644 --- a/frontend/src/config/appNav.js +++ b/frontend/src/config/appNav.js @@ -5,7 +5,8 @@ import { Building2, Settings, Shield, - Target + Target, + Inbox } from 'lucide-react' /** @@ -15,30 +16,27 @@ import { * @typedef {{ to: string, label: string, shortLabel?: string, end?: boolean, Icon: import('react').ForwardRefExoticComponent }} AppNavItem */ -/** @returns {Omit[]} */ -function baseItems() { - return [ +/** @param {{ showInbox?: boolean }} opts */ +function baseItems(opts = {}) { + const showInbox = !!opts.showInbox + const items = [ { to: '/', label: 'Übersicht', end: true }, + ...(showInbox ? [{ to: '/inbox', label: 'Posteingang', shortLabel: 'Post' }] : []), { to: '/exercises', label: 'Übungen', shortLabel: 'Übungen' }, { to: '/planning', label: 'Planung' }, { to: '/clubs', label: 'Vereine' }, { to: '/trainer-contexts', label: 'Meine Bereiche', shortLabel: 'Bereiche' }, { to: '/settings', label: 'Einstellungen', shortLabel: 'Einst.' } ] + return items } -/** @param {boolean} isAdmin */ -export function getMainNavItems(isAdmin) { - const icons = [ - LayoutDashboard, - BookOpen, - Calendar, - Building2, - Target, - Settings - ] +/** @param {boolean} isAdmin @param {{ showInbox?: boolean }} opts */ +export function getMainNavItems(isAdmin, opts = {}) { + const showInbox = !!opts.showInbox + const icons = [LayoutDashboard, ...(showInbox ? [Inbox] : []), BookOpen, Calendar, Building2, Target, Settings] - const raw = baseItems().map((item, i) => ({ + const raw = baseItems(opts).map((item, i) => ({ ...item, Icon: icons[i] })) diff --git a/frontend/src/context/OrgInboxContext.jsx b/frontend/src/context/OrgInboxContext.jsx new file mode 100644 index 0000000..625a66a --- /dev/null +++ b/frontend/src/context/OrgInboxContext.jsx @@ -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 {children} +} + +export function useOrgInbox() { + const ctx = useContext(OrgInboxContext) + if (!ctx) { + throw new Error('useOrgInbox must be used within OrgInboxProvider') + } + return ctx +} diff --git a/frontend/src/pages/ClubsPage.jsx b/frontend/src/pages/ClubsPage.jsx index 64347f2..dfa7e3c 100644 --- a/frontend/src/pages/ClubsPage.jsx +++ b/frontend/src/pages/ClubsPage.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useMemo } from 'react' import api from '../utils/api' +import { notifyOrgInboxChanged } from '../context/OrgInboxContext' import { useAuth } from '../context/AuthContext' import PageSectionNav from '../components/PageSectionNav' @@ -647,6 +648,7 @@ function ClubsPage() { if (!confirm('Antrag ablehnen?')) return try { await api.rejectClubJoinRequest(membersAdminClubId, req.id) + notifyOrgInboxChanged() await reloadMembersAdmin() } catch (err) { alert(err.message || String(err)) @@ -1255,6 +1257,7 @@ function ClubsPage() { acceptJoinModal.id, acceptJoinModal.roles.length ? acceptJoinModal.roles : ['trainer'] ) + notifyOrgInboxChanged() setAcceptJoinModal(null) await reloadMembersAdmin() await loadData() diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 6dc8ee9..e744684 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -5,6 +5,7 @@ import { useAuth } from '../context/AuthContext' import api from '../utils/api' import EmailVerificationBanner from '../components/EmailVerificationBanner' import DashboardTrainingVisibilityWidget from '../components/DashboardTrainingVisibilityWidget' +import DashboardOrgInboxWidget from '../components/DashboardOrgInboxWidget' function unitWhenLabel(u) { const d = u.planned_date ? String(u.planned_date).slice(0, 10) : '' @@ -183,6 +184,7 @@ function Dashboard() { {user?.id ? ( <> +
diff --git a/frontend/src/pages/InboxPage.jsx b/frontend/src/pages/InboxPage.jsx new file mode 100644 index 0000000..2aead00 --- /dev/null +++ b/frontend/src/pages/InboxPage.jsx @@ -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 ( +
+

Posteingang

+

Kein Zugriff. Nur Plattform-Admins und Vereinsadmins sehen den Posteingang.

+

+ Zur Übersicht +

+
+ ) + } + + return ( +
+
+
+

+ Posteingang +

+

+ Offene Beitrittsanträge zu Vereinen, für die du zuständig bist. +

+
+ +
+ + {loading ? ( +
+
+
+ ) : inboxJoinRequests.length === 0 ? ( +
+

+ Keine offenen Beitrittsanträge. +

+
+ ) : ( +
+ {inboxJoinRequests.map((req) => ( +
+
+
+ {req.club_name || 'Verein'} + {req.club_abbreviation ? ( + + ({req.club_abbreviation}) + + ) : null} +
+ + {req.applicant_name || req.applicant_email || 'Bewerber/in'} + +
+ {req.applicant_email} · Profil #{req.profile_id} · {formatWhen(req.created_at)} +
+ {req.message ?

{req.message}

: null} +
+
+ + +
+
+ ))} +
+ )} + + {acceptModal && ( +
+
+

Antrag annehmen

+

{acceptModal.label}

+
+ Rollen bei Aufnahme +
+ {CLUB_ROLE_OPTIONS.map((opt) => ( + + ))} +
+
+
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 8bf5c55..fe5e7b3 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -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) { const query = clubId ? `?club_id=${clubId}` : '' return request(`/api/divisions${query}`) @@ -1326,6 +1331,7 @@ export const api = { listClubJoinRequests, acceptClubJoinRequest, rejectClubJoinRequest, + getInboxJoinRequests, listDivisions, createDivision, updateDivision,