feat: enhance club management features and member requests
- Updated the backend to include a new `requested_club_id` field in the registration request model. - Replaced the club memberships router with a new club join requests router for better management of membership applications. - Added API endpoints for listing public clubs and managing club join requests, improving user experience during registration and membership processes. - Enhanced the ClubsPage in the frontend to support member management and join requests, including new modals for adding members and handling requests. - Updated API utility functions to accommodate new endpoints for club join requests and public club listings.
This commit is contained in:
parent
7d476268b8
commit
0f08e8df58
|
|
@ -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)
|
||||
|
|
|
|||
29
backend/migrations/040_club_membership_requests.sql
Normal file
29
backend/migrations/040_club_membership_requests.sql
Normal file
|
|
@ -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();
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
281
backend/routers/club_join_requests.py
Normal file
281
backend/routers/club_join_requests.py
Normal file
|
|
@ -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}
|
||||
|
|
@ -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)):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,7 +246,131 @@ function AccountSettingsPage() {
|
|||
<span style={{ textTransform: 'uppercase', letterSpacing: '0.03em', fontWeight: 600 }}>
|
||||
{user?.tier || 'free'}
|
||||
</span>
|
||||
|
||||
<strong style={{ color: 'var(--text2)' }}>Vereine</strong>
|
||||
<span style={{ lineHeight: 1.45 }}>
|
||||
{user?.clubs?.length ? (
|
||||
<>
|
||||
{user.clubs.map((c) => (
|
||||
<div key={c.id}>
|
||||
<strong style={{ color: 'var(--text1)' }}>{c.name}</strong>
|
||||
{': '}
|
||||
{(c.roles || []).length ? (c.roles || []).join(', ') : '—'}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: '1rem' }}>
|
||||
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>Vereinsbeitritt</h2>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||
Beantrage die Mitgliedschaft in einem Verein. Vereinsadministratoren können den Antrag unter
|
||||
„Vereinsverwaltung → Mitglieder“ annehmen oder ablehnen.
|
||||
</p>
|
||||
|
||||
{myJoinRequests.length > 0 && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<strong style={{ fontSize: '0.9rem' }}>Meine Anträge</strong>
|
||||
<ul style={{ margin: '0.5rem 0 0', paddingLeft: '1.25rem', color: 'var(--text2)', fontSize: '0.9rem' }}>
|
||||
{myJoinRequests.map((r) => (
|
||||
<li key={r.id} style={{ marginBottom: '0.35rem' }}>
|
||||
{r.club_name || `Verein #${r.club_id}`} — {joinStatusLabel(r.status)}
|
||||
{r.status === 'pending' ? (
|
||||
<>
|
||||
{' '}
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
marginLeft: '0.35rem',
|
||||
fontSize: '0.75rem',
|
||||
padding: '0.15rem 0.45rem',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface2)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={async () => {
|
||||
if (!confirm('Antrag wirklich zurückziehen?')) return
|
||||
try {
|
||||
await api.withdrawClubJoinRequest(r.id)
|
||||
refreshJoinRequests()
|
||||
} catch (err) {
|
||||
showErr(err.message || 'Zurückziehen fehlgeschlagen.')
|
||||
}
|
||||
}}
|
||||
>
|
||||
zurückziehen
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault()
|
||||
if (!joinClubId) {
|
||||
showErr('Bitte einen Verein auswählen.')
|
||||
return
|
||||
}
|
||||
setJoinBusy(true)
|
||||
try {
|
||||
await api.createClubJoinRequest({
|
||||
club_id: parseInt(joinClubId, 10),
|
||||
message: (joinMessage || '').trim() || undefined,
|
||||
})
|
||||
setJoinMessage('')
|
||||
setJoinClubId('')
|
||||
refreshJoinRequests()
|
||||
await checkAuth()
|
||||
showOk('Antrag gesendet.')
|
||||
} catch (err) {
|
||||
showErr(err.message || 'Antrag fehlgeschlagen.')
|
||||
} finally {
|
||||
setJoinBusy(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label className="form-label" htmlFor="join-club-select">
|
||||
Verein auswählen
|
||||
</label>
|
||||
<select
|
||||
id="join-club-select"
|
||||
className="form-input"
|
||||
value={joinClubId}
|
||||
onChange={(e) => setJoinClubId(e.target.value)}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{joinClubChoices.map((c) => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{c.name}
|
||||
{c.abbreviation ? ` (${c.abbreviation})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="form-label" htmlFor="join-msg" style={{ marginTop: '0.75rem' }}>
|
||||
Nachricht (optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="join-msg"
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={joinMessage}
|
||||
onChange={(e) => setJoinMessage(e.target.value)}
|
||||
placeholder="z. B. Trainingsgruppe oder Kontakt zum Verein"
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary" disabled={joinBusy} style={{ marginTop: '0.85rem' }}>
|
||||
{joinBusy ? 'Senden…' : 'Beitritt beantragen'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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 => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
|
|
@ -204,6 +329,7 @@ function ClubsPage() {
|
|||
{tab === 'clubs' && 'Vereine'}
|
||||
{tab === 'divisions' && 'Sparten'}
|
||||
{tab === 'groups' && 'Trainingsgruppen'}
|
||||
{tab === 'members' && 'Mitglieder'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -440,6 +566,156 @@ function ClubsPage() {
|
|||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'members' && canManageOrgSomewhere && (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<h2>Mitglieder & Beitrittsanträge</h2>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<label style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>Verein</label>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ minWidth: '200px' }}
|
||||
value={membersAdminClubId ?? ''}
|
||||
onChange={(e) =>
|
||||
setMembersAdminClubId(e.target.value ? parseInt(e.target.value, 10) : null)
|
||||
}
|
||||
>
|
||||
{manageableClubs.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
setAddMemberForm({ profile_id: '', roles: ['trainer'] })
|
||||
setShowAddMemberModal(true)
|
||||
}}
|
||||
>
|
||||
Mitglied hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{manageableClubs.length === 0 ? (
|
||||
<div className="card">
|
||||
<p style={{ color: 'var(--text2)' }}>
|
||||
Keine Vereine, für die du Mitglieder verwalten darfst.
|
||||
</p>
|
||||
</div>
|
||||
) : membersAdminLoading ? (
|
||||
<p style={{ color: 'var(--text2)' }}>Laden…</p>
|
||||
) : (
|
||||
<>
|
||||
{joinRequestsAdmin.length > 0 && (
|
||||
<div className="card" style={{ marginBottom: '1rem' }}>
|
||||
<h3 style={{ marginTop: 0 }}>Offene Beitrittsanträge</h3>
|
||||
<div style={{ display: 'grid', gap: '0.75rem' }}>
|
||||
{joinRequestsAdmin.map((req) => (
|
||||
<div
|
||||
key={req.id}
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{req.applicant_name || req.applicant_email || 'Nutzer'}</strong>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text2)' }}>
|
||||
{req.applicant_email} · Profil #{req.profile_id}
|
||||
</div>
|
||||
{req.message ? (
|
||||
<div style={{ marginTop: '0.35rem', fontSize: '0.875rem' }}>{req.message}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.35rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() =>
|
||||
setAcceptJoinModal({
|
||||
id: req.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(membersAdminClubId, req.id)
|
||||
await reloadMembersAdmin()
|
||||
} catch (err) {
|
||||
alert(err.message || String(err))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Mitglieder</h3>
|
||||
{clubMembersAdmin.length === 0 ? (
|
||||
<p style={{ color: 'var(--text2)' }}>Noch keine Mitglieder erfasst.</p>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '0.65rem' }}>
|
||||
{clubMembersAdmin.map((m) => (
|
||||
<div
|
||||
key={m.membership_id}
|
||||
style={{
|
||||
padding: '0.65rem',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--surface2)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{m.name || m.email}</strong>
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--text2)' }}>
|
||||
{m.email} · #{m.profile_id} · {m.status}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', marginTop: '0.25rem' }}>
|
||||
Rollen: {(m.roles || []).join(', ') || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setEditMemberModal(m)}
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div style={{
|
||||
|
|
@ -718,6 +994,50 @@ function ClubsPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Haupttrainer</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={formData.trainer_id != null ? String(formData.trainer_id) : ''}
|
||||
onChange={(e) =>
|
||||
updateFormField(
|
||||
'trainer_id',
|
||||
e.target.value === '' ? '' : parseInt(e.target.value, 10)
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{groupMemberDirectory.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{(p.name || p.email || '').trim()} (#{p.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--text3)', marginTop: '0.35rem' }}>
|
||||
Liste = aktive Vereinsmitglieder. Nach Zuweisungen ggf. Seite neu laden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Co-Trainer (Mehrfachauswahl)</label>
|
||||
<select
|
||||
multiple
|
||||
className="form-input"
|
||||
size={Math.min(8, Math.max(4, groupMemberDirectory.length || 4))}
|
||||
value={(formData.co_trainer_ids || []).map(String)}
|
||||
onChange={(e) => {
|
||||
const opts = [...e.target.selectedOptions].map((o) => parseInt(o.value, 10))
|
||||
updateFormField('co_trainer_ids', opts)
|
||||
}}
|
||||
>
|
||||
{groupMemberDirectory.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{(p.name || p.email || '').trim()} (#{p.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Status</label>
|
||||
<select
|
||||
|
|
@ -749,6 +1069,340 @@ function ClubsPage() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddMemberModal && membersAdminClubId && (
|
||||
<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 }}>Mitglied hinzufügen</h2>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>
|
||||
{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.'}
|
||||
</p>
|
||||
{isPlatformAdmin && profilesAdminList.length > 0 ? (
|
||||
<div className="form-row">
|
||||
<label className="form-label">Profil</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={addMemberForm.profile_id === '' ? '' : String(addMemberForm.profile_id)}
|
||||
onChange={(e) =>
|
||||
setAddMemberForm((prev) => ({ ...prev, profile_id: e.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="">Bitte wählen…</option>
|
||||
{profilesAdminList.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
#{p.id} — {p.email || '—'} ({p.name || 'ohne Name'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="form-row">
|
||||
<label className="form-label">Profil-ID</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="form-input"
|
||||
value={addMemberForm.profile_id}
|
||||
onChange={(e) =>
|
||||
setAddMemberForm((prev) => ({
|
||||
...prev,
|
||||
profile_id: e.target.value === '' ? '' : parseInt(e.target.value, 10),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-row">
|
||||
<span className="form-label">Rollen</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={addMemberForm.roles.includes(opt.code)}
|
||||
onChange={() => {
|
||||
setAddMemberForm((prev) => {
|
||||
const set = new Set(prev.roles)
|
||||
if (set.has(opt.code)) set.delete(opt.code)
|
||||
else set.add(opt.code)
|
||||
return { ...prev, roles: Array.from(set) }
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{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 () => {
|
||||
const raw = addMemberForm.profile_id
|
||||
const profile_id = typeof raw === 'number' ? raw : parseInt(String(raw), 10)
|
||||
if (!Number.isFinite(profile_id) || profile_id < 1) {
|
||||
alert('Gültige Profil-ID wählen.')
|
||||
return
|
||||
}
|
||||
if (!addMemberForm.roles.length) {
|
||||
alert('Mindestens eine Rolle.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.addClubMember(membersAdminClubId, {
|
||||
profile_id,
|
||||
roles: addMemberForm.roles,
|
||||
})
|
||||
setShowAddMemberModal(false)
|
||||
await reloadMembersAdmin()
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
alert(err.message || String(err))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setShowAddMemberModal(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{acceptJoinModal && membersAdminClubId && (
|
||||
<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)' }}>{acceptJoinModal.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={acceptJoinModal.roles.includes(opt.code)}
|
||||
onChange={() => {
|
||||
setAcceptJoinModal((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(
|
||||
membersAdminClubId,
|
||||
acceptJoinModal.id,
|
||||
acceptJoinModal.roles.length ? acceptJoinModal.roles : ['trainer']
|
||||
)
|
||||
setAcceptJoinModal(null)
|
||||
await reloadMembersAdmin()
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
alert(err.message || String(err))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Aufnehmen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setAcceptJoinModal(null)}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editMemberModal && membersAdminClubId && (
|
||||
<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 }}>Mitglied bearbeiten</h2>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
|
||||
{editMemberModal.name || editMemberModal.email} (#{editMemberModal.profile_id})
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Status</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={editMemberModal.status || 'active'}
|
||||
onChange={(e) =>
|
||||
setEditMemberModal((prev) => (prev ? { ...prev, status: e.target.value } : prev))
|
||||
}
|
||||
>
|
||||
<option value="active">aktiv</option>
|
||||
<option value="inactive">inaktiv</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<span className="form-label">Rollen</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={(editMemberModal.roles || []).includes(opt.code)}
|
||||
onChange={() => {
|
||||
setEditMemberModal((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)
|
||||
let roles = Array.from(set)
|
||||
if (!roles.length) roles = ['trainer']
|
||||
return { ...prev, roles }
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '1rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.updateClubMember(membersAdminClubId, editMemberModal.profile_id, {
|
||||
roles: editMemberModal.roles,
|
||||
status: editMemberModal.status,
|
||||
})
|
||||
setEditMemberModal(null)
|
||||
await reloadMembersAdmin()
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
alert(err.message || String(err))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{ background: 'var(--danger)', color: '#fff', border: 'none' }}
|
||||
onClick={async () => {
|
||||
if (
|
||||
!confirm(
|
||||
'Mitgliedschaft wirklich entfernen? (Nutzer verliert alle Rollen in diesem Verein.)'
|
||||
)
|
||||
)
|
||||
return
|
||||
try {
|
||||
await api.removeClubMember(membersAdminClubId, editMemberModal.profile_id)
|
||||
setEditMemberModal(null)
|
||||
await reloadMembersAdmin()
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
alert(err.message || String(err))
|
||||
}
|
||||
}}
|
||||
>
|
||||
Aus Verein entfernen
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setEditMemberModal(null)}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../utils/api'
|
||||
|
|
@ -11,11 +11,18 @@ function LoginPage() {
|
|||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [success, setSuccess] = useState('')
|
||||
const [publicClubs, setPublicClubs] = useState([])
|
||||
const [requestedClubId, setRequestedClubId] = useState('')
|
||||
const [resending, setResending] = useState(false)
|
||||
|
||||
const navigate = useNavigate()
|
||||
const { checkAuth } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'register') return
|
||||
api.listPublicClubsDirectory().then(setPublicClubs).catch(() => setPublicClubs([]))
|
||||
}, [mode])
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
|
@ -29,10 +36,15 @@ function LoginPage() {
|
|||
await checkAuth()
|
||||
navigate('/')
|
||||
} else {
|
||||
await api.register(email, password, name)
|
||||
const extra =
|
||||
requestedClubId !== ''
|
||||
? { requested_club_id: parseInt(requestedClubId, 10) }
|
||||
: {}
|
||||
await api.register(email, password, name, extra)
|
||||
setSuccess('Registrierung erfolgreich! Bitte prüfe deine E-Mails (auch Spam).')
|
||||
setMode('login')
|
||||
setPassword('')
|
||||
setRequestedClubId('')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'Ein Fehler ist aufgetreten')
|
||||
|
|
@ -84,6 +96,7 @@ function LoginPage() {
|
|||
onClick={() => {
|
||||
setMode('login')
|
||||
setError('')
|
||||
setRequestedClubId('')
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
|
|
@ -104,6 +117,7 @@ function LoginPage() {
|
|||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{mode === 'register' && (
|
||||
<>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Name</label>
|
||||
<input
|
||||
|
|
@ -115,6 +129,26 @@ function LoginPage() {
|
|||
placeholder="Dein Name"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Verein (optional)</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={requestedClubId}
|
||||
onChange={(e) => setRequestedClubId(e.target.value)}
|
||||
>
|
||||
<option value="">Kein Antrag / später</option>
|
||||
{publicClubs.map((c) => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{c.name}
|
||||
{c.abbreviation ? ` (${c.abbreviation})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p style={{ fontSize: '0.72rem', color: 'var(--text3)', marginTop: '0.35rem', lineHeight: 1.4 }}>
|
||||
Nach der E-Mail-Bestätigung kann der Vereinsadmin deinen Beitritt freigeben.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="form-row">
|
||||
|
|
|
|||
|
|
@ -95,10 +95,10 @@ export async function login(email, password) {
|
|||
})
|
||||
}
|
||||
|
||||
export async function register(email, password, name) {
|
||||
export async function register(email, password, name, extra = {}) {
|
||||
return request('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, name })
|
||||
body: JSON.stringify({ email, password, name, ...extra }),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -202,6 +202,51 @@ export async function removeClubMember(clubId, profileId) {
|
|||
return request(`/api/clubs/${clubId}/members/${profileId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Aktive Vereine (öffentlich, für Registrierungswahl). */
|
||||
export async function listPublicClubsDirectory() {
|
||||
return request('/api/clubs/public-directory')
|
||||
}
|
||||
|
||||
/** Vereinsinternes Mitgliederverzeichnis (Trainer-/Co-Auswahl). */
|
||||
export async function clubMembersDirectory(clubId) {
|
||||
return request(`/api/clubs/${clubId}/members/directory`)
|
||||
}
|
||||
|
||||
/** Eigene Beitrittsanträge. */
|
||||
export async function getMyClubJoinRequests() {
|
||||
return request('/api/me/club-join-requests')
|
||||
}
|
||||
|
||||
export async function createClubJoinRequest(payload) {
|
||||
return request('/api/me/club-join-requests', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function withdrawClubJoinRequest(requestId) {
|
||||
return request(`/api/me/club-join-requests/${requestId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Offene Anträge (Vereins-/Plattform-Admin). */
|
||||
export async function listClubJoinRequests(clubId) {
|
||||
return request(`/api/clubs/${clubId}/join-requests`)
|
||||
}
|
||||
|
||||
export async function acceptClubJoinRequest(clubId, requestId, roles = ['trainer']) {
|
||||
return request(`/api/clubs/${clubId}/join-requests/${requestId}/accept`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ roles }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function rejectClubJoinRequest(clubId, requestId) {
|
||||
return request(`/api/clubs/${clubId}/join-requests/${requestId}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listDivisions(clubId) {
|
||||
const query = clubId ? `?club_id=${clubId}` : ''
|
||||
return request(`/api/divisions${query}`)
|
||||
|
|
@ -1069,6 +1114,14 @@ export const api = {
|
|||
addClubMember,
|
||||
updateClubMember,
|
||||
removeClubMember,
|
||||
listPublicClubsDirectory,
|
||||
clubMembersDirectory,
|
||||
getMyClubJoinRequests,
|
||||
createClubJoinRequest,
|
||||
withdrawClubJoinRequest,
|
||||
listClubJoinRequests,
|
||||
acceptClubJoinRequest,
|
||||
rejectClubJoinRequest,
|
||||
listDivisions,
|
||||
createDivision,
|
||||
updateDivision,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Shinkan Jinkendo Frontend Version
|
||||
|
||||
export const APP_VERSION = "0.8.17"
|
||||
export const APP_VERSION = "0.8.18"
|
||||
export const BUILD_DATE = "2026-05-05"
|
||||
|
||||
export const PAGE_VERSIONS = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user