diff --git a/backend/main.py b/backend/main.py
index 27fffbb..c8c90b5 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -154,14 +154,14 @@ def read_root():
}
# Register routers
-from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
+from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
app.include_router(auth.router)
app.include_router(profiles.router)
app.include_router(exercises.router)
app.include_router(exercise_progression_graphs.router)
app.include_router(clubs.router)
-app.include_router(club_memberships.router)
+app.include_router(club_join_requests.router)
app.include_router(skills.router)
app.include_router(training_planning.router)
app.include_router(training_framework_programs.router)
diff --git a/backend/migrations/040_club_membership_requests.sql b/backend/migrations/040_club_membership_requests.sql
new file mode 100644
index 0000000..976981a
--- /dev/null
+++ b/backend/migrations/040_club_membership_requests.sql
@@ -0,0 +1,29 @@
+-- Migration 040: Antrag auf Vereinsbeitritt (pending → accept/reject durch Vereins-/Plattform-Admin)
+
+CREATE TABLE IF NOT EXISTS club_membership_requests (
+ id SERIAL PRIMARY KEY,
+ profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
+ club_id INT NOT NULL REFERENCES clubs(id) ON DELETE CASCADE,
+ status VARCHAR(20) NOT NULL DEFAULT 'pending'
+ CHECK (status IN ('pending', 'accepted', 'rejected', 'withdrawn')),
+ message TEXT,
+ decided_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
+ decided_at TIMESTAMP,
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW()
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_club_membership_requests_pending
+ ON club_membership_requests (profile_id, club_id)
+ WHERE status = 'pending';
+
+CREATE INDEX IF NOT EXISTS idx_club_membership_requests_club_status
+ ON club_membership_requests (club_id, status);
+
+CREATE INDEX IF NOT EXISTS idx_club_membership_requests_profile
+ ON club_membership_requests (profile_id);
+
+DROP TRIGGER IF EXISTS club_membership_requests_update ON club_membership_requests;
+CREATE TRIGGER club_membership_requests_update
+ BEFORE UPDATE ON club_membership_requests
+ FOR EACH ROW EXECUTE FUNCTION update_timestamp();
diff --git a/backend/models.py b/backend/models.py
index f8c9cb9..ecae0d7 100644
--- a/backend/models.py
+++ b/backend/models.py
@@ -19,6 +19,7 @@ class RegisterRequest(BaseModel):
email: EmailStr
password: str
name: Optional[str] = None
+ requested_club_id: Optional[int] = Field(default=None, ge=1)
class PasswordResetRequest(BaseModel):
email: str
diff --git a/backend/routers/auth.py b/backend/routers/auth.py
index b740576..bdfa1e3 100644
--- a/backend/routers/auth.py
+++ b/backend/routers/auth.py
@@ -302,7 +302,33 @@ async def register(req: RegisterRequest, request: Request):
email_verified, verification_token, verification_expires,
trial_ends_at, created_at
) VALUES (%s, %s, %s, 'email', %s, 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
+ RETURNING id
""", (name, email, pin_hash, role, verification_token, verification_expires, trial_ends))
+ new_profile_id = cur.fetchone()["id"]
+
+ req_club = req.requested_club_id
+ if req_club is not None:
+ cur.execute(
+ "SELECT id FROM clubs WHERE id = %s AND status = 'active'",
+ (int(req_club),),
+ )
+ if cur.fetchone():
+ cur.execute(
+ """
+ SELECT id FROM club_membership_requests
+ WHERE profile_id = %s AND club_id = %s AND status = 'pending'
+ LIMIT 1
+ """,
+ (new_profile_id, int(req_club)),
+ )
+ if not cur.fetchone():
+ cur.execute(
+ """
+ INSERT INTO club_membership_requests (profile_id, club_id, status, message)
+ VALUES (%s, %s, 'pending', NULL)
+ """,
+ (new_profile_id, int(req_club)),
+ )
verify_url = verification_link(verification_token)
diff --git a/backend/routers/club_join_requests.py b/backend/routers/club_join_requests.py
new file mode 100644
index 0000000..20dda2b
--- /dev/null
+++ b/backend/routers/club_join_requests.py
@@ -0,0 +1,281 @@
+"""
+Anträge auf Vereinsbeitritt: Nutzer stellt Antrag, Vereins-/Plattform-Admin nimmt an oder lehnt ab.
+"""
+from typing import Any, Dict, List, Optional
+
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel, Field
+
+from auth import require_auth
+from club_tenancy import can_manage_club_org
+from db import get_db, get_cursor, r2d
+
+router = APIRouter(prefix="/api", tags=["club_join_requests"])
+
+_ALLOWED_MEMBER_ROLES = frozenset({"club_admin", "trainer", "division_lead", "content_editor"})
+
+
+def _normalize_roles(raw: List[str]) -> List[str]:
+ out: List[str] = []
+ seen = set()
+ for r in raw:
+ if not isinstance(r, str):
+ raise HTTPException(status_code=400, detail="Rollen müssen Strings sein")
+ code = r.strip().lower()
+ if not code or code not in _ALLOWED_MEMBER_ROLES:
+ raise HTTPException(status_code=400, detail=f"Unbekannte Rolle: {code}")
+ if code not in seen:
+ seen.add(code)
+ out.append(code)
+ return out
+
+
+def _club_active(cur, club_id: int) -> bool:
+ cur.execute("SELECT 1 FROM clubs WHERE id = %s AND status = 'active'", (club_id,))
+ return cur.fetchone() is not None
+
+
+def _assert_manage_club(cur, session: dict, club_id: int) -> None:
+ pid = session["profile_id"]
+ role = session.get("role")
+ if not can_manage_club_org(cur, pid, club_id, role):
+ raise HTTPException(status_code=403, detail="Keine Berechtigung für Mitglieder-Verwaltung in diesem Verein")
+
+
+def _is_active_member(cur, profile_id: int, club_id: int) -> bool:
+ cur.execute(
+ """
+ SELECT 1 FROM club_members
+ WHERE profile_id = %s AND club_id = %s AND status = 'active'
+ LIMIT 1
+ """,
+ (profile_id, club_id),
+ )
+ return cur.fetchone() is not None
+
+
+def _upsert_active_member_with_roles(cur, club_id: int, profile_id: int, roles: List[str]) -> None:
+ roles_n = _normalize_roles(roles)
+ if not roles_n:
+ raise HTTPException(status_code=400, detail="Mindestens eine Rolle angeben")
+ cur.execute(
+ """
+ INSERT INTO club_members (profile_id, club_id, status)
+ VALUES (%s, %s, 'active')
+ ON CONFLICT (profile_id, club_id)
+ DO UPDATE SET status = 'active', updated_at = NOW()
+ RETURNING id
+ """,
+ (profile_id, club_id),
+ )
+ cm_id = cur.fetchone()["id"]
+ cur.execute("DELETE FROM club_member_roles WHERE club_member_id = %s", (cm_id,))
+ for rc in roles_n:
+ cur.execute(
+ """
+ INSERT INTO club_member_roles (club_member_id, role_code)
+ VALUES (%s, %s)
+ ON CONFLICT (club_member_id, role_code) DO NOTHING
+ """,
+ (cm_id, rc),
+ )
+
+
+class JoinRequestCreate(BaseModel):
+ club_id: int = Field(..., ge=1)
+ message: Optional[str] = Field(None, max_length=2000)
+
+
+class JoinRequestAccept(BaseModel):
+ roles: List[str] = Field(default_factory=lambda: ["trainer"])
+
+
+def _response_one(cur, req_id: int, viewer_profile_id: int) -> Dict[str, Any]:
+ cur.execute(
+ """
+ SELECT r.*, c.name AS club_name, c.abbreviation AS club_abbreviation
+ FROM club_membership_requests r
+ INNER JOIN clubs c ON c.id = r.club_id
+ WHERE r.id = %s AND r.profile_id = %s
+ """,
+ (req_id, viewer_profile_id),
+ )
+ row = cur.fetchone()
+ if not row:
+ raise HTTPException(status_code=404, detail="Antrag nicht gefunden")
+ return r2d(row)
+
+
+@router.get("/me/club-join-requests")
+def get_my_join_requests(session: dict = Depends(require_auth)):
+ pid = session["profile_id"]
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ """
+ SELECT r.*, c.name AS club_name, c.abbreviation AS club_abbreviation
+ FROM club_membership_requests r
+ INNER JOIN clubs c ON c.id = r.club_id
+ WHERE r.profile_id = %s
+ ORDER BY r.created_at DESC
+ LIMIT 100
+ """,
+ (pid,),
+ )
+ return [r2d(r) for r in cur.fetchall()]
+
+
+@router.post("/me/club-join-requests", status_code=201)
+def create_my_join_request(body: JoinRequestCreate, session: dict = Depends(require_auth)):
+ """Antrag stellen (nicht möglich wenn bereits aktives Mitglied)."""
+ pid = session["profile_id"]
+ msg = (body.message or "").strip() or None
+ cid = body.club_id
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ if not _club_active(cur, cid):
+ raise HTTPException(status_code=404, detail="Verein nicht gefunden oder nicht aktiv")
+
+ if _is_active_member(cur, pid, cid):
+ raise HTTPException(status_code=400, detail="Du bist bereits Mitglied in diesem Verein")
+
+ cur.execute(
+ """
+ SELECT id FROM club_membership_requests
+ WHERE profile_id = %s AND club_id = %s AND status = 'pending'
+ LIMIT 1
+ """,
+ (pid, cid),
+ )
+ if cur.fetchone():
+ raise HTTPException(status_code=409, detail="Für diesen Verein liegt bereits ein offener Antrag vor")
+
+ cur.execute(
+ """
+ INSERT INTO club_membership_requests (profile_id, club_id, status, message)
+ VALUES (%s, %s, 'pending', %s)
+ RETURNING id
+ """,
+ (pid, cid, msg),
+ )
+ rid = cur.fetchone()["id"]
+ conn.commit()
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ return _response_one(cur, rid, pid)
+
+
+@router.delete("/me/club-join-requests/{request_id}")
+def withdraw_my_join_request(request_id: int, session: dict = Depends(require_auth)):
+ pid = session["profile_id"]
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ """
+ UPDATE club_membership_requests
+ SET status = 'withdrawn', updated_at = NOW()
+ WHERE id = %s AND profile_id = %s AND status = 'pending'
+ RETURNING id
+ """,
+ (request_id, pid),
+ )
+ if not cur.fetchone():
+ raise HTTPException(status_code=404, detail="Offener Antrag nicht gefunden")
+ conn.commit()
+ return {"ok": True}
+
+
+@router.get("/clubs/{club_id}/join-requests")
+def list_club_join_requests(club_id: int, session: dict = Depends(require_auth)):
+ """Offene Anträge für einen Verein (Vereins-/Plattform-Admin)."""
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ _assert_manage_club(cur, session, club_id)
+ cur.execute(
+ """
+ SELECT r.*, p.email AS applicant_email, p.name AS applicant_name
+ FROM club_membership_requests r
+ INNER JOIN profiles p ON p.id = r.profile_id
+ WHERE r.club_id = %s AND r.status = 'pending'
+ ORDER BY r.created_at ASC
+ """,
+ (club_id,),
+ )
+ return [r2d(r) for r in cur.fetchall()]
+
+
+@router.post("/clubs/{club_id}/join-requests/{request_id}/accept")
+def accept_club_join_request(
+ club_id: int,
+ request_id: int,
+ body: JoinRequestAccept,
+ session: dict = Depends(require_auth),
+):
+ admin_pid = session["profile_id"]
+ roles = _normalize_roles(body.roles)
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ _assert_manage_club(cur, session, club_id)
+
+ cur.execute(
+ """
+ SELECT id, profile_id, status FROM club_membership_requests
+ WHERE id = %s AND club_id = %s
+ """,
+ (request_id, club_id),
+ )
+ row = cur.fetchone()
+ if not row:
+ raise HTTPException(status_code=404, detail="Antrag nicht gefunden")
+ if row["status"] != "pending":
+ raise HTTPException(status_code=400, detail="Antrag ist nicht mehr offen")
+
+ applicant_id = row["profile_id"]
+
+ cur.execute(
+ """
+ UPDATE club_membership_requests
+ SET status = 'accepted',
+ decided_by_profile_id = %s,
+ decided_at = NOW(),
+ updated_at = NOW()
+ WHERE id = %s AND club_id = %s AND status = 'pending'
+ RETURNING id
+ """,
+ (admin_pid, request_id, club_id),
+ )
+ if not cur.fetchone():
+ raise HTTPException(status_code=409, detail="Antrag konnte nicht angenommen werden")
+
+ _upsert_active_member_with_roles(cur, club_id, applicant_id, roles)
+ conn.commit()
+
+ return {"ok": True, "profile_id": applicant_id, "club_id": club_id}
+
+
+@router.post("/clubs/{club_id}/join-requests/{request_id}/reject")
+def reject_club_join_request(club_id: int, request_id: int, session: dict = Depends(require_auth)):
+ admin_pid = session["profile_id"]
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ _assert_manage_club(cur, session, club_id)
+
+ cur.execute(
+ """
+ UPDATE club_membership_requests
+ SET status = 'rejected',
+ decided_by_profile_id = %s,
+ decided_at = NOW(),
+ updated_at = NOW()
+ WHERE id = %s AND club_id = %s AND status = 'pending'
+ RETURNING id
+ """,
+ (admin_pid, request_id, club_id),
+ )
+ if not cur.fetchone():
+ raise HTTPException(status_code=404, detail="Offener Antrag nicht gefunden")
+ conn.commit()
+ return {"ok": True}
diff --git a/backend/routers/clubs.py b/backend/routers/clubs.py
index bcfb2ed..841a33d 100644
--- a/backend/routers/clubs.py
+++ b/backend/routers/clubs.py
@@ -57,6 +57,48 @@ def list_clubs(
return [r2d(r) for r in rows]
+# ── Öffentliches Vereinsverzeichnis (Registrierung / Antrag ohne Mitgliedschaft) ──
+@router.get("/clubs/public-directory")
+def public_club_directory():
+ """Aktive Vereine zur Auswahl bei Registrierung oder Beitrittsantrag (nur id, name, Kürzel)."""
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ """
+ SELECT id, name, abbreviation
+ FROM clubs
+ WHERE status = 'active'
+ ORDER BY name
+ """
+ )
+ return [r2d(r) for r in cur.fetchall()]
+
+
+# ── Aktive Mitglieder für Trainer-/Co-Trainer-Auswahl (jeder Vereinsmitglied) ──
+@router.get("/clubs/{club_id}/members/directory")
+def club_members_directory(club_id: int, session=Depends(require_auth)):
+ profile_id = session["profile_id"]
+ role = session.get("role")
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ if not is_platform_admin(role):
+ assert_club_member(cur, profile_id, club_id)
+ cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,))
+ if not cur.fetchone():
+ raise HTTPException(404, "Verein nicht gefunden")
+ cur.execute(
+ """
+ SELECT p.id, p.name, p.email
+ FROM club_members cm
+ INNER JOIN profiles p ON p.id = cm.profile_id
+ WHERE cm.club_id = %s AND cm.status = 'active'
+ ORDER BY COALESCE(p.name, ''), p.email
+ """,
+ (club_id,),
+ )
+ return [r2d(r) for r in cur.fetchall()]
+
+
# ── Get Club ──────────────────────────────────────────────────────────
@router.get("/clubs/{club_id}")
def get_club(club_id: int, session=Depends(require_auth)):
diff --git a/backend/routers/profiles.py b/backend/routers/profiles.py
index ca78d09..035d42c 100644
--- a/backend/routers/profiles.py
+++ b/backend/routers/profiles.py
@@ -11,7 +11,7 @@ from fastapi import APIRouter, HTTPException, Header, Depends
from db import get_db, get_cursor, r2d
from auth import require_auth
-from club_tenancy import assert_club_member, memberships_with_roles
+from club_tenancy import assert_club_member, memberships_with_roles, is_platform_admin
from models import ProfileCreate, ProfileUpdate
router = APIRouter(prefix="/api", tags=["profiles"])
@@ -50,12 +50,20 @@ def get_current_profile(session=Depends(require_auth)):
# ── Admin Profile Management ──────────────────────────────────────────────────
@router.get("/profiles")
def list_profiles(session=Depends(require_auth)):
- """List all profiles (admin)."""
+ """Liste aller Profile (nur Plattform-Admin)."""
+ role = (session.get("role") or "").lower()
+ if not is_platform_admin(role):
+ raise HTTPException(status_code=403, detail="Nur Plattform-Administratoren dürfen alle Profile einsehen")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles ORDER BY created")
rows = cur.fetchall()
- return [r2d(r) for r in rows]
+ out = []
+ for r in rows:
+ d = r2d(r)
+ d.pop("pin_hash", None)
+ out.append(d)
+ return out
@router.post("/profiles")
diff --git a/backend/version.py b/backend/version.py
index 4a8a9d1..c075971 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,14 +1,15 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.17"
+APP_VERSION = "0.8.18"
BUILD_DATE = "2026-05-05"
-DB_SCHEMA_VERSION = "20260505039"
+DB_SCHEMA_VERSION = "20260505040"
MODULE_VERSIONS = {
- "auth": "1.0.0",
- "profiles": "1.1.0", # /profiles/me: clubs[], pin_hash ausgeblendet, active_club_id
- "clubs": "0.3.0",
+ "auth": "1.1.0", # Registrierung: optional requested_club_id → Beitrittsantrag
+ "profiles": "1.2.0", # GET /profiles nur Plattform-Admin; pin_hash aus Liste entfernt
+ "clubs": "0.4.0", # public-directory, members/directory; Vereins-GUI verwendet Endpoints
"club_memberships": "1.0.0",
+ "club_join_requests": "1.0.0",
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
@@ -24,6 +25,15 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
+ {
+ "version": "0.8.18",
+ "date": "2026-05-05",
+ "changes": [
+ "DB 040 club_membership_requests; API Antrag/Abrufen/annehmen/ablehnen; öffentliches Vereinsverzeichnis; Mitglieder-Directory für Trainerwahl",
+ "GUI: Vereinsverwaltung Tab Mitglieder & Anträge; Registrierung/Einstellungen Vereinsantrag; Gruppenformular Haupt- und Co-Trainer",
+ "GET /profiles nur noch für Plattform-Admins",
+ ],
+ },
{
"version": "0.8.17",
"date": "2026-05-05",
diff --git a/frontend/src/pages/AccountSettingsPage.jsx b/frontend/src/pages/AccountSettingsPage.jsx
index cba52d3..ea6634c 100644
--- a/frontend/src/pages/AccountSettingsPage.jsx
+++ b/frontend/src/pages/AccountSettingsPage.jsx
@@ -10,6 +10,12 @@ function AccountSettingsPage() {
const [name, setName] = useState('')
const [savingProfile, setSavingProfile] = useState(false)
+ const [publicClubsDir, setPublicClubsDir] = useState([])
+ const [myJoinRequests, setMyJoinRequests] = useState([])
+ const [joinClubId, setJoinClubId] = useState('')
+ const [joinMessage, setJoinMessage] = useState('')
+ const [joinBusy, setJoinBusy] = useState(false)
+
const [newPw1, setNewPw1] = useState('')
const [newPw2, setNewPw2] = useState('')
const [savingPw, setSavingPw] = useState(false)
@@ -22,6 +28,32 @@ function AccountSettingsPage() {
setName(typeof user?.name === 'string' ? user.name : '')
}, [user])
+ const refreshJoinRequests = () => {
+ api.getMyClubJoinRequests().then(setMyJoinRequests).catch(() => {})
+ }
+
+ useEffect(() => {
+ if (!user?.id) return
+ api.listPublicClubsDirectory().then(setPublicClubsDir).catch(() => {})
+ refreshJoinRequests()
+ }, [user?.id])
+
+ const memberClubIds = new Set((user?.clubs || []).map((c) => c.id))
+ const pendingClubIds = new Set(
+ myJoinRequests.filter((r) => r.status === 'pending').map((r) => r.club_id)
+ )
+ const joinClubChoices = publicClubsDir.filter(
+ (c) => !memberClubIds.has(c.id) && !pendingClubIds.has(c.id)
+ )
+
+ const joinStatusLabel = (s) =>
+ ({
+ pending: 'ausstehend',
+ accepted: 'angenommen',
+ rejected: 'abgelehnt',
+ withdrawn: 'zurückgezogen',
+ })[s] || s
+
/** API: boolean true / Legacy: fehlt oder false → als „nicht verifiziert“ behandeln */
const emailExplicitlyVerified =
user?.email_verified === true ||
@@ -214,9 +246,133 @@ function AccountSettingsPage() {
{user?.tier || 'free'}
+
+ Vereine
+
+ {user?.clubs?.length ? (
+ <>
+ {user.clubs.map((c) => (
+
+ Beantrage die Mitgliedschaft in einem Verein. Vereinsadministratoren können den Antrag unter + „Vereinsverwaltung → Mitglieder“ annehmen oder ablehnen. +
+ + {myJoinRequests.length > 0 && ( +diff --git a/frontend/src/pages/ClubsPage.jsx b/frontend/src/pages/ClubsPage.jsx index 70ae42c..92379ec 100644 --- a/frontend/src/pages/ClubsPage.jsx +++ b/frontend/src/pages/ClubsPage.jsx @@ -2,6 +2,13 @@ import React, { useState, useEffect } from 'react' import api from '../utils/api' import { useAuth } from '../context/AuthContext' +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 ClubsPage() { const { user } = useAuth() const [activeTab, setActiveTab] = useState('clubs') @@ -13,6 +20,19 @@ function ClubsPage() { const [editing, setEditing] = useState(null) const [modalType, setModalType] = useState('club') + const [membersAdminClubId, setMembersAdminClubId] = useState(null) + const [clubMembersAdmin, setClubMembersAdmin] = useState([]) + const [joinRequestsAdmin, setJoinRequestsAdmin] = useState([]) + const [membersAdminLoading, setMembersAdminLoading] = useState(false) + const [groupMemberDirectory, setGroupMemberDirectory] = useState([]) + + const [showAddMemberModal, setShowAddMemberModal] = useState(false) + const [profilesAdminList, setProfilesAdminList] = useState([]) + const [addMemberForm, setAddMemberForm] = useState({ profile_id: '', roles: ['trainer'] }) + + const [editMemberModal, setEditMemberModal] = useState(null) + const [acceptJoinModal, setAcceptJoinModal] = useState(null) + // Form state const [formData, setFormData] = useState({}) @@ -58,6 +78,70 @@ function ClubsPage() { } } + useEffect(() => { + if (!clubs.length) { + setMembersAdminClubId(null) + return + } + const mcl = clubs.filter((c) => isPlatformAdmin || clubAdminClubIds.has(c.id)) + if (!mcl.length) { + setMembersAdminClubId(null) + return + } + setMembersAdminClubId((prev) => + prev != null && mcl.some((x) => x.id === prev) ? prev : mcl[0].id + ) + }, [clubs, isPlatformAdmin, user?.clubs]) + + useEffect(() => { + if (activeTab !== 'members' || !membersAdminClubId || !canManageClub(membersAdminClubId)) return + let cancelled = false + setMembersAdminLoading(true) + Promise.all([ + api.listClubMembers(membersAdminClubId, { includeInactive: true }), + api.listClubJoinRequests(membersAdminClubId), + ]) + .then(([m, j]) => { + if (!cancelled) { + setClubMembersAdmin(m) + setJoinRequestsAdmin(j) + } + }) + .catch((err) => { + if (!cancelled) alert('Mitglieder/Anträge: ' + err.message) + }) + .finally(() => { + if (!cancelled) setMembersAdminLoading(false) + }) + return () => { + cancelled = true + } + }, [activeTab, membersAdminClubId]) + + useEffect(() => { + if (!showModal || modalType !== 'group' || !formData.club_id) { + setGroupMemberDirectory([]) + return + } + let cancelled = false + api + .clubMembersDirectory(formData.club_id) + .then((rows) => { + if (!cancelled) setGroupMemberDirectory(rows) + }) + .catch(() => { + if (!cancelled) setGroupMemberDirectory([]) + }) + return () => { + cancelled = true + } + }, [showModal, modalType, formData.club_id]) + + useEffect(() => { + if (!showAddMemberModal || !isPlatformAdmin) return + api.listProfiles().then(setProfilesAdminList).catch(() => setProfilesAdminList([])) + }, [showAddMemberModal, isPlatformAdmin]) + const handleCreate = (type) => { setEditing(null) setModalType(type) @@ -93,10 +177,34 @@ function ClubsPage() { setShowModal(true) } + const manageableClubs = clubs.filter((c) => canManageClub(c.id)) + + const reloadMembersAdmin = async () => { + if (!membersAdminClubId || !canManageClub(membersAdminClubId)) return + try { + const [m, j] = await Promise.all([ + api.listClubMembers(membersAdminClubId, { includeInactive: true }), + api.listClubJoinRequests(membersAdminClubId), + ]) + setClubMembersAdmin(m) + setJoinRequestsAdmin(j) + } catch (err) { + console.error(err) + } + } + const handleEdit = (item, type) => { setEditing(item) setModalType(type) - setFormData({ ...item }) + if (type === 'group') { + const co = item.co_trainer_ids + setFormData({ + ...item, + co_trainer_ids: Array.isArray(co) ? co : [], + }) + } else { + setFormData({ ...item }) + } setShowModal(true) } @@ -145,10 +253,24 @@ function ClubsPage() { await api.createDivision(formData) } } else if (modalType === 'group') { + const trainerRaw = formData.trainer_id + const trainer_id = + trainerRaw === '' || trainerRaw === undefined || trainerRaw === null + ? null + : Number(trainerRaw) + const coRaw = formData.co_trainer_ids + const co_trainer_ids = Array.isArray(coRaw) + ? coRaw.map((x) => Number(x)).filter((n) => Number.isFinite(n)) + : [] + const payload = { + ...formData, + trainer_id: Number.isFinite(trainer_id) ? trainer_id : null, + co_trainer_ids, + } if (editing) { - await api.updateTrainingGroup(editing.id, formData) + await api.updateTrainingGroup(editing.id, payload) } else { - await api.createTrainingGroup(formData) + await api.createTrainingGroup(payload) } } @@ -187,7 +309,10 @@ function ClubsPage() { marginBottom: '1.5rem', borderBottom: '2px solid var(--border)' }}> - {['clubs', 'divisions', 'groups'].map(tab => ( + {(canManageOrgSomewhere + ? ['clubs', 'divisions', 'groups', 'members'] + : ['clubs', 'divisions', 'groups'] + ).map(tab => ( ))}
+ Keine Vereine, für die du Mitglieder verwalten darfst. +
+Laden…
+ ) : ( + <> + {joinRequestsAdmin.length > 0 && ( +Noch keine Mitglieder erfasst.
+ ) : ( ++ Liste = aktive Vereinsmitglieder. Nach Zuweisungen ggf. Seite neu laden. +
++ {isPlatformAdmin + ? 'Nutzer aus der Liste wählen oder Profil-ID eingeben.' + : 'Profil-ID des Nutzers (z. B. aus der Nutzerverwaltung). Offene Beitrittsanträge kannst du oben direkt annehmen.'} +
+ {isPlatformAdmin && profilesAdminList.length > 0 ? ( +{acceptJoinModal.label}
++ {editMemberModal.name || editMemberModal.email} (#{editMemberModal.profile_id}) +
+