feat: Clubs & Organization Management complete
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 5s
Test Suite / playwright-tests (push) Failing after 14s

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)
This commit is contained in:
Lars 2026-04-22 16:48:02 +02:00
parent 8c7cf91cef
commit 8e027e02bb
4 changed files with 1276 additions and 10 deletions

View File

@ -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__":

528
backend/routers/clubs.py Normal file
View File

@ -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}

View File

@ -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 (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner"></div>
<p>Laden...</p>
</div>
)
}
return (
<div style={{ padding: '2rem' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
<h1>Vereinsverwaltung</h1>
<h1 style={{ marginBottom: '1.5rem' }}>Vereinsverwaltung</h1>
<div className="card" style={{ marginTop: '1.5rem' }}>
<p style={{ color: 'var(--text2)' }}>
Vereinsverwaltung folgt in Kürze
</p>
{/* Tabs */}
<div style={{
display: 'flex',
gap: '0.5rem',
marginBottom: '1.5rem',
borderBottom: '2px solid var(--border)'
}}>
{['clubs', 'divisions', 'groups'].map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
style={{
padding: '0.75rem 1.5rem',
background: activeTab === tab ? 'var(--accent)' : 'transparent',
color: activeTab === tab ? 'white' : 'var(--text1)',
border: 'none',
borderRadius: '8px 8px 0 0',
cursor: 'pointer',
fontWeight: activeTab === tab ? 'bold' : 'normal'
}}
>
{tab === 'clubs' && 'Vereine'}
{tab === 'divisions' && 'Sparten'}
{tab === 'groups' && 'Trainingsgruppen'}
</button>
))}
</div>
{/* Clubs Tab */}
{activeTab === 'clubs' && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
<h2>Vereine</h2>
{isAdmin && (
<button className="btn btn-primary" onClick={() => handleCreate('club')}>
+ Neuer Verein
</button>
)}
</div>
{clubs.length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
Keine Vereine gefunden
</p>
</div>
) : (
<div style={{ display: 'grid', gap: '1rem' }}>
{clubs.map(club => (
<div key={club.id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<div style={{ flex: 1 }}>
<h3 style={{ marginBottom: '0.5rem' }}>
{club.name}
{club.abbreviation && (
<span style={{ color: 'var(--text2)', fontSize: '0.875rem', marginLeft: '0.5rem' }}>
({club.abbreviation})
</span>
)}
</h3>
{club.description && (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>
{club.description}
</p>
)}
<span style={{
display: 'inline-block',
marginTop: '0.5rem',
fontSize: '0.75rem',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
background: club.status === 'active' ? '#2ea44f' : 'var(--surface2)',
color: club.status === 'active' ? 'white' : 'var(--text2)'
}}>
{club.status}
</span>
</div>
{isAdmin && (
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
className="btn btn-secondary"
onClick={() => handleEdit(club, 'club')}
>
Bearbeiten
</button>
<button
className="btn"
style={{
background: 'var(--danger)',
color: 'white',
border: 'none'
}}
onClick={() => handleDelete(club, 'club')}
>
Löschen
</button>
</div>
)}
</div>
</div>
))}
</div>
)}
</>
)}
{/* Divisions Tab */}
{activeTab === 'divisions' && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
<h2>Sparten</h2>
{isAdmin && (
<button className="btn btn-primary" onClick={() => handleCreate('division')}>
+ Neue Sparte
</button>
)}
</div>
{divisions.length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
Keine Sparten gefunden
</p>
</div>
) : (
<div style={{ display: 'grid', gap: '1rem' }}>
{divisions.map(division => (
<div key={division.id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<div style={{ flex: 1 }}>
<h3 style={{ marginBottom: '0.5rem' }}>{division.name}</h3>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem' }}>
Verein: {division.club_name}
</p>
{division.focus_area && (
<span style={{
display: 'inline-block',
marginTop: '0.5rem',
fontSize: '0.75rem',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
background: 'var(--surface2)',
color: 'var(--text2)'
}}>
{division.focus_area}
</span>
)}
</div>
{isAdmin && (
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
className="btn btn-secondary"
onClick={() => handleEdit(division, 'division')}
>
Bearbeiten
</button>
<button
className="btn"
style={{
background: 'var(--danger)',
color: 'white',
border: 'none'
}}
onClick={() => handleDelete(division, 'division')}
>
Löschen
</button>
</div>
)}
</div>
</div>
))}
</div>
)}
</>
)}
{/* Training Groups Tab */}
{activeTab === 'groups' && (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
<h2>Trainingsgruppen</h2>
{isTrainer && (
<button className="btn btn-primary" onClick={() => handleCreate('group')}>
+ Neue Gruppe
</button>
)}
</div>
{groups.length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
Keine Trainingsgruppen gefunden
</p>
</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
gap: '1rem'
}}>
{groups.map(group => (
<div key={group.id} className="card">
<h3 style={{ marginBottom: '0.5rem' }}>{group.name}</h3>
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '0.5rem' }}>
{group.club_name}
{group.division_name && ` · ${group.division_name}`}
</p>
<div style={{ fontSize: '0.875rem', color: 'var(--text2)', marginBottom: '1rem' }}>
{group.weekday && group.time_start && (
<div>📅 {group.weekday}, {group.time_start.slice(0,5)} - {group.time_end?.slice(0,5)}</div>
)}
{group.location && <div>📍 {group.location}</div>}
{group.trainer_name && <div>👤 {group.trainer_name}</div>}
{group.level && <div> {group.level}</div>}
{group.age_group && <div>👶 {group.age_group}</div>}
</div>
{(isAdmin || group.trainer_id === user?.id) && (
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
<button
className="btn btn-secondary"
style={{ flex: 1 }}
onClick={() => handleEdit(group, 'group')}
>
Bearbeiten
</button>
{isAdmin && (
<button
className="btn"
style={{
background: 'var(--danger)',
color: 'white',
border: 'none'
}}
onClick={() => handleDelete(group, 'group')}
>
Löschen
</button>
)}
</div>
)}
</div>
))}
</div>
)}
</>
)}
{/* Modal */}
{showModal && (
<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: 1000,
padding: '1rem'
}}>
<div style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '2rem',
maxWidth: '600px',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto'
}}>
<h2 style={{ marginBottom: '1.5rem' }}>
{editing
? (modalType === 'club' ? 'Verein bearbeiten' : modalType === 'division' ? 'Sparte bearbeiten' : 'Gruppe bearbeiten')
: (modalType === 'club' ? 'Neuer Verein' : modalType === 'division' ? 'Neue Sparte' : 'Neue Gruppe')
}
</h2>
<form onSubmit={handleSubmit}>
{/* Club Form */}
{modalType === 'club' && (
<>
<div className="form-row">
<label className="form-label">Name *</label>
<input
type="text"
className="form-input"
value={formData.name || ''}
onChange={(e) => updateFormField('name', e.target.value)}
required
/>
</div>
<div className="form-row">
<label className="form-label">Kürzel</label>
<input
type="text"
className="form-input"
value={formData.abbreviation || ''}
onChange={(e) => updateFormField('abbreviation', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
rows={3}
value={formData.description || ''}
onChange={(e) => updateFormField('description', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Status</label>
<select
className="form-input"
value={formData.status || 'active'}
onChange={(e) => updateFormField('status', e.target.value)}
>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
</select>
</div>
</>
)}
{/* Division Form */}
{modalType === 'division' && (
<>
<div className="form-row">
<label className="form-label">Verein *</label>
<select
className="form-input"
value={formData.club_id || ''}
onChange={(e) => updateFormField('club_id', parseInt(e.target.value))}
required
disabled={editing}
>
<option value="">Bitte wählen</option>
{clubs.map(club => (
<option key={club.id} value={club.id}>{club.name}</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Name *</label>
<input
type="text"
className="form-input"
value={formData.name || ''}
onChange={(e) => updateFormField('name', e.target.value)}
required
/>
</div>
<div className="form-row">
<label className="form-label">Fokusbereich</label>
<select
className="form-input"
value={formData.focus_area || ''}
onChange={(e) => updateFormField('focus_area', e.target.value)}
>
<option value="">Bitte wählen</option>
<option value="karate">Karate</option>
<option value="selbstverteidigung">Selbstverteidigung</option>
<option value="gewaltschutz">Gewaltschutz</option>
</select>
</div>
</>
)}
{/* Group Form */}
{modalType === 'group' && (
<>
<div className="form-row">
<label className="form-label">Verein *</label>
<select
className="form-input"
value={formData.club_id || ''}
onChange={(e) => updateFormField('club_id', parseInt(e.target.value))}
required
disabled={editing}
>
<option value="">Bitte wählen</option>
{clubs.map(club => (
<option key={club.id} value={club.id}>{club.name}</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Sparte (optional)</label>
<select
className="form-input"
value={formData.division_id || ''}
onChange={(e) => updateFormField('division_id', e.target.value ? parseInt(e.target.value) : null)}
>
<option value="">Keine Sparte</option>
{divisions.filter(d => d.club_id === formData.club_id).map(div => (
<option key={div.id} value={div.id}>{div.name}</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Name *</label>
<input
type="text"
className="form-input"
value={formData.name || ''}
onChange={(e) => updateFormField('name', e.target.value)}
required
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div className="form-row">
<label className="form-label">Level</label>
<input
type="text"
className="form-input"
value={formData.level || ''}
onChange={(e) => updateFormField('level', e.target.value)}
placeholder="z.B. Anfänger, Fortgeschritten"
/>
</div>
<div className="form-row">
<label className="form-label">Altersgruppe</label>
<input
type="text"
className="form-input"
value={formData.age_group || ''}
onChange={(e) => updateFormField('age_group', e.target.value)}
placeholder="z.B. Kinder, Erwachsene"
/>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '1rem' }}>
<div className="form-row">
<label className="form-label">Wochentag</label>
<select
className="form-input"
value={formData.weekday || ''}
onChange={(e) => updateFormField('weekday', e.target.value)}
>
<option value="">-</option>
<option value="Montag">Montag</option>
<option value="Dienstag">Dienstag</option>
<option value="Mittwoch">Mittwoch</option>
<option value="Donnerstag">Donnerstag</option>
<option value="Freitag">Freitag</option>
<option value="Samstag">Samstag</option>
<option value="Sonntag">Sonntag</option>
</select>
</div>
<div className="form-row">
<label className="form-label">Von</label>
<input
type="time"
className="form-input"
value={formData.time_start || ''}
onChange={(e) => updateFormField('time_start', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Bis</label>
<input
type="time"
className="form-input"
value={formData.time_end || ''}
onChange={(e) => updateFormField('time_end', e.target.value)}
/>
</div>
</div>
<div className="form-row">
<label className="form-label">Ort</label>
<input
type="text"
className="form-input"
value={formData.location || ''}
onChange={(e) => updateFormField('location', e.target.value)}
placeholder="z.B. Sporthalle Musterstadt"
/>
</div>
<div className="form-row">
<label className="form-label">Fokus</label>
<input
type="text"
className="form-input"
value={formData.focus || ''}
onChange={(e) => updateFormField('focus', e.target.value)}
placeholder="z.B. Kata, Kumite"
/>
</div>
<div className="form-row">
<label className="form-label">Status</label>
<select
className="form-input"
value={formData.status || 'active'}
onChange={(e) => updateFormField('status', e.target.value)}
>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
</select>
</div>
</>
)}
{/* Buttons */}
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.5rem' }}>
<button type="submit" className="btn btn-primary" style={{ flex: 1 }}>
{editing ? 'Speichern' : 'Erstellen'}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setShowModal(false)}
>
Abbrechen
</button>
</div>
</form>
</div>
</div>
)}
</div>
</div>
)

View File

@ -68,6 +68,10 @@ export async function listClubs() {
return request('/api/clubs')
}
export async function getClub(id) {
return request(`/api/clubs/${id}`)
}
export async function createClub(data) {
return request('/api/clubs', {
method: 'POST',
@ -75,9 +79,47 @@ export async function createClub(data) {
})
}
export async function listTrainingGroups(clubId) {
export async function updateClub(id, data) {
return request(`/api/clubs/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
})
}
export async function deleteClub(id) {
return request(`/api/clubs/${id}`, { method: 'DELETE' })
}
export async function listDivisions(clubId) {
const query = clubId ? `?club_id=${clubId}` : ''
return request(`/api/groups${query}`)
return request(`/api/divisions${query}`)
}
export async function createDivision(data) {
return request('/api/divisions', {
method: 'POST',
body: JSON.stringify(data)
})
}
export async function updateDivision(id, data) {
return request(`/api/divisions/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
})
}
export async function deleteDivision(id) {
return request(`/api/divisions/${id}`, { method: 'DELETE' })
}
export async function listTrainingGroups(filters = {}) {
const query = new URLSearchParams(filters).toString()
return request(`/api/groups${query ? '?' + query : ''}`)
}
export async function getTrainingGroup(id) {
return request(`/api/groups/${id}`)
}
export async function createTrainingGroup(data) {
@ -87,6 +129,17 @@ export async function createTrainingGroup(data) {
})
}
export async function updateTrainingGroup(id, data) {
return request(`/api/groups/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
})
}
export async function deleteTrainingGroup(id) {
return request(`/api/groups/${id}`, { method: 'DELETE' })
}
// ============================================================================
// Skills & Methods
// ============================================================================
@ -192,9 +245,19 @@ export const api = {
// Clubs & Groups
listClubs,
getClub,
createClub,
updateClub,
deleteClub,
listDivisions,
createDivision,
updateDivision,
deleteDivision,
listTrainingGroups,
getTrainingGroup,
createTrainingGroup,
updateTrainingGroup,
deleteTrainingGroup,
// Skills & Methods
listSkills,