feat: enhance role permissions and UI for clubs and training planning
- Updated role permissions to allow trainers and users to create clubs and training groups. - Modified database insertion logic to reflect the correct role for trainers during registration. - Enhanced frontend components to display appropriate messages and buttons based on user roles. - Improved user guidance in the Clubs and Training Planning pages, emphasizing the need for clubs and groups before planning training sessions.
This commit is contained in:
parent
1f2a4595cd
commit
69b26fc928
|
|
@ -245,7 +245,7 @@ async def register(req: RegisterRequest, request: Request):
|
||||||
id, name, email, pin_hash, auth_type, role, tier,
|
id, name, email, pin_hash, auth_type, role, tier,
|
||||||
email_verified, verification_token, verification_expires,
|
email_verified, verification_token, verification_expires,
|
||||||
trial_ends_at, created_at
|
trial_ends_at, created_at
|
||||||
) VALUES (%s, %s, %s, %s, 'email', 'user', 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
|
) VALUES (%s, %s, %s, %s, 'email', 'trainer', 'free', FALSE, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||||
""", (profile_id, name, email, pin_hash, verification_token, verification_expires, trial_ends))
|
""", (profile_id, name, email, pin_hash, verification_token, verification_expires, trial_ends))
|
||||||
|
|
||||||
# Send verification email
|
# Send verification email
|
||||||
|
|
|
||||||
|
|
@ -82,10 +82,10 @@ def get_club(club_id: int, session=Depends(require_auth)):
|
||||||
# ── Create Club ───────────────────────────────────────────────────────
|
# ── Create Club ───────────────────────────────────────────────────────
|
||||||
@router.post("/clubs")
|
@router.post("/clubs")
|
||||||
def create_club(data: dict, session=Depends(require_auth)):
|
def create_club(data: dict, session=Depends(require_auth)):
|
||||||
"""Create new club (admin only)."""
|
"""Create new club (Admin oder Trainer — MVP ohne separates Vereins-Onboarding)."""
|
||||||
role = session.get('role')
|
role = session.get('role')
|
||||||
if role not in ['admin', 'superadmin']:
|
if role not in ['admin', 'superadmin', 'trainer', 'user']:
|
||||||
raise HTTPException(403, "Nur Admins dürfen Vereine erstellen")
|
raise HTTPException(403, "Keine Berechtigung, Vereine anzulegen")
|
||||||
|
|
||||||
name = data.get('name')
|
name = data.get('name')
|
||||||
if not name:
|
if not name:
|
||||||
|
|
@ -395,10 +395,11 @@ def get_training_group(group_id: int, session=Depends(require_auth)):
|
||||||
# ── Create Training Group ─────────────────────────────────────────────
|
# ── Create Training Group ─────────────────────────────────────────────
|
||||||
@router.post("/groups")
|
@router.post("/groups")
|
||||||
def create_training_group(data: dict, session=Depends(require_auth)):
|
def create_training_group(data: dict, session=Depends(require_auth)):
|
||||||
"""Create new training group (admin or trainer)."""
|
"""Create new training group (admin, trainer oder normaler Nutzer mit Vereinsbezug)."""
|
||||||
|
profile_id = session["profile_id"]
|
||||||
role = session.get('role')
|
role = session.get('role')
|
||||||
if role not in ['admin', 'superadmin', 'trainer']:
|
if role not in ['admin', 'superadmin', 'trainer', 'user']:
|
||||||
raise HTTPException(403, "Nur Admins und Trainer dürfen Gruppen erstellen")
|
raise HTTPException(403, "Keine Berechtigung, Trainingsgruppen anzulegen")
|
||||||
|
|
||||||
club_id = data.get('club_id')
|
club_id = data.get('club_id')
|
||||||
name = data.get('name')
|
name = data.get('name')
|
||||||
|
|
@ -406,6 +407,10 @@ def create_training_group(data: dict, session=Depends(require_auth)):
|
||||||
if not club_id or not name:
|
if not club_id or not name:
|
||||||
raise HTTPException(400, "club_id und name sind Pflichtfelder")
|
raise HTTPException(400, "club_id und name sind Pflichtfelder")
|
||||||
|
|
||||||
|
trainer_id = data.get('trainer_id')
|
||||||
|
if trainer_id in (None, "", 0) and role in ("trainer", "user"):
|
||||||
|
trainer_id = profile_id
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
|
@ -436,7 +441,7 @@ def create_training_group(data: dict, session=Depends(require_auth)):
|
||||||
data.get('time_start'),
|
data.get('time_start'),
|
||||||
data.get('time_end'),
|
data.get('time_end'),
|
||||||
data.get('location'),
|
data.get('location'),
|
||||||
data.get('trainer_id'),
|
trainer_id,
|
||||||
data.get('co_trainer_ids'),
|
data.get('co_trainer_ids'),
|
||||||
data.get('status', 'active')
|
data.get('status', 'active')
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,11 @@ from auth import require_auth
|
||||||
router = APIRouter(prefix="/api", tags=["training_planning"])
|
router = APIRouter(prefix="/api", tags=["training_planning"])
|
||||||
|
|
||||||
|
|
||||||
|
def _has_planning_role(role: Optional[str]) -> bool:
|
||||||
|
"""Kann Trainingseinheiten/Vorlagen anlegen (bis Governance: auch einfacher Account)."""
|
||||||
|
return role in ("admin", "superadmin", "trainer", "user")
|
||||||
|
|
||||||
|
|
||||||
def _optional_positive_int(val, field_name: str) -> Optional[int]:
|
def _optional_positive_int(val, field_name: str) -> Optional[int]:
|
||||||
if val is None or val == "":
|
if val is None or val == "":
|
||||||
return None
|
return None
|
||||||
|
|
@ -50,7 +55,7 @@ def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str)
|
||||||
if not group:
|
if not group:
|
||||||
raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden")
|
raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden")
|
||||||
co_trainers = group["co_trainer_ids"] or []
|
co_trainers = group["co_trainer_ids"] or []
|
||||||
if role not in ["admin", "superadmin", "trainer"]:
|
if not _has_planning_role(role):
|
||||||
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Trainingseinheiten erstellen")
|
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Trainingseinheiten erstellen")
|
||||||
if role not in ["admin", "superadmin"]:
|
if role not in ["admin", "superadmin"]:
|
||||||
if group["trainer_id"] != profile_id and profile_id not in co_trainers:
|
if group["trainer_id"] != profile_id and profile_id not in co_trainers:
|
||||||
|
|
@ -381,7 +386,7 @@ def get_training_plan_template(template_id: int, session=Depends(require_auth)):
|
||||||
def create_training_plan_template(data: dict, session=Depends(require_auth)):
|
def create_training_plan_template(data: dict, session=Depends(require_auth)):
|
||||||
profile_id = session["profile_id"]
|
profile_id = session["profile_id"]
|
||||||
role = session.get("role")
|
role = session.get("role")
|
||||||
if role not in ["admin", "superadmin", "trainer"]:
|
if not _has_planning_role(role):
|
||||||
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Vorlagen anlegen")
|
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Vorlagen anlegen")
|
||||||
name = (data.get("name") or "").strip()
|
name = (data.get("name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
|
|
@ -787,7 +792,7 @@ def quick_create_training_unit(data: dict, session=Depends(require_auth)):
|
||||||
role = session.get("role")
|
role = session.get("role")
|
||||||
co_trainers = group["co_trainer_ids"] or []
|
co_trainers = group["co_trainer_ids"] or []
|
||||||
|
|
||||||
if role not in ["admin", "superadmin", "trainer"]:
|
if not _has_planning_role(role):
|
||||||
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Trainingseinheiten erstellen")
|
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Trainingseinheiten erstellen")
|
||||||
|
|
||||||
if role not in ["admin", "superadmin"]:
|
if role not in ["admin", "superadmin"]:
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ function ClubsPage() {
|
||||||
|
|
||||||
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
||||||
const isTrainer = user?.role === 'trainer' || isAdmin
|
const isTrainer = user?.role === 'trainer' || isAdmin
|
||||||
|
const canCreateClub = ['admin', 'superadmin', 'trainer', 'user'].includes(user?.role)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
|
|
@ -61,7 +62,7 @@ function ClubsPage() {
|
||||||
time_start: '',
|
time_start: '',
|
||||||
time_end: '',
|
time_end: '',
|
||||||
location: '',
|
location: '',
|
||||||
trainer_id: '',
|
trainer_id: user?.id ?? '',
|
||||||
co_trainer_ids: [],
|
co_trainer_ids: [],
|
||||||
status: 'active'
|
status: 'active'
|
||||||
})
|
})
|
||||||
|
|
@ -144,7 +145,11 @@ function ClubsPage() {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem' }}>
|
<div style={{ padding: '2rem' }}>
|
||||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
<h1 style={{ marginBottom: '1.5rem' }}>Vereinsverwaltung</h1>
|
<h1 style={{ marginBottom: '0.75rem' }}>Vereinsverwaltung</h1>
|
||||||
|
<p style={{ color: 'var(--text2)', marginBottom: '1.35rem', maxWidth: '46rem', lineHeight: 1.55 }}>
|
||||||
|
Für die Trainingsplanung wird mindestens ein <strong>Verein</strong> und eine <strong>Trainingsgruppe</strong> gebraucht.
|
||||||
|
Sparten sind optional — typische Eckdaten einer Gruppe (Wochentag, Zeit, Ort) kannst du schrittweise eintragen.
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|
@ -179,7 +184,7 @@ function ClubsPage() {
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||||
<h2>Vereine</h2>
|
<h2>Vereine</h2>
|
||||||
{isAdmin && (
|
{canCreateClub && (
|
||||||
<button className="btn btn-primary" onClick={() => handleCreate('club')}>
|
<button className="btn btn-primary" onClick={() => handleCreate('club')}>
|
||||||
+ Neuer Verein
|
+ Neuer Verein
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -188,9 +193,17 @@ function ClubsPage() {
|
||||||
|
|
||||||
{clubs.length === 0 ? (
|
{clubs.length === 0 ? (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
<p style={{ color: 'var(--text2)', textAlign: 'center', marginBottom: canCreateClub ? '1rem' : 0 }}>
|
||||||
Keine Vereine gefunden
|
Noch kein Verein angelegt.
|
||||||
|
{canCreateClub
|
||||||
|
? ' Nutze „+ Neuer Verein“ — ein Name reicht zum Start.'
|
||||||
|
: ' Bitte einen Administrator oder Support um Anlage.'}
|
||||||
</p>
|
</p>
|
||||||
|
{canCreateClub && (
|
||||||
|
<p style={{ color: 'var(--text2)', textAlign: 'center', fontSize: '0.9rem' }}>
|
||||||
|
Danach im Tab Trainingsgruppen eine Gruppe diesem Verein zuordnen; Details sind optional.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||||
|
|
@ -328,7 +341,7 @@ function ClubsPage() {
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
||||||
<h2>Trainingsgruppen</h2>
|
<h2>Trainingsgruppen</h2>
|
||||||
{isTrainer && (
|
{canCreateClub && (
|
||||||
<button className="btn btn-primary" onClick={() => handleCreate('group')}>
|
<button className="btn btn-primary" onClick={() => handleCreate('group')}>
|
||||||
+ Neue Gruppe
|
+ Neue Gruppe
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
|
|
@ -481,7 +482,32 @@ function TrainingPlanningPage() {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem' }}>
|
<div style={{ padding: '2rem' }}>
|
||||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
<h1 style={{ marginBottom: '1.5rem' }}>Trainingsplanung</h1>
|
<h1 style={{ marginBottom: '0.35rem' }}>Trainingsplanung</h1>
|
||||||
|
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', marginBottom: '1.25rem' }}>
|
||||||
|
Wähle eine Trainingsgruppe, lege dann Termine mit Inhalt (Abschnitte und Übungen) an — ein Plan entsteht aus einer oder mehreren{' '}
|
||||||
|
<strong>Trainingseinheiten</strong> im gewählten Zeitraum.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!loading && groups.length === 0 && (
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
marginBottom: '1.25rem',
|
||||||
|
borderLeft: '4px solid var(--accent)',
|
||||||
|
padding: '1rem 1.25rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ fontSize: '1.1rem', marginBottom: '0.5rem' }}>Erst Verein & Gruppe anlegen</h2>
|
||||||
|
<p style={{ color: 'var(--text2)', marginBottom: '0.85rem', lineHeight: 1.5 }}>
|
||||||
|
Ohne Trainingsgruppe kann hier nichts gebucht werden. Unter <strong>Vereine</strong> legst du einen Verein an
|
||||||
|
(kurzer Name genügt), optional eine Sparte, dann eine <strong>Trainingsgruppe</strong>. Wochentage, feste Zeiten oder
|
||||||
|
Eigenschaften sind optional und kannst du später ergänzen.
|
||||||
|
</p>
|
||||||
|
<Link to="/clubs" className="btn btn-primary" style={{ textDecoration: 'none' }}>
|
||||||
|
Zu Vereinen & Trainingsgruppen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||||
<div
|
<div
|
||||||
|
|
@ -545,56 +571,93 @@ function TrainingPlanningPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedGroupId && (
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
marginTop: '1.25rem',
|
||||||
flexWrap: 'wrap',
|
paddingTop: '1rem',
|
||||||
gap: '0.5rem',
|
borderTop: '1px solid var(--border, rgba(0,0,0,0.08))'
|
||||||
marginBottom: '1.5rem',
|
|
||||||
alignItems: 'center'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button className="btn btn-primary" onClick={handleCreate}>
|
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', marginBottom: '0.75rem' }}>
|
||||||
+ Neue Trainingseinheit
|
<strong>Plan anlegen:</strong> neue Trainingseinheit mit Datum, Zeit und Ablauf — oder schnell nur mit Datum (Zeiten aus der Gruppe).
|
||||||
</button>
|
{!selectedGroupId && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
<span style={{ display: 'block', marginTop: '0.35rem' }}>
|
||||||
<label className="form-label" style={{ marginBottom: 0 }}>
|
Wähle oben eine Trainingsgruppe, um die Schaltflächen zu aktivieren.
|
||||||
Schnell (+ optional Vorlage):
|
</span>
|
||||||
</label>
|
)}
|
||||||
<select
|
{groups.length === 0 && (
|
||||||
className="form-input"
|
<span style={{ display: 'block', marginTop: '0.35rem' }}>
|
||||||
style={{ minWidth: '200px', marginBottom: 0 }}
|
Es gibt noch keine aktive Trainingsgruppe — unter{' '}
|
||||||
value={quickTemplateId}
|
<Link to="/clubs">
|
||||||
onChange={(e) => setQuickTemplateId(e.target.value)}
|
Vereine
|
||||||
|
</Link>{' '}
|
||||||
|
anlegen oder aktivieren.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '0.5rem',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!selectedGroupId}
|
||||||
|
title={!selectedGroupId ? 'Zuerst eine Trainingsgruppe wählen' : undefined}
|
||||||
|
onClick={handleCreate}
|
||||||
>
|
>
|
||||||
<option value="">Standard (leer)</option>
|
+ Neue Trainingseinheit planen
|
||||||
{planTemplates.map((t) => (
|
|
||||||
<option key={t.id} value={String(t.id)}>
|
|
||||||
{t.name}
|
|
||||||
{typeof t.sections_count === 'number' ? ` (${t.sections_count} Abschn.)` : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button className="btn btn-secondary" onClick={handleQuickCreate}>
|
|
||||||
Schnell erstellen
|
|
||||||
</button>
|
</button>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<label className="form-label" style={{ marginBottom: 0 }}>
|
||||||
|
Schnell (+ optional Vorlage):
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
style={{ minWidth: '180px', marginBottom: 0 }}
|
||||||
|
value={quickTemplateId}
|
||||||
|
onChange={(e) => setQuickTemplateId(e.target.value)}
|
||||||
|
disabled={!selectedGroupId}
|
||||||
|
title={!selectedGroupId ? 'Zuerst Trainingsgruppe wählen' : undefined}
|
||||||
|
>
|
||||||
|
<option value="">Standard (leer)</option>
|
||||||
|
{planTemplates.map((t) => (
|
||||||
|
<option key={t.id} value={String(t.id)}>
|
||||||
|
{t.name}
|
||||||
|
{typeof t.sections_count === 'number' ? ` (${t.sections_count} Abschn.)` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={!selectedGroupId}
|
||||||
|
title={!selectedGroupId ? 'Zuerst eine Trainingsgruppe wählen' : undefined}
|
||||||
|
onClick={handleQuickCreate}
|
||||||
|
>
|
||||||
|
Schnell erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{!selectedGroupId ? (
|
{!selectedGroupId ? (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
||||||
Bitte wähle eine Trainingsgruppe aus
|
Wähle oben eine Trainingsgruppe — danach kannst du mit <strong>„Neue Trainingseinheit planen“</strong> starten.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : units.length === 0 ? (
|
) : units.length === 0 ? (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
||||||
Keine Trainingseinheiten im gewählten Zeitraum
|
Keine Trainingseinheiten in diesem Zeitraum. Nutze oben <strong>„Neue Trainingseinheit planen“</strong> oder{' '}
|
||||||
|
<strong>„Schnell erstellen“</strong>, um den ersten Termin anzulegen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user