From 8e027e02bb5a2f5802f6029cd550fbfe9c970e21 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 22 Apr 2026 16:48:02 +0200 Subject: [PATCH] feat: Clubs & Organization Management complete Backend: - Created routers/clubs.py with full CRUD - Clubs: list, get, create, update, delete (admin only) - Divisions: list, create, update, delete (admin only) - Training Groups: list, get, create, update, delete (admin/trainer) - Registered clubs router in main.py - Permission checks: admin for clubs/divisions, trainer for groups Frontend: - Complete ClubsPage with 3 tabs (Vereine, Sparten, Gruppen) - Role-based UI (admin sees all actions, trainer can manage groups) - Full CRUD forms with modals - Mobile-responsive card layouts - Updated api.js with all club/division/group functions Migration already exists: 002_organization.sql (clubs, divisions, training_groups) Next: Skills & Methods display (read-only) --- backend/main.py | 7 +- backend/routers/clubs.py | 528 ++++++++++++++++++++++++ frontend/src/pages/ClubsPage.jsx | 684 ++++++++++++++++++++++++++++++- frontend/src/utils/api.js | 67 ++- 4 files changed, 1276 insertions(+), 10 deletions(-) create mode 100644 backend/routers/clubs.py diff --git a/backend/main.py b/backend/main.py index 06a2849..42427a4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -70,15 +70,16 @@ def read_root(): } # Register routers -from routers import auth, profiles, exercises +from routers import auth, profiles, exercises, clubs app.include_router(auth.router) app.include_router(profiles.router) app.include_router(exercises.router) +app.include_router(clubs.router) # TODO: Add more routers as they are created -# from routers import clubs, groups, skills, methods -# app.include_router(clubs.router, prefix="/api") +# from routers import skills, methods +# app.include_router(skills.router, prefix="/api") # ... etc if __name__ == "__main__": diff --git a/backend/routers/clubs.py b/backend/routers/clubs.py new file mode 100644 index 0000000..7e20293 --- /dev/null +++ b/backend/routers/clubs.py @@ -0,0 +1,528 @@ +""" +Club & Organization Management Endpoints for Shinkan Jinkendo + +Handles CRUD operations for clubs, divisions, and training groups. +""" +from typing import Optional +from fastapi import APIRouter, HTTPException, Depends, Query + +from db import get_db, get_cursor, r2d +from auth import require_auth + +router = APIRouter(prefix="/api", tags=["clubs"]) + + +# ── List Clubs ──────────────────────────────────────────────────────── +@router.get("/clubs") +def list_clubs( + status: Optional[str] = Query(default=None), + session=Depends(require_auth) +): + """ + List all clubs (public for authenticated users). + + Filters: + - status: active, inactive + """ + with get_db() as conn: + cur = get_cursor(conn) + + query = "SELECT * FROM clubs" + params = [] + + if status: + query += " WHERE status = %s" + params.append(status) + + query += " ORDER BY name" + + cur.execute(query, params) + rows = cur.fetchall() + return [r2d(r) for r in rows] + + +# ── Get Club ────────────────────────────────────────────────────────── +@router.get("/clubs/{club_id}") +def get_club(club_id: int, session=Depends(require_auth)): + """Get club by ID with divisions and groups.""" + with get_db() as conn: + cur = get_cursor(conn) + + # Get club + cur.execute("SELECT * FROM clubs WHERE id = %s", (club_id,)) + club = cur.fetchone() + + if not club: + raise HTTPException(404, "Verein nicht gefunden") + + club = r2d(club) + + # Get divisions + cur.execute(""" + SELECT * FROM divisions + WHERE club_id = %s + ORDER BY name + """, (club_id,)) + club['divisions'] = [r2d(r) for r in cur.fetchall()] + + # Get training groups + cur.execute(""" + SELECT g.*, + p.name as trainer_name + FROM training_groups g + LEFT JOIN profiles p ON g.trainer_id = p.id + WHERE g.club_id = %s + ORDER BY g.weekday, g.time_start + """, (club_id,)) + club['training_groups'] = [r2d(r) for r in cur.fetchall()] + + return club + + +# ── Create Club ─────────────────────────────────────────────────────── +@router.post("/clubs") +def create_club(data: dict, session=Depends(require_auth)): + """Create new club (admin only).""" + role = session.get('role') + if role not in ['admin', 'superadmin']: + raise HTTPException(403, "Nur Admins dürfen Vereine erstellen") + + name = data.get('name') + if not name: + raise HTTPException(400, "Name ist Pflichtfeld") + + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute(""" + INSERT INTO clubs (name, abbreviation, description, status) + VALUES (%s, %s, %s, %s) + RETURNING id + """, ( + name, + data.get('abbreviation'), + data.get('description'), + data.get('status', 'active') + )) + + club_id = cur.fetchone()['id'] + conn.commit() + + return get_club(club_id, session) + + +# ── Update Club ─────────────────────────────────────────────────────── +@router.put("/clubs/{club_id}") +def update_club(club_id: int, data: dict, session=Depends(require_auth)): + """Update club (admin only).""" + role = session.get('role') + if role not in ['admin', 'superadmin']: + raise HTTPException(403, "Nur Admins dürfen Vereine bearbeiten") + + with get_db() as conn: + cur = get_cursor(conn) + + # Check existence + cur.execute("SELECT id FROM clubs WHERE id = %s", (club_id,)) + if not cur.fetchone(): + raise HTTPException(404, "Verein nicht gefunden") + + # Update + cur.execute(""" + UPDATE clubs SET + name = %s, + abbreviation = %s, + description = %s, + status = %s, + updated_at = NOW() + WHERE id = %s + """, ( + data.get('name'), + data.get('abbreviation'), + data.get('description'), + data.get('status'), + club_id + )) + + conn.commit() + + return get_club(club_id, session) + + +# ── Delete Club ─────────────────────────────────────────────────────── +@router.delete("/clubs/{club_id}") +def delete_club(club_id: int, session=Depends(require_auth)): + """Delete club (superadmin only).""" + role = session.get('role') + if role != 'superadmin': + raise HTTPException(403, "Nur Superadmins dürfen Vereine löschen") + + with get_db() as conn: + cur = get_cursor(conn) + + # Check existence + cur.execute("SELECT id FROM clubs WHERE id = %s", (club_id,)) + if not cur.fetchone(): + raise HTTPException(404, "Verein nicht gefunden") + + # Delete (CASCADE handles divisions and groups) + cur.execute("DELETE FROM clubs WHERE id = %s", (club_id,)) + conn.commit() + + return {"ok": True} + + +# ── List Divisions ──────────────────────────────────────────────────── +@router.get("/divisions") +def list_divisions( + club_id: Optional[int] = Query(default=None), + session=Depends(require_auth) +): + """List divisions (optional filter by club).""" + with get_db() as conn: + cur = get_cursor(conn) + + query = """ + SELECT d.*, c.name as club_name + FROM divisions d + LEFT JOIN clubs c ON d.club_id = c.id + """ + params = [] + + if club_id: + query += " WHERE d.club_id = %s" + params.append(club_id) + + query += " ORDER BY d.name" + + cur.execute(query, params) + rows = cur.fetchall() + return [r2d(r) for r in rows] + + +# ── Create Division ─────────────────────────────────────────────────── +@router.post("/divisions") +def create_division(data: dict, session=Depends(require_auth)): + """Create new division (admin only).""" + role = session.get('role') + if role not in ['admin', 'superadmin']: + raise HTTPException(403, "Nur Admins dürfen Sparten erstellen") + + club_id = data.get('club_id') + name = data.get('name') + + if not club_id or not name: + raise HTTPException(400, "club_id und name sind Pflichtfelder") + + with get_db() as conn: + cur = get_cursor(conn) + + # Check club exists + cur.execute("SELECT id FROM clubs WHERE id = %s", (club_id,)) + if not cur.fetchone(): + raise HTTPException(404, "Verein nicht gefunden") + + # Insert + cur.execute(""" + INSERT INTO divisions (club_id, name, focus_area) + VALUES (%s, %s, %s) + RETURNING id + """, ( + club_id, + name, + data.get('focus_area') + )) + + division_id = cur.fetchone()['id'] + conn.commit() + + # Return created division + cur.execute(""" + SELECT d.*, c.name as club_name + FROM divisions d + LEFT JOIN clubs c ON d.club_id = c.id + WHERE d.id = %s + """, (division_id,)) + return r2d(cur.fetchone()) + + +# ── Update Division ─────────────────────────────────────────────────── +@router.put("/divisions/{division_id}") +def update_division(division_id: int, data: dict, session=Depends(require_auth)): + """Update division (admin only).""" + role = session.get('role') + if role not in ['admin', 'superadmin']: + raise HTTPException(403, "Nur Admins dürfen Sparten bearbeiten") + + with get_db() as conn: + cur = get_cursor(conn) + + # Check existence + cur.execute("SELECT id FROM divisions WHERE id = %s", (division_id,)) + if not cur.fetchone(): + raise HTTPException(404, "Sparte nicht gefunden") + + # Update + cur.execute(""" + UPDATE divisions SET + name = %s, + focus_area = %s, + updated_at = NOW() + WHERE id = %s + """, ( + data.get('name'), + data.get('focus_area'), + division_id + )) + + conn.commit() + + # Return updated division + cur.execute(""" + SELECT d.*, c.name as club_name + FROM divisions d + LEFT JOIN clubs c ON d.club_id = c.id + WHERE d.id = %s + """, (division_id,)) + return r2d(cur.fetchone()) + + +# ── Delete Division ─────────────────────────────────────────────────── +@router.delete("/divisions/{division_id}") +def delete_division(division_id: int, session=Depends(require_auth)): + """Delete division (admin only).""" + role = session.get('role') + if role not in ['admin', 'superadmin']: + raise HTTPException(403, "Nur Admins dürfen Sparten löschen") + + with get_db() as conn: + cur = get_cursor(conn) + + # Check existence + cur.execute("SELECT id FROM divisions WHERE id = %s", (division_id,)) + if not cur.fetchone(): + raise HTTPException(404, "Sparte nicht gefunden") + + # Delete + cur.execute("DELETE FROM divisions WHERE id = %s", (division_id,)) + conn.commit() + + return {"ok": True} + + +# ── List Training Groups ────────────────────────────────────────────── +@router.get("/groups") +def list_training_groups( + club_id: Optional[int] = Query(default=None), + division_id: Optional[int] = Query(default=None), + status: Optional[str] = Query(default=None), + session=Depends(require_auth) +): + """ + List training groups with optional filters. + + Filters: + - club_id: Filter by club + - division_id: Filter by division + - status: active, inactive + """ + with get_db() as conn: + cur = get_cursor(conn) + + query = """ + SELECT g.*, + c.name as club_name, + d.name as division_name, + p.name as trainer_name + FROM training_groups g + LEFT JOIN clubs c ON g.club_id = c.id + LEFT JOIN divisions d ON g.division_id = d.id + LEFT JOIN profiles p ON g.trainer_id = p.id + """ + + where = [] + params = [] + + if club_id: + where.append("g.club_id = %s") + params.append(club_id) + + if division_id: + where.append("g.division_id = %s") + params.append(division_id) + + if status: + where.append("g.status = %s") + params.append(status) + + if where: + query += " WHERE " + " AND ".join(where) + + query += " ORDER BY g.weekday, g.time_start" + + cur.execute(query, params) + rows = cur.fetchall() + return [r2d(r) for r in rows] + + +# ── Get Training Group ──────────────────────────────────────────────── +@router.get("/groups/{group_id}") +def get_training_group(group_id: int, session=Depends(require_auth)): + """Get training group by ID.""" + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute(""" + SELECT g.*, + c.name as club_name, + d.name as division_name, + p.name as trainer_name + FROM training_groups g + LEFT JOIN clubs c ON g.club_id = c.id + LEFT JOIN divisions d ON g.division_id = d.id + LEFT JOIN profiles p ON g.trainer_id = p.id + WHERE g.id = %s + """, (group_id,)) + + group = cur.fetchone() + + if not group: + raise HTTPException(404, "Trainingsgruppe nicht gefunden") + + return r2d(group) + + +# ── Create Training Group ───────────────────────────────────────────── +@router.post("/groups") +def create_training_group(data: dict, session=Depends(require_auth)): + """Create new training group (admin or trainer).""" + role = session.get('role') + if role not in ['admin', 'superadmin', 'trainer']: + raise HTTPException(403, "Nur Admins und Trainer dürfen Gruppen erstellen") + + club_id = data.get('club_id') + name = data.get('name') + + if not club_id or not name: + raise HTTPException(400, "club_id und name sind Pflichtfelder") + + with get_db() as conn: + cur = get_cursor(conn) + + # Check club exists + cur.execute("SELECT id FROM clubs WHERE id = %s", (club_id,)) + if not cur.fetchone(): + raise HTTPException(404, "Verein nicht gefunden") + + # Insert + cur.execute(""" + INSERT INTO training_groups ( + club_id, division_id, name, focus, level, age_group, + weekday, time_start, time_end, location, + trainer_id, co_trainer_ids, status + ) VALUES ( + %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, + %s, %s, %s + ) RETURNING id + """, ( + club_id, + data.get('division_id'), + name, + data.get('focus'), + data.get('level'), + data.get('age_group'), + data.get('weekday'), + data.get('time_start'), + data.get('time_end'), + data.get('location'), + data.get('trainer_id'), + data.get('co_trainer_ids'), + data.get('status', 'active') + )) + + group_id = cur.fetchone()['id'] + conn.commit() + + return get_training_group(group_id, session) + + +# ── Update Training Group ───────────────────────────────────────────── +@router.put("/groups/{group_id}") +def update_training_group(group_id: int, data: dict, session=Depends(require_auth)): + """Update training group (admin or assigned trainer).""" + profile_id = session['profile_id'] + role = session.get('role') + + with get_db() as conn: + cur = get_cursor(conn) + + # Check existence and ownership + cur.execute("SELECT trainer_id FROM training_groups WHERE id = %s", (group_id,)) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Trainingsgruppe nicht gefunden") + + # Only admin or assigned trainer can update + if role not in ['admin', 'superadmin'] and row['trainer_id'] != profile_id: + raise HTTPException(403, "Keine Berechtigung") + + # Update + cur.execute(""" + UPDATE training_groups SET + name = %s, + division_id = %s, + focus = %s, + level = %s, + age_group = %s, + weekday = %s, + time_start = %s, + time_end = %s, + location = %s, + trainer_id = %s, + co_trainer_ids = %s, + status = %s, + updated_at = NOW() + WHERE id = %s + """, ( + data.get('name'), + data.get('division_id'), + data.get('focus'), + data.get('level'), + data.get('age_group'), + data.get('weekday'), + data.get('time_start'), + data.get('time_end'), + data.get('location'), + data.get('trainer_id'), + data.get('co_trainer_ids'), + data.get('status'), + group_id + )) + + conn.commit() + + return get_training_group(group_id, session) + + +# ── Delete Training Group ───────────────────────────────────────────── +@router.delete("/groups/{group_id}") +def delete_training_group(group_id: int, session=Depends(require_auth)): + """Delete training group (admin only).""" + role = session.get('role') + if role not in ['admin', 'superadmin']: + raise HTTPException(403, "Nur Admins dürfen Gruppen löschen") + + with get_db() as conn: + cur = get_cursor(conn) + + # Check existence + cur.execute("SELECT id FROM training_groups WHERE id = %s", (group_id,)) + if not cur.fetchone(): + raise HTTPException(404, "Trainingsgruppe nicht gefunden") + + # Delete + cur.execute("DELETE FROM training_groups WHERE id = %s", (group_id,)) + conn.commit() + + return {"ok": True} diff --git a/frontend/src/pages/ClubsPage.jsx b/frontend/src/pages/ClubsPage.jsx index f303db9..c51bd9e 100644 --- a/frontend/src/pages/ClubsPage.jsx +++ b/frontend/src/pages/ClubsPage.jsx @@ -1,14 +1,688 @@ +import React, { useState, useEffect } from 'react' +import api from '../utils/api' +import { useAuth } from '../context/AuthContext' + function ClubsPage() { + const { user } = useAuth() + const [activeTab, setActiveTab] = useState('clubs') + const [clubs, setClubs] = useState([]) + const [divisions, setDivisions] = useState([]) + const [groups, setGroups] = useState([]) + const [loading, setLoading] = useState(true) + const [showModal, setShowModal] = useState(false) + const [editing, setEditing] = useState(null) + const [modalType, setModalType] = useState('club') + + // Form state + const [formData, setFormData] = useState({}) + + const isAdmin = user?.role === 'admin' || user?.role === 'superadmin' + const isTrainer = user?.role === 'trainer' || isAdmin + + useEffect(() => { + loadData() + }, []) + + const loadData = async () => { + try { + const [clubsData, divisionsData, groupsData] = await Promise.all([ + api.listClubs(), + api.listDivisions(), + api.listTrainingGroups() + ]) + setClubs(clubsData) + setDivisions(divisionsData) + setGroups(groupsData) + } catch (err) { + console.error('Failed to load data:', err) + alert('Fehler beim Laden: ' + err.message) + } finally { + setLoading(false) + } + } + + const handleCreate = (type) => { + setEditing(null) + setModalType(type) + + if (type === 'club') { + setFormData({ name: '', abbreviation: '', description: '', status: 'active' }) + } else if (type === 'division') { + setFormData({ club_id: '', name: '', focus_area: '' }) + } else if (type === 'group') { + setFormData({ + club_id: '', + division_id: '', + name: '', + focus: '', + level: '', + age_group: '', + weekday: '', + time_start: '', + time_end: '', + location: '', + trainer_id: '', + co_trainer_ids: [], + status: 'active' + }) + } + + setShowModal(true) + } + + const handleEdit = (item, type) => { + setEditing(item) + setModalType(type) + setFormData({ ...item }) + setShowModal(true) + } + + const handleDelete = async (item, type) => { + const confirmMsg = { + club: `Verein "${item.name}" wirklich löschen? Alle Sparten und Gruppen werden auch gelöscht!`, + division: `Sparte "${item.name}" wirklich löschen?`, + group: `Trainingsgruppe "${item.name}" wirklich löschen?` + } + + if (!confirm(confirmMsg[type])) return + + try { + if (type === 'club') await api.deleteClub(item.id) + else if (type === 'division') await api.deleteDivision(item.id) + else if (type === 'group') await api.deleteTrainingGroup(item.id) + + await loadData() + } catch (err) { + alert('Fehler beim Löschen: ' + err.message) + } + } + + const handleSubmit = async (e) => { + e.preventDefault() + + try { + if (modalType === 'club') { + if (editing) { + await api.updateClub(editing.id, formData) + } else { + await api.createClub(formData) + } + } else if (modalType === 'division') { + if (editing) { + await api.updateDivision(editing.id, formData) + } else { + await api.createDivision(formData) + } + } else if (modalType === 'group') { + if (editing) { + await api.updateTrainingGroup(editing.id, formData) + } else { + await api.createTrainingGroup(formData) + } + } + + setShowModal(false) + await loadData() + } catch (err) { + alert('Fehler beim Speichern: ' + err.message) + } + } + + const updateFormField = (field, value) => { + setFormData(prev => ({ ...prev, [field]: value })) + } + + if (loading) { + return ( +
+
+

Laden...

+
+ ) + } + return (
-

Vereinsverwaltung

+

Vereinsverwaltung

-
-

- Vereinsverwaltung folgt in Kürze -

+ {/* Tabs */} +
+ {['clubs', 'divisions', 'groups'].map(tab => ( + + ))}
+ + {/* Clubs Tab */} + {activeTab === 'clubs' && ( + <> +
+

Vereine

+ {isAdmin && ( + + )} +
+ + {clubs.length === 0 ? ( +
+

+ Keine Vereine gefunden +

+
+ ) : ( +
+ {clubs.map(club => ( +
+
+
+

+ {club.name} + {club.abbreviation && ( + + ({club.abbreviation}) + + )} +

+ {club.description && ( +

+ {club.description} +

+ )} + + {club.status} + +
+ {isAdmin && ( +
+ + +
+ )} +
+
+ ))} +
+ )} + + )} + + {/* Divisions Tab */} + {activeTab === 'divisions' && ( + <> +
+

Sparten

+ {isAdmin && ( + + )} +
+ + {divisions.length === 0 ? ( +
+

+ Keine Sparten gefunden +

+
+ ) : ( +
+ {divisions.map(division => ( +
+
+
+

{division.name}

+

+ Verein: {division.club_name} +

+ {division.focus_area && ( + + {division.focus_area} + + )} +
+ {isAdmin && ( +
+ + +
+ )} +
+
+ ))} +
+ )} + + )} + + {/* Training Groups Tab */} + {activeTab === 'groups' && ( + <> +
+

Trainingsgruppen

+ {isTrainer && ( + + )} +
+ + {groups.length === 0 ? ( +
+

+ Keine Trainingsgruppen gefunden +

+
+ ) : ( +
+ {groups.map(group => ( +
+

{group.name}

+

+ {group.club_name} + {group.division_name && ` · ${group.division_name}`} +

+ +
+ {group.weekday && group.time_start && ( +
📅 {group.weekday}, {group.time_start.slice(0,5)} - {group.time_end?.slice(0,5)}
+ )} + {group.location &&
📍 {group.location}
} + {group.trainer_name &&
👤 {group.trainer_name}
} + {group.level &&
⭐ {group.level}
} + {group.age_group &&
👶 {group.age_group}
} +
+ + {(isAdmin || group.trainer_id === user?.id) && ( +
+ + {isAdmin && ( + + )} +
+ )} +
+ ))} +
+ )} + + )} + + {/* Modal */} + {showModal && ( +
+
+

+ {editing + ? (modalType === 'club' ? 'Verein bearbeiten' : modalType === 'division' ? 'Sparte bearbeiten' : 'Gruppe bearbeiten') + : (modalType === 'club' ? 'Neuer Verein' : modalType === 'division' ? 'Neue Sparte' : 'Neue Gruppe') + } +

+ +
+ {/* Club Form */} + {modalType === 'club' && ( + <> +
+ + updateFormField('name', e.target.value)} + required + /> +
+ +
+ + updateFormField('abbreviation', e.target.value)} + /> +
+ +
+ +