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
|
||||
from routers import auth, profiles
|
||||
from routers import auth, profiles, exercises
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(profiles.router)
|
||||
app.include_router(exercises.router)
|
||||
|
||||
# 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")
|
||||
# ... 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() {
|
||||
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 (
|
||||
<div style={{ padding: '2rem' }}>
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<h1>Übungsverwaltung</h1>
|
||||
|
||||
<div className="card" style={{ marginTop: '1.5rem' }}>
|
||||
<p style={{ color: 'var(--text2)' }}>
|
||||
Übungsverwaltung wird als nächstes implementiert
|
||||
</p>
|
||||
{/* 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>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
||||
<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>
|
||||
</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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user