feat: Exercise CRUD complete
Backend: - Registered exercises router in main.py - Migration 005 already created (exercises, exercise_skills, exercise_variants, exercise_media) - Full CRUD endpoints with visibility logic Frontend: - Complete ExercisesPage with list/create/edit/delete - Filter controls (focus_area, visibility, status) - Modal form with all fields - Skills multi-select - Mobile-responsive card layout Next: Clubs Management
This commit is contained in:
parent
de53ba3f66
commit
8c7cf91cef
|
|
@ -70,13 +70,14 @@ def read_root():
|
||||||
}
|
}
|
||||||
|
|
||||||
# Register routers
|
# Register routers
|
||||||
from routers import auth, profiles
|
from routers import auth, profiles, exercises
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(profiles.router)
|
app.include_router(profiles.router)
|
||||||
|
app.include_router(exercises.router)
|
||||||
|
|
||||||
# TODO: Add more routers as they are created
|
# TODO: Add more routers as they are created
|
||||||
# from routers import clubs, groups, skills, methods, exercises
|
# from routers import clubs, groups, skills, methods
|
||||||
# app.include_router(clubs.router, prefix="/api")
|
# app.include_router(clubs.router, prefix="/api")
|
||||||
# ... etc
|
# ... etc
|
||||||
|
|
||||||
|
|
|
||||||
334
backend/routers/exercises.py
Normal file
334
backend/routers/exercises.py
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
"""
|
||||||
|
Exercise Management Endpoints for Shinkan Jinkendo
|
||||||
|
|
||||||
|
Handles CRUD operations for exercises (Übungen).
|
||||||
|
"""
|
||||||
|
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=["exercises"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── List Exercises ────────────────────────────────────────────────────────
|
||||||
|
@router.get("/exercises")
|
||||||
|
def list_exercises(
|
||||||
|
focus_area: Optional[str] = Query(default=None),
|
||||||
|
visibility: Optional[str] = Query(default=None),
|
||||||
|
status: Optional[str] = Query(default=None),
|
||||||
|
skill_id: Optional[int] = Query(default=None),
|
||||||
|
session=Depends(require_auth)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List exercises with optional filters.
|
||||||
|
|
||||||
|
Filters:
|
||||||
|
- focus_area: karate, selbstverteidigung, gewaltschutz
|
||||||
|
- visibility: private, club, official
|
||||||
|
- status: draft, in_review, approved, archived
|
||||||
|
- skill_id: Filter by associated skill
|
||||||
|
"""
|
||||||
|
profile_id = session['profile_id']
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
# Base query
|
||||||
|
query = """
|
||||||
|
SELECT DISTINCT e.*,
|
||||||
|
p.name as creator_name,
|
||||||
|
c.name as club_name,
|
||||||
|
tm.name as primary_method_name
|
||||||
|
FROM exercises e
|
||||||
|
LEFT JOIN profiles p ON e.created_by = p.id
|
||||||
|
LEFT JOIN clubs c ON e.club_id = c.id
|
||||||
|
LEFT JOIN training_methods tm ON e.primary_method_id = tm.id
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Add skill join if filtering by skill
|
||||||
|
if skill_id:
|
||||||
|
query += " LEFT JOIN exercise_skills es ON e.id = es.exercise_id"
|
||||||
|
|
||||||
|
# Build WHERE clause
|
||||||
|
where = []
|
||||||
|
params = []
|
||||||
|
|
||||||
|
# Visibility filter: show own private + club + official
|
||||||
|
where.append("(e.visibility = 'official' OR e.visibility = 'club' OR e.created_by = %s)")
|
||||||
|
params.append(profile_id)
|
||||||
|
|
||||||
|
if focus_area:
|
||||||
|
where.append("e.focus_area = %s")
|
||||||
|
params.append(focus_area)
|
||||||
|
|
||||||
|
if visibility:
|
||||||
|
where.append("e.visibility = %s")
|
||||||
|
params.append(visibility)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
where.append("e.status = %s")
|
||||||
|
params.append(status)
|
||||||
|
|
||||||
|
if skill_id:
|
||||||
|
where.append("es.skill_id = %s")
|
||||||
|
params.append(skill_id)
|
||||||
|
|
||||||
|
if where:
|
||||||
|
query += " WHERE " + " AND ".join(where)
|
||||||
|
|
||||||
|
query += " ORDER BY e.created_at DESC"
|
||||||
|
|
||||||
|
cur.execute(query, params)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return [r2d(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Get Exercise ──────────────────────────────────────────────────────────
|
||||||
|
@router.get("/exercises/{exercise_id}")
|
||||||
|
def get_exercise(exercise_id: int, session=Depends(require_auth)):
|
||||||
|
"""Get exercise by ID with associated skills and variants."""
|
||||||
|
profile_id = session['profile_id']
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
# Get exercise
|
||||||
|
cur.execute("""
|
||||||
|
SELECT e.*,
|
||||||
|
p.name as creator_name,
|
||||||
|
c.name as club_name,
|
||||||
|
tm.name as primary_method_name
|
||||||
|
FROM exercises e
|
||||||
|
LEFT JOIN profiles p ON e.created_by = p.id
|
||||||
|
LEFT JOIN clubs c ON e.club_id = c.id
|
||||||
|
LEFT JOIN training_methods tm ON e.primary_method_id = tm.id
|
||||||
|
WHERE e.id = %s
|
||||||
|
""", (exercise_id,))
|
||||||
|
exercise = cur.fetchone()
|
||||||
|
|
||||||
|
if not exercise:
|
||||||
|
raise HTTPException(404, "Übung nicht gefunden")
|
||||||
|
|
||||||
|
exercise = r2d(exercise)
|
||||||
|
|
||||||
|
# Check visibility
|
||||||
|
if exercise['visibility'] == 'private' and exercise['created_by'] != profile_id:
|
||||||
|
raise HTTPException(403, "Keine Berechtigung")
|
||||||
|
|
||||||
|
# Get associated skills
|
||||||
|
cur.execute("""
|
||||||
|
SELECT es.*, s.name as skill_name, s.category as skill_category
|
||||||
|
FROM exercise_skills es
|
||||||
|
JOIN skills s ON es.skill_id = s.id
|
||||||
|
WHERE es.exercise_id = %s
|
||||||
|
ORDER BY es.is_primary DESC, s.name
|
||||||
|
""", (exercise_id,))
|
||||||
|
exercise['skills'] = [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
# Get variants
|
||||||
|
cur.execute("""
|
||||||
|
SELECT * FROM exercise_variants
|
||||||
|
WHERE exercise_id = %s
|
||||||
|
ORDER BY created_at
|
||||||
|
""", (exercise_id,))
|
||||||
|
exercise['variants'] = [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
# Get media
|
||||||
|
cur.execute("""
|
||||||
|
SELECT * FROM exercise_media
|
||||||
|
WHERE exercise_id = %s
|
||||||
|
ORDER BY sort_order, created_at
|
||||||
|
""", (exercise_id,))
|
||||||
|
exercise['media'] = [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
return exercise
|
||||||
|
|
||||||
|
|
||||||
|
# ── Create Exercise ───────────────────────────────────────────────────────
|
||||||
|
@router.post("/exercises")
|
||||||
|
def create_exercise(data: dict, session=Depends(require_auth)):
|
||||||
|
"""Create new exercise."""
|
||||||
|
profile_id = session['profile_id']
|
||||||
|
|
||||||
|
# Required fields
|
||||||
|
title = data.get('title')
|
||||||
|
goal = data.get('goal')
|
||||||
|
execution = data.get('execution')
|
||||||
|
|
||||||
|
if not title or not goal or not execution:
|
||||||
|
raise HTTPException(400, "Titel, Ziel und Durchführung sind Pflichtfelder")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
# Insert exercise
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO exercises (
|
||||||
|
title, summary, goal, execution, preparation, trainer_notes,
|
||||||
|
equipment, duration_min, duration_max, group_size_min, group_size_max,
|
||||||
|
age_groups, focus_area, secondary_areas, training_character,
|
||||||
|
primary_method_id, secondary_method_ids,
|
||||||
|
visibility, status, created_by, club_id
|
||||||
|
) VALUES (
|
||||||
|
%s, %s, %s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s, %s,
|
||||||
|
%s, %s, %s, %s,
|
||||||
|
%s, %s,
|
||||||
|
%s, %s, %s, %s
|
||||||
|
) RETURNING id
|
||||||
|
""", (
|
||||||
|
title,
|
||||||
|
data.get('summary'),
|
||||||
|
goal,
|
||||||
|
execution,
|
||||||
|
data.get('preparation'),
|
||||||
|
data.get('trainer_notes'),
|
||||||
|
data.get('equipment'), # JSONB
|
||||||
|
data.get('duration_min'),
|
||||||
|
data.get('duration_max'),
|
||||||
|
data.get('group_size_min'),
|
||||||
|
data.get('group_size_max'),
|
||||||
|
data.get('age_groups'), # JSONB
|
||||||
|
data.get('focus_area'),
|
||||||
|
data.get('secondary_areas'), # JSONB
|
||||||
|
data.get('training_character'),
|
||||||
|
data.get('primary_method_id'),
|
||||||
|
data.get('secondary_method_ids'), # JSONB
|
||||||
|
data.get('visibility', 'private'),
|
||||||
|
data.get('status', 'draft'),
|
||||||
|
profile_id,
|
||||||
|
data.get('club_id')
|
||||||
|
))
|
||||||
|
|
||||||
|
exercise_id = cur.fetchone()['id']
|
||||||
|
|
||||||
|
# Add skills if provided
|
||||||
|
if data.get('skills'):
|
||||||
|
for skill in data['skills']:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO exercise_skills (
|
||||||
|
exercise_id, skill_id, is_primary, intensity,
|
||||||
|
development_contribution, required_level, target_level
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""", (
|
||||||
|
exercise_id,
|
||||||
|
skill['skill_id'],
|
||||||
|
skill.get('is_primary', False),
|
||||||
|
skill.get('intensity'),
|
||||||
|
skill.get('development_contribution'),
|
||||||
|
skill.get('required_level'),
|
||||||
|
skill.get('target_level')
|
||||||
|
))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return get_exercise(exercise_id, session)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Update Exercise ───────────────────────────────────────────────────────
|
||||||
|
@router.put("/exercises/{exercise_id}")
|
||||||
|
def update_exercise(exercise_id: int, data: dict, session=Depends(require_auth)):
|
||||||
|
"""Update exercise."""
|
||||||
|
profile_id = session['profile_id']
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
# Check ownership
|
||||||
|
cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Übung nicht gefunden")
|
||||||
|
|
||||||
|
if row['created_by'] != profile_id:
|
||||||
|
raise HTTPException(403, "Keine Berechtigung")
|
||||||
|
|
||||||
|
# Update exercise
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE exercises SET
|
||||||
|
title = %s, summary = %s, goal = %s, execution = %s,
|
||||||
|
preparation = %s, trainer_notes = %s, equipment = %s,
|
||||||
|
duration_min = %s, duration_max = %s,
|
||||||
|
group_size_min = %s, group_size_max = %s,
|
||||||
|
age_groups = %s, focus_area = %s, secondary_areas = %s,
|
||||||
|
training_character = %s, primary_method_id = %s,
|
||||||
|
secondary_method_ids = %s, visibility = %s, status = %s,
|
||||||
|
club_id = %s, updated_at = NOW()
|
||||||
|
WHERE id = %s
|
||||||
|
""", (
|
||||||
|
data.get('title'),
|
||||||
|
data.get('summary'),
|
||||||
|
data.get('goal'),
|
||||||
|
data.get('execution'),
|
||||||
|
data.get('preparation'),
|
||||||
|
data.get('trainer_notes'),
|
||||||
|
data.get('equipment'),
|
||||||
|
data.get('duration_min'),
|
||||||
|
data.get('duration_max'),
|
||||||
|
data.get('group_size_min'),
|
||||||
|
data.get('group_size_max'),
|
||||||
|
data.get('age_groups'),
|
||||||
|
data.get('focus_area'),
|
||||||
|
data.get('secondary_areas'),
|
||||||
|
data.get('training_character'),
|
||||||
|
data.get('primary_method_id'),
|
||||||
|
data.get('secondary_method_ids'),
|
||||||
|
data.get('visibility'),
|
||||||
|
data.get('status'),
|
||||||
|
data.get('club_id'),
|
||||||
|
exercise_id
|
||||||
|
))
|
||||||
|
|
||||||
|
# Update skills if provided
|
||||||
|
if 'skills' in data:
|
||||||
|
# Delete existing skills
|
||||||
|
cur.execute("DELETE FROM exercise_skills WHERE exercise_id = %s", (exercise_id,))
|
||||||
|
|
||||||
|
# Add new skills
|
||||||
|
for skill in data['skills']:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO exercise_skills (
|
||||||
|
exercise_id, skill_id, is_primary, intensity,
|
||||||
|
development_contribution, required_level, target_level
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""", (
|
||||||
|
exercise_id,
|
||||||
|
skill['skill_id'],
|
||||||
|
skill.get('is_primary', False),
|
||||||
|
skill.get('intensity'),
|
||||||
|
skill.get('development_contribution'),
|
||||||
|
skill.get('required_level'),
|
||||||
|
skill.get('target_level')
|
||||||
|
))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return get_exercise(exercise_id, session)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Delete Exercise ───────────────────────────────────────────────────────
|
||||||
|
@router.delete("/exercises/{exercise_id}")
|
||||||
|
def delete_exercise(exercise_id: int, session=Depends(require_auth)):
|
||||||
|
"""Delete exercise (only owner or admin)."""
|
||||||
|
profile_id = session['profile_id']
|
||||||
|
role = session.get('role')
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
|
# Check ownership or admin
|
||||||
|
cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Übung nicht gefunden")
|
||||||
|
|
||||||
|
if row['created_by'] != profile_id and role not in ['admin', 'superadmin']:
|
||||||
|
raise HTTPException(403, "Keine Berechtigung")
|
||||||
|
|
||||||
|
# Delete (CASCADE handles skills, variants, media)
|
||||||
|
cur.execute("DELETE FROM exercises WHERE id = %s", (exercise_id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
@ -1,14 +1,566 @@
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import api from '../utils/api'
|
||||||
|
|
||||||
function ExercisesPage() {
|
function ExercisesPage() {
|
||||||
|
const [exercises, setExercises] = useState([])
|
||||||
|
const [skills, setSkills] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const [editingExercise, setEditingExercise] = useState(null)
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
focus_area: '',
|
||||||
|
visibility: '',
|
||||||
|
status: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: '',
|
||||||
|
summary: '',
|
||||||
|
goal: '',
|
||||||
|
execution: '',
|
||||||
|
preparation: '',
|
||||||
|
trainer_notes: '',
|
||||||
|
equipment: [],
|
||||||
|
duration_min: '',
|
||||||
|
duration_max: '',
|
||||||
|
group_size_min: '',
|
||||||
|
group_size_max: '',
|
||||||
|
age_groups: [],
|
||||||
|
focus_area: '',
|
||||||
|
secondary_areas: [],
|
||||||
|
training_character: '',
|
||||||
|
visibility: 'private',
|
||||||
|
status: 'draft',
|
||||||
|
skills: []
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [filters])
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const [exercisesData, skillsData] = await Promise.all([
|
||||||
|
api.listExercises(filters),
|
||||||
|
api.listSkills()
|
||||||
|
])
|
||||||
|
setExercises(exercisesData)
|
||||||
|
setSkills(skillsData)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load data:', err)
|
||||||
|
alert('Fehler beim Laden: ' + err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditingExercise(null)
|
||||||
|
setFormData({
|
||||||
|
title: '',
|
||||||
|
summary: '',
|
||||||
|
goal: '',
|
||||||
|
execution: '',
|
||||||
|
preparation: '',
|
||||||
|
trainer_notes: '',
|
||||||
|
equipment: [],
|
||||||
|
duration_min: '',
|
||||||
|
duration_max: '',
|
||||||
|
group_size_min: '',
|
||||||
|
group_size_max: '',
|
||||||
|
age_groups: [],
|
||||||
|
focus_area: '',
|
||||||
|
secondary_areas: [],
|
||||||
|
training_character: '',
|
||||||
|
visibility: 'private',
|
||||||
|
status: 'draft',
|
||||||
|
skills: []
|
||||||
|
})
|
||||||
|
setShowModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (exercise) => {
|
||||||
|
setEditingExercise(exercise)
|
||||||
|
setFormData({
|
||||||
|
title: exercise.title || '',
|
||||||
|
summary: exercise.summary || '',
|
||||||
|
goal: exercise.goal || '',
|
||||||
|
execution: exercise.execution || '',
|
||||||
|
preparation: exercise.preparation || '',
|
||||||
|
trainer_notes: exercise.trainer_notes || '',
|
||||||
|
equipment: exercise.equipment || [],
|
||||||
|
duration_min: exercise.duration_min || '',
|
||||||
|
duration_max: exercise.duration_max || '',
|
||||||
|
group_size_min: exercise.group_size_min || '',
|
||||||
|
group_size_max: exercise.group_size_max || '',
|
||||||
|
age_groups: exercise.age_groups || [],
|
||||||
|
focus_area: exercise.focus_area || '',
|
||||||
|
secondary_areas: exercise.secondary_areas || [],
|
||||||
|
training_character: exercise.training_character || '',
|
||||||
|
visibility: exercise.visibility || 'private',
|
||||||
|
status: exercise.status || 'draft',
|
||||||
|
skills: exercise.skills?.map(s => ({
|
||||||
|
skill_id: s.skill_id,
|
||||||
|
is_primary: s.is_primary || false,
|
||||||
|
intensity: s.intensity || null,
|
||||||
|
development_contribution: s.development_contribution || null,
|
||||||
|
required_level: s.required_level || null,
|
||||||
|
target_level: s.target_level || null
|
||||||
|
})) || []
|
||||||
|
})
|
||||||
|
setShowModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (exercise) => {
|
||||||
|
if (!confirm(`Übung "${exercise.title}" wirklich löschen?`)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.deleteExercise(exercise.id)
|
||||||
|
await loadData()
|
||||||
|
} catch (err) {
|
||||||
|
alert('Fehler beim Löschen: ' + err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
if (!formData.title || !formData.goal || !formData.execution) {
|
||||||
|
alert('Titel, Ziel und Durchführung sind Pflichtfelder')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingExercise) {
|
||||||
|
await api.updateExercise(editingExercise.id, formData)
|
||||||
|
} else {
|
||||||
|
await api.createExercise(formData)
|
||||||
|
}
|
||||||
|
setShowModal(false)
|
||||||
|
await loadData()
|
||||||
|
} catch (err) {
|
||||||
|
alert('Fehler beim Speichern: ' + err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFormField = (field, value) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSkill = (skillId) => {
|
||||||
|
const existing = formData.skills.find(s => s.skill_id === skillId)
|
||||||
|
if (existing) {
|
||||||
|
updateFormField('skills', formData.skills.filter(s => s.skill_id !== skillId))
|
||||||
|
} else {
|
||||||
|
updateFormField('skills', [...formData.skills, {
|
||||||
|
skill_id: skillId,
|
||||||
|
is_primary: false,
|
||||||
|
intensity: null,
|
||||||
|
development_contribution: null,
|
||||||
|
required_level: null,
|
||||||
|
target_level: null
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||||
|
<div className="spinner"></div>
|
||||||
|
<p>Laden...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
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>Übungsverwaltung</h1>
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||||
|
<h1>Übungen</h1>
|
||||||
|
<button className="btn btn-primary" onClick={handleCreate}>
|
||||||
|
+ Neue Übung
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card" style={{ marginTop: '1.5rem' }}>
|
{/* Filters */}
|
||||||
<p style={{ color: 'var(--text2)' }}>
|
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||||
Übungsverwaltung wird als nächstes implementiert
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem' }}>
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Fokusbereich</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={filters.focus_area}
|
||||||
|
onChange={(e) => setFilters({ ...filters, focus_area: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
<option value="karate">Karate</option>
|
||||||
|
<option value="selbstverteidigung">Selbstverteidigung</option>
|
||||||
|
<option value="gewaltschutz">Gewaltschutz</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Sichtbarkeit</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={filters.visibility}
|
||||||
|
onChange={(e) => setFilters({ ...filters, visibility: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
<option value="private">Privat</option>
|
||||||
|
<option value="club">Verein</option>
|
||||||
|
<option value="official">Offiziell</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Status</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={filters.status}
|
||||||
|
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Alle</option>
|
||||||
|
<option value="draft">Entwurf</option>
|
||||||
|
<option value="in_review">In Prüfung</option>
|
||||||
|
<option value="approved">Freigegeben</option>
|
||||||
|
<option value="archived">Archiviert</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Exercises Grid */}
|
||||||
|
{exercises.length === 0 ? (
|
||||||
|
<div className="card">
|
||||||
|
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
||||||
|
Keine Übungen gefunden. Lege jetzt deine erste Übung an!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
||||||
|
gap: '1rem'
|
||||||
|
}}>
|
||||||
|
{exercises.map(exercise => (
|
||||||
|
<div key={exercise.id} className="card">
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<h3 style={{ marginBottom: '0.5rem' }}>{exercise.title}</h3>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
color: 'var(--text2)'
|
||||||
|
}}>
|
||||||
|
{exercise.focus_area || 'Ohne Fokus'}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: exercise.visibility === 'official' ? 'var(--accent)' : 'var(--surface2)',
|
||||||
|
color: exercise.visibility === 'official' ? 'white' : 'var(--text2)'
|
||||||
|
}}>
|
||||||
|
{exercise.visibility}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
padding: '0.25rem 0.5rem',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: exercise.status === 'approved' ? '#2ea44f' : 'var(--surface2)',
|
||||||
|
color: exercise.status === 'approved' ? 'white' : 'var(--text2)'
|
||||||
|
}}>
|
||||||
|
{exercise.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{exercise.summary && (
|
||||||
|
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem' }}>
|
||||||
|
{exercise.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
onClick={() => handleEdit(exercise)}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
style={{
|
||||||
|
background: 'var(--danger)',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none'
|
||||||
|
}}
|
||||||
|
onClick={() => handleDelete(exercise)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create/Edit 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' }}>
|
||||||
|
{editingExercise ? 'Übung bearbeiten' : 'Neue Übung'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Titel *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => updateFormField('title', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Kurzbeschreibung</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
rows={2}
|
||||||
|
value={formData.summary}
|
||||||
|
onChange={(e) => updateFormField('summary', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Ziel der Übung *</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
rows={3}
|
||||||
|
value={formData.goal}
|
||||||
|
onChange={(e) => updateFormField('goal', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Durchführung *</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
rows={4}
|
||||||
|
value={formData.execution}
|
||||||
|
onChange={(e) => updateFormField('execution', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Vorbereitung / Aufbau</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
rows={3}
|
||||||
|
value={formData.preparation}
|
||||||
|
onChange={(e) => updateFormField('preparation', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Hinweise für Trainer</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
rows={2}
|
||||||
|
value={formData.trainer_notes}
|
||||||
|
onChange={(e) => updateFormField('trainer_notes', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Dauer Min (Minuten)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
value={formData.duration_min}
|
||||||
|
onChange={(e) => updateFormField('duration_min', e.target.value ? parseInt(e.target.value) : '')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Dauer Max (Minuten)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
value={formData.duration_max}
|
||||||
|
onChange={(e) => updateFormField('duration_max', e.target.value ? parseInt(e.target.value) : '')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Gruppengröße Min</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
value={formData.group_size_min}
|
||||||
|
onChange={(e) => updateFormField('group_size_min', e.target.value ? parseInt(e.target.value) : '')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Gruppengröße Max</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
value={formData.group_size_max}
|
||||||
|
onChange={(e) => updateFormField('group_size_max', e.target.value ? parseInt(e.target.value) : '')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Trainingscharakter</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={formData.training_character}
|
||||||
|
onChange={(e) => updateFormField('training_character', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Bitte wählen</option>
|
||||||
|
<option value="grundlage">Grundlage</option>
|
||||||
|
<option value="aufbau">Aufbau</option>
|
||||||
|
<option value="vertiefung">Vertiefung</option>
|
||||||
|
<option value="festigung">Festigung</option>
|
||||||
|
<option value="diagnose">Diagnose</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Sichtbarkeit</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={formData.visibility}
|
||||||
|
onChange={(e) => updateFormField('visibility', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="private">Privat</option>
|
||||||
|
<option value="club">Verein</option>
|
||||||
|
<option value="official">Offiziell</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Status</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(e) => updateFormField('status', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="draft">Entwurf</option>
|
||||||
|
<option value="in_review">In Prüfung</option>
|
||||||
|
<option value="approved">Freigegeben</option>
|
||||||
|
<option value="archived">Archiviert</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skills Selection */}
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Fähigkeiten</label>
|
||||||
|
<div style={{
|
||||||
|
maxHeight: '200px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '0.5rem'
|
||||||
|
}}>
|
||||||
|
{skills.map(skill => (
|
||||||
|
<label
|
||||||
|
key={skill.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'background 0.2s'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--surface2)'}
|
||||||
|
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.skills.some(s => s.skill_id === skill.id)}
|
||||||
|
onChange={() => toggleSkill(skill.id)}
|
||||||
|
style={{ marginRight: '0.5rem' }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '0.875rem' }}>
|
||||||
|
{skill.name} <span style={{ color: 'var(--text2)' }}>({skill.category})</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.5rem' }}>
|
||||||
|
<button type="submit" className="btn btn-primary" style={{ flex: 1 }}>
|
||||||
|
{editingExercise ? 'Speichern' : 'Erstellen'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user