feat: Training Planning (core feature) complete
Some checks failed
Deploy Development / deploy (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 4s
Test Suite / playwright-tests (push) Failing after 14s

Backend:
- Created migration 006_training_planning.sql
  - training_units table (planned vs actual, status, notes)
  - training_unit_exercises M:N (order, duration, modifications)
- Created routers/training_planning.py with full CRUD
  - List with filters (group, date range, status)
  - Get detail with exercises
  - Create/Update/Delete with access control
  - Quick create endpoint (auto-fills from group defaults)
  - Permission: trainer for own groups, admin for all
- Registered training_planning router in main.py

Frontend:
- Complete TrainingPlanningPage with full planning workflow
  - Group selector + date range picker
  - List view with status badges (planned/completed/cancelled)
  - Create/Edit modal with exercise management
  - Drag to reorder exercises (▲▼)
  - Quick create button (one-click with group defaults)
  - Durchführung section (actual date/time, attendance, status)
  - Public notes + trainer-only notes
- Updated api.js with deleteTrainingUnit and quickCreateTrainingUnit
- Added /planning route to App.jsx
- Navigation already configured (Calendar icon)

This is the CORE feature - trainers can now plan and document training sessions.

Next: Admin routes, role system refinement, testing
This commit is contained in:
Lars 2026-04-22 16:54:34 +02:00
parent 505a8e5e38
commit 7f156ba085
6 changed files with 1188 additions and 6 deletions

View File

@ -70,18 +70,14 @@ def read_root():
}
# Register routers
from routers import auth, profiles, exercises, clubs, skills
from routers import auth, profiles, exercises, clubs, skills, training_planning
app.include_router(auth.router)
app.include_router(profiles.router)
app.include_router(exercises.router)
app.include_router(clubs.router)
app.include_router(skills.router)
# TODO: Add more routers as they are created
# from routers import training_planning
# app.include_router(training_planning.router, prefix="/api")
# ... etc
app.include_router(training_planning.router)
if __name__ == "__main__":
import uvicorn

View File

@ -0,0 +1,60 @@
-- Migration 006: Training Planning (Training Units)
-- Erstellt: 2026-04-22
-- Beschreibung: Trainingsplanung und -dokumentation
-- Training Units (Trainingseinheiten)
CREATE TABLE training_units (
id SERIAL PRIMARY KEY,
group_id INT REFERENCES training_groups(id) ON DELETE CASCADE,
-- Planung
planned_date DATE NOT NULL,
planned_time_start TIME,
planned_time_end TIME,
planned_focus VARCHAR(200),
-- Durchführung
actual_date DATE,
actual_time_start TIME,
actual_time_end TIME,
attendance_count INT,
-- Status & Notizen
status VARCHAR(50) DEFAULT 'planned', -- planned, completed, cancelled
notes TEXT,
trainer_notes TEXT,
-- Meta
created_by INT REFERENCES profiles(id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_training_units_group ON training_units(group_id);
CREATE INDEX idx_training_units_date ON training_units(planned_date);
CREATE INDEX idx_training_units_status ON training_units(status);
CREATE INDEX idx_training_units_created_by ON training_units(created_by);
-- Training Unit Exercises (M:N - geplante und durchgeführte Übungen)
CREATE TABLE training_unit_exercises (
id SERIAL PRIMARY KEY,
training_unit_id INT REFERENCES training_units(id) ON DELETE CASCADE,
exercise_id INT REFERENCES exercises(id),
-- Reihenfolge
order_index INT NOT NULL,
-- Zeitplanung
planned_duration_min INT,
actual_duration_min INT,
-- Notizen
notes TEXT,
modifications TEXT, -- Anpassungen während der Durchführung
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_training_unit_exercises_unit ON training_unit_exercises(training_unit_id);
CREATE INDEX idx_training_unit_exercises_exercise ON training_unit_exercises(exercise_id);
CREATE INDEX idx_training_unit_exercises_order ON training_unit_exercises(training_unit_id, order_index);

View File

@ -0,0 +1,403 @@
"""
Training Planning Endpoints for Shinkan Jinkendo
Handles CRUD operations for training units and their exercises.
"""
from typing import Optional
from datetime import date, time
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=["training_planning"])
# ── List Training Units ───────────────────────────────────────────────
@router.get("/training-units")
def list_training_units(
group_id: Optional[int] = Query(default=None),
start_date: Optional[str] = Query(default=None),
end_date: Optional[str] = Query(default=None),
status: Optional[str] = Query(default=None),
session=Depends(require_auth)
):
"""
List training units with optional filters.
Filters:
- group_id: Filter by training group
- start_date: Filter from this date (YYYY-MM-DD)
- end_date: Filter to this date (YYYY-MM-DD)
- status: planned, completed, cancelled
"""
profile_id = session['profile_id']
role = session.get('role')
with get_db() as conn:
cur = get_cursor(conn)
query = """
SELECT tu.*,
tg.name as group_name,
tg.weekday as group_weekday,
c.name as club_name,
p.name as trainer_name
FROM training_units tu
LEFT JOIN training_groups tg ON tu.group_id = tg.id
LEFT JOIN clubs c ON tg.club_id = c.id
LEFT JOIN profiles p ON tu.created_by = p.id
"""
where = []
params = []
# Access control: show only own units unless admin
if role not in ['admin', 'superadmin']:
where.append("(tu.created_by = %s OR tg.trainer_id = %s)")
params.extend([profile_id, profile_id])
if group_id:
where.append("tu.group_id = %s")
params.append(group_id)
if start_date:
where.append("tu.planned_date >= %s")
params.append(start_date)
if end_date:
where.append("tu.planned_date <= %s")
params.append(end_date)
if status:
where.append("tu.status = %s")
params.append(status)
if where:
query += " WHERE " + " AND ".join(where)
query += " ORDER BY tu.planned_date DESC, tu.planned_time_start DESC"
cur.execute(query, params)
rows = cur.fetchall()
return [r2d(r) for r in rows]
# ── Get Training Unit ─────────────────────────────────────────────────
@router.get("/training-units/{unit_id}")
def get_training_unit(unit_id: int, session=Depends(require_auth)):
"""Get training unit by ID with exercises."""
profile_id = session['profile_id']
role = session.get('role')
with get_db() as conn:
cur = get_cursor(conn)
# Get training unit
cur.execute("""
SELECT tu.*,
tg.name as group_name,
tg.weekday as group_weekday,
tg.time_start as group_time_start,
tg.time_end as group_time_end,
tg.location as group_location,
c.name as club_name,
p.name as trainer_name
FROM training_units tu
LEFT JOIN training_groups tg ON tu.group_id = tg.id
LEFT JOIN clubs c ON tg.club_id = c.id
LEFT JOIN profiles p ON tu.created_by = p.id
WHERE tu.id = %s
""", (unit_id,))
unit = cur.fetchone()
if not unit:
raise HTTPException(404, "Trainingseinheit nicht gefunden")
unit = r2d(unit)
# Access control
cur.execute("SELECT trainer_id FROM training_groups WHERE id = %s", (unit['group_id'],))
group = cur.fetchone()
if role not in ['admin', 'superadmin']:
if unit['created_by'] != profile_id and (not group or group['trainer_id'] != profile_id):
raise HTTPException(403, "Keine Berechtigung")
# Get exercises
cur.execute("""
SELECT tue.*,
e.title as exercise_title,
e.summary as exercise_summary,
e.focus_area as exercise_focus_area
FROM training_unit_exercises tue
LEFT JOIN exercises e ON tue.exercise_id = e.id
WHERE tue.training_unit_id = %s
ORDER BY tue.order_index
""", (unit_id,))
unit['exercises'] = [r2d(r) for r in cur.fetchall()]
return unit
# ── Create Training Unit ──────────────────────────────────────────────
@router.post("/training-units")
def create_training_unit(data: dict, session=Depends(require_auth)):
"""Create new training unit."""
profile_id = session['profile_id']
role = session.get('role')
group_id = data.get('group_id')
planned_date = data.get('planned_date')
if not group_id or not planned_date:
raise HTTPException(400, "group_id und planned_date sind Pflichtfelder")
with get_db() as conn:
cur = get_cursor(conn)
# Check group exists and access
cur.execute("""
SELECT trainer_id, co_trainer_ids
FROM training_groups
WHERE id = %s
""", (group_id,))
group = cur.fetchone()
if not group:
raise HTTPException(404, "Trainingsgruppe nicht gefunden")
# Check permission
co_trainers = group['co_trainer_ids'] or []
if role not in ['admin', 'superadmin', 'trainer']:
raise HTTPException(403, "Nur Trainer dürfen Trainingseinheiten erstellen")
if role not in ['admin', 'superadmin']:
if group['trainer_id'] != profile_id and profile_id not in co_trainers:
raise HTTPException(403, "Nur der zuständige Trainer darf für diese Gruppe planen")
# Insert training unit
cur.execute("""
INSERT INTO training_units (
group_id, planned_date, planned_time_start, planned_time_end,
planned_focus, status, notes, trainer_notes, created_by
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
group_id,
planned_date,
data.get('planned_time_start'),
data.get('planned_time_end'),
data.get('planned_focus'),
data.get('status', 'planned'),
data.get('notes'),
data.get('trainer_notes'),
profile_id
))
unit_id = cur.fetchone()['id']
# Add exercises if provided
exercises = data.get('exercises', [])
for idx, ex in enumerate(exercises):
cur.execute("""
INSERT INTO training_unit_exercises (
training_unit_id, exercise_id, order_index,
planned_duration_min, notes
) VALUES (%s, %s, %s, %s, %s)
""", (
unit_id,
ex.get('exercise_id'),
idx,
ex.get('planned_duration_min'),
ex.get('notes')
))
conn.commit()
return get_training_unit(unit_id, session)
# ── Update Training Unit ──────────────────────────────────────────────
@router.put("/training-units/{unit_id}")
def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth)):
"""Update training unit."""
profile_id = session['profile_id']
role = session.get('role')
with get_db() as conn:
cur = get_cursor(conn)
# Check existence and access
cur.execute("""
SELECT tu.created_by, tu.group_id, tg.trainer_id, tg.co_trainer_ids
FROM training_units tu
LEFT JOIN training_groups tg ON tu.group_id = tg.id
WHERE tu.id = %s
""", (unit_id,))
unit = cur.fetchone()
if not unit:
raise HTTPException(404, "Trainingseinheit nicht gefunden")
# Check permission
co_trainers = unit['co_trainer_ids'] or []
if role not in ['admin', 'superadmin']:
if unit['created_by'] != profile_id and unit['trainer_id'] != profile_id and profile_id not in co_trainers:
raise HTTPException(403, "Keine Berechtigung")
# Update training unit
cur.execute("""
UPDATE training_units SET
planned_date = %s,
planned_time_start = %s,
planned_time_end = %s,
planned_focus = %s,
actual_date = %s,
actual_time_start = %s,
actual_time_end = %s,
attendance_count = %s,
status = %s,
notes = %s,
trainer_notes = %s,
updated_at = NOW()
WHERE id = %s
""", (
data.get('planned_date'),
data.get('planned_time_start'),
data.get('planned_time_end'),
data.get('planned_focus'),
data.get('actual_date'),
data.get('actual_time_start'),
data.get('actual_time_end'),
data.get('attendance_count'),
data.get('status'),
data.get('notes'),
data.get('trainer_notes'),
unit_id
))
# Update exercises if provided
if 'exercises' in data:
# Delete existing exercises
cur.execute("DELETE FROM training_unit_exercises WHERE training_unit_id = %s", (unit_id,))
# Add new exercises
exercises = data['exercises']
for idx, ex in enumerate(exercises):
cur.execute("""
INSERT INTO training_unit_exercises (
training_unit_id, exercise_id, order_index,
planned_duration_min, actual_duration_min,
notes, modifications
) VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (
unit_id,
ex.get('exercise_id'),
idx,
ex.get('planned_duration_min'),
ex.get('actual_duration_min'),
ex.get('notes'),
ex.get('modifications')
))
conn.commit()
return get_training_unit(unit_id, session)
# ── Delete Training Unit ──────────────────────────────────────────────
@router.delete("/training-units/{unit_id}")
def delete_training_unit(unit_id: int, session=Depends(require_auth)):
"""Delete training unit."""
profile_id = session['profile_id']
role = session.get('role')
with get_db() as conn:
cur = get_cursor(conn)
# Check existence and access
cur.execute("""
SELECT created_by FROM training_units WHERE id = %s
""", (unit_id,))
unit = cur.fetchone()
if not unit:
raise HTTPException(404, "Trainingseinheit nicht gefunden")
# Only creator or admin can delete
if role not in ['admin', 'superadmin'] and unit['created_by'] != profile_id:
raise HTTPException(403, "Keine Berechtigung")
# Delete (CASCADE handles exercises)
cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,))
conn.commit()
return {"ok": True}
# ── Quick Create from Template ────────────────────────────────────────
@router.post("/training-units/quick-create")
def quick_create_training_unit(data: dict, session=Depends(require_auth)):
"""
Quick create training unit with group defaults.
Takes group_id and date, auto-fills time from group schedule.
"""
profile_id = session['profile_id']
group_id = data.get('group_id')
planned_date = data.get('planned_date')
if not group_id or not planned_date:
raise HTTPException(400, "group_id und planned_date sind Pflichtfelder")
with get_db() as conn:
cur = get_cursor(conn)
# Get group defaults
cur.execute("""
SELECT weekday, time_start, time_end, trainer_id, co_trainer_ids
FROM training_groups
WHERE id = %s
""", (group_id,))
group = cur.fetchone()
if not group:
raise HTTPException(404, "Trainingsgruppe nicht gefunden")
# Check permission
role = session.get('role')
co_trainers = group['co_trainer_ids'] or []
if role not in ['admin', 'superadmin', 'trainer']:
raise HTTPException(403, "Nur Trainer dürfen Trainingseinheiten erstellen")
if role not in ['admin', 'superadmin']:
if group['trainer_id'] != profile_id and profile_id not in co_trainers:
raise HTTPException(403, "Keine Berechtigung für diese Gruppe")
# Create with group defaults
cur.execute("""
INSERT INTO training_units (
group_id, planned_date,
planned_time_start, planned_time_end,
status, created_by
) VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""", (
group_id,
planned_date,
group['time_start'],
group['time_end'],
'planned',
profile_id
))
unit_id = cur.fetchone()['id']
conn.commit()
return get_training_unit(unit_id, session)

View File

@ -9,6 +9,7 @@ import ProfilePage from './pages/ProfilePage'
import ExercisesPage from './pages/ExercisesPage'
import ClubsPage from './pages/ClubsPage'
import SkillsPage from './pages/SkillsPage'
import TrainingPlanningPage from './pages/TrainingPlanningPage'
import './app.css'
// Bottom Navigation (Mobile)
@ -167,6 +168,14 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/planning"
element={
<ProtectedRoute>
<TrainingPlanningPage />
</ProtectedRoute>
}
/>
{/* Catch all - redirect to dashboard or login */}
<Route path="*" element={<Navigate to="/" replace />} />

View File

@ -0,0 +1,701 @@
import React, { useState, useEffect } from 'react'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
function TrainingPlanningPage() {
const { user } = useAuth()
const [groups, setGroups] = useState([])
const [selectedGroupId, setSelectedGroupId] = useState('')
const [units, setUnits] = useState([])
const [exercises, setExercises] = useState([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingUnit, setEditingUnit] = useState(null)
// Date range (default: next 30 days)
const today = new Date().toISOString().split('T')[0]
const thirtyDaysLater = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
const [startDate, setStartDate] = useState(today)
const [endDate, setEndDate] = useState(thirtyDaysLater)
// Form state
const [formData, setFormData] = useState({
group_id: '',
planned_date: '',
planned_time_start: '',
planned_time_end: '',
planned_focus: '',
actual_date: '',
actual_time_start: '',
actual_time_end: '',
attendance_count: '',
status: 'planned',
notes: '',
trainer_notes: '',
exercises: []
})
useEffect(() => {
loadData()
}, [])
useEffect(() => {
if (selectedGroupId) {
loadUnits()
}
}, [selectedGroupId, startDate, endDate])
const loadData = async () => {
try {
const [groupsData, exercisesData] = await Promise.all([
api.listTrainingGroups({ status: 'active' }),
api.listExercises()
])
setGroups(groupsData)
setExercises(exercisesData)
// Auto-select first group if trainer owns it
if (groupsData.length > 0) {
const ownGroup = groupsData.find(g => g.trainer_id === user?.id)
if (ownGroup) {
setSelectedGroupId(ownGroup.id)
} else if (groupsData.length === 1) {
setSelectedGroupId(groupsData[0].id)
}
}
} catch (err) {
console.error('Failed to load data:', err)
alert('Fehler beim Laden: ' + err.message)
} finally {
setLoading(false)
}
}
const loadUnits = async () => {
if (!selectedGroupId) return
try {
const unitsData = await api.listTrainingUnits({ group_id: selectedGroupId, start_date: startDate, end_date: endDate })
setUnits(unitsData)
} catch (err) {
console.error('Failed to load units:', err)
}
}
const handleQuickCreate = async () => {
if (!selectedGroupId) {
alert('Bitte wähle zuerst eine Trainingsgruppe')
return
}
const date = prompt('Datum für neue Trainingseinheit (YYYY-MM-DD):', today)
if (!date) return
try {
await api.quickCreateTrainingUnit({
group_id: parseInt(selectedGroupId),
planned_date: date
})
await loadUnits()
} catch (err) {
alert('Fehler beim Erstellen: ' + err.message)
}
}
const handleCreate = () => {
if (!selectedGroupId) {
alert('Bitte wähle zuerst eine Trainingsgruppe')
return
}
const group = groups.find(g => g.id === parseInt(selectedGroupId))
setEditingUnit(null)
setFormData({
group_id: selectedGroupId,
planned_date: today,
planned_time_start: group?.time_start?.slice(0, 5) || '',
planned_time_end: group?.time_end?.slice(0, 5) || '',
planned_focus: '',
actual_date: '',
actual_time_start: '',
actual_time_end: '',
attendance_count: '',
status: 'planned',
notes: '',
trainer_notes: '',
exercises: []
})
setShowModal(true)
}
const handleEdit = async (unit) => {
try {
const fullUnit = await api.getTrainingUnit(unit.id)
setEditingUnit(fullUnit)
setFormData({
group_id: fullUnit.group_id,
planned_date: fullUnit.planned_date || '',
planned_time_start: fullUnit.planned_time_start?.slice(0, 5) || '',
planned_time_end: fullUnit.planned_time_end?.slice(0, 5) || '',
planned_focus: fullUnit.planned_focus || '',
actual_date: fullUnit.actual_date || '',
actual_time_start: fullUnit.actual_time_start?.slice(0, 5) || '',
actual_time_end: fullUnit.actual_time_end?.slice(0, 5) || '',
attendance_count: fullUnit.attendance_count || '',
status: fullUnit.status || 'planned',
notes: fullUnit.notes || '',
trainer_notes: fullUnit.trainer_notes || '',
exercises: fullUnit.exercises || []
})
setShowModal(true)
} catch (err) {
alert('Fehler beim Laden: ' + err.message)
}
}
const handleDelete = async (unit) => {
if (!confirm(`Trainingseinheit vom ${unit.planned_date} wirklich löschen?`)) return
try {
await api.deleteTrainingUnit(unit.id)
await loadUnits()
} catch (err) {
alert('Fehler beim Löschen: ' + err.message)
}
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!formData.group_id || !formData.planned_date) {
alert('Gruppe und Datum sind Pflichtfelder')
return
}
try {
const payload = {
...formData,
group_id: parseInt(formData.group_id),
attendance_count: formData.attendance_count ? parseInt(formData.attendance_count) : null,
exercises: formData.exercises.map((ex, idx) => ({
exercise_id: ex.exercise_id,
order_index: idx,
planned_duration_min: ex.planned_duration_min ? parseInt(ex.planned_duration_min) : null,
actual_duration_min: ex.actual_duration_min ? parseInt(ex.actual_duration_min) : null,
notes: ex.notes || null,
modifications: ex.modifications || null
}))
}
if (editingUnit) {
await api.updateTrainingUnit(editingUnit.id, payload)
} else {
await api.createTrainingUnit(payload)
}
setShowModal(false)
await loadUnits()
} catch (err) {
alert('Fehler beim Speichern: ' + err.message)
}
}
const updateFormField = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }))
}
const addExercise = () => {
setFormData(prev => ({
...prev,
exercises: [...prev.exercises, {
exercise_id: '',
planned_duration_min: '',
actual_duration_min: '',
notes: '',
modifications: ''
}]
}))
}
const updateExercise = (index, field, value) => {
setFormData(prev => ({
...prev,
exercises: prev.exercises.map((ex, i) =>
i === index ? { ...ex, [field]: value } : ex
)
}))
}
const removeExercise = (index) => {
setFormData(prev => ({
...prev,
exercises: prev.exercises.filter((_, i) => i !== index)
}))
}
const moveExercise = (index, direction) => {
const newExercises = [...formData.exercises]
const target = index + direction
if (target < 0 || target >= newExercises.length) return
[newExercises[index], newExercises[target]] = [newExercises[target], newExercises[index]]
setFormData(prev => ({ ...prev, exercises: newExercises }))
}
if (loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner"></div>
<p>Laden...</p>
</div>
)
}
const selectedGroup = groups.find(g => g.id === parseInt(selectedGroupId))
return (
<div style={{ padding: '2rem' }}>
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
<h1 style={{ marginBottom: '1.5rem' }}>Trainingsplanung</h1>
{/* Group & Date Controls */}
<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">Trainingsgruppe</label>
<select
className="form-input"
value={selectedGroupId}
onChange={(e) => setSelectedGroupId(e.target.value)}
>
<option value="">Bitte wählen</option>
{groups.map(g => (
<option key={g.id} value={g.id}>
{g.name} ({g.club_name})
</option>
))}
</select>
</div>
<div>
<label className="form-label">Von</label>
<input
type="date"
className="form-input"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
<div>
<label className="form-label">Bis</label>
<input
type="date"
className="form-input"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
</div>
{selectedGroup && (
<div style={{ marginTop: '1rem', padding: '1rem', background: 'var(--surface2)', borderRadius: '8px' }}>
<p style={{ fontSize: '0.875rem', color: 'var(--text2)' }}>
📍 {selectedGroup.location || 'Kein Ort angegeben'}
{selectedGroup.weekday && ` · ${selectedGroup.weekday}`}
{selectedGroup.time_start && ` · ${selectedGroup.time_start.slice(0, 5)} - ${selectedGroup.time_end?.slice(0, 5)}`}
</p>
</div>
)}
</div>
{/* Action Buttons */}
{selectedGroupId && (
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem' }}>
<button className="btn btn-primary" onClick={handleCreate}>
+ Neue Trainingseinheit
</button>
<button className="btn btn-secondary" onClick={handleQuickCreate}>
Schnell erstellen
</button>
</div>
)}
{/* Units List */}
{!selectedGroupId ? (
<div className="card">
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
Bitte wähle eine Trainingsgruppe aus
</p>
</div>
) : units.length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
Keine Trainingseinheiten im gewählten Zeitraum
</p>
</div>
) : (
<div style={{ display: 'grid', gap: '1rem' }}>
{units.map(unit => (
<div key={unit.id} className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '1rem' }}>
<div>
<h3 style={{ marginBottom: '0.25rem' }}>
{unit.planned_date}
{unit.planned_time_start && ` · ${unit.planned_time_start.slice(0, 5)} - ${unit.planned_time_end?.slice(0, 5)}`}
</h3>
{unit.planned_focus && (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '0.5rem' }}>
🎯 {unit.planned_focus}
</p>
)}
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<span style={{
fontSize: '0.75rem',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
background: unit.status === 'completed' ? '#2ea44f' : unit.status === 'cancelled' ? 'var(--danger)' : 'var(--surface2)',
color: unit.status === 'completed' || unit.status === 'cancelled' ? 'white' : 'var(--text2)'
}}>
{unit.status === 'planned' && '📅 Geplant'}
{unit.status === 'completed' && '✓ Durchgeführt'}
{unit.status === 'cancelled' && '✗ Abgesagt'}
</span>
{unit.attendance_count !== null && (
<span style={{
fontSize: '0.75rem',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
background: 'var(--surface2)',
color: 'var(--text2)'
}}>
👥 {unit.attendance_count} Teilnehmer
</span>
)}
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
className="btn btn-secondary"
onClick={() => handleEdit(unit)}
>
Bearbeiten
</button>
<button
className="btn"
style={{
background: 'var(--danger)',
color: 'white',
border: 'none'
}}
onClick={() => handleDelete(unit)}
>
Löschen
</button>
</div>
</div>
{unit.notes && (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginTop: '0.5rem' }}>
💬 {unit.notes}
</p>
)}
</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',
overflowY: 'auto'
}}>
<div style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '2rem',
maxWidth: '900px',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
margin: '1rem'
}}>
<h2 style={{ marginBottom: '1.5rem' }}>
{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
</h2>
<form onSubmit={handleSubmit}>
{/* Basic Info */}
<h3 style={{ marginBottom: '1rem' }}>Planung</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '1rem', marginBottom: '1rem' }}>
<div className="form-row">
<label className="form-label">Datum *</label>
<input
type="date"
className="form-input"
value={formData.planned_date}
onChange={(e) => updateFormField('planned_date', e.target.value)}
required
/>
</div>
<div className="form-row">
<label className="form-label">Von</label>
<input
type="time"
className="form-input"
value={formData.planned_time_start}
onChange={(e) => updateFormField('planned_time_start', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Bis</label>
<input
type="time"
className="form-input"
value={formData.planned_time_end}
onChange={(e) => updateFormField('planned_time_end', e.target.value)}
/>
</div>
</div>
<div className="form-row">
<label className="form-label">Trainingsfokus</label>
<input
type="text"
className="form-input"
value={formData.planned_focus}
onChange={(e) => updateFormField('planned_focus', e.target.value)}
placeholder="z.B. Kihon Grundlagen, Kata Heian Shodan"
/>
</div>
{/* Exercises */}
<h3 style={{ marginTop: '2rem', marginBottom: '1rem' }}>Übungen</h3>
{formData.exercises.length === 0 ? (
<p style={{ color: 'var(--text2)', marginBottom: '1rem' }}>
Noch keine Übungen hinzugefügt
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1rem' }}>
{formData.exercises.map((ex, idx) => (
<div key={idx} style={{
display: 'grid',
gridTemplateColumns: '30px 1fr 80px auto',
gap: '0.5rem',
alignItems: 'center',
padding: '0.5rem',
background: 'var(--surface2)',
borderRadius: '8px'
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
<button
type="button"
onClick={() => moveExercise(idx, -1)}
disabled={idx === 0}
style={{
padding: '2px',
fontSize: '0.75rem',
background: 'none',
border: 'none',
cursor: idx === 0 ? 'not-allowed' : 'pointer',
opacity: idx === 0 ? 0.3 : 1
}}
>
</button>
<button
type="button"
onClick={() => moveExercise(idx, 1)}
disabled={idx === formData.exercises.length - 1}
style={{
padding: '2px',
fontSize: '0.75rem',
background: 'none',
border: 'none',
cursor: idx === formData.exercises.length - 1 ? 'not-allowed' : 'pointer',
opacity: idx === formData.exercises.length - 1 ? 0.3 : 1
}}
>
</button>
</div>
<select
className="form-input"
value={ex.exercise_id}
onChange={(e) => updateExercise(idx, 'exercise_id', parseInt(e.target.value))}
style={{ margin: 0 }}
>
<option value="">Übung wählen</option>
{exercises.map(exercise => (
<option key={exercise.id} value={exercise.id}>
{exercise.title}
</option>
))}
</select>
<input
type="number"
className="form-input"
value={ex.planned_duration_min}
onChange={(e) => updateExercise(idx, 'planned_duration_min', e.target.value)}
placeholder="min"
style={{ margin: 0 }}
/>
<button
type="button"
className="btn"
onClick={() => removeExercise(idx)}
style={{
padding: '0.5rem',
background: 'var(--danger)',
color: 'white',
border: 'none'
}}
>
</button>
</div>
))}
</div>
)}
<button
type="button"
className="btn btn-secondary"
onClick={addExercise}
style={{ marginBottom: '2rem' }}
>
+ Übung hinzufügen
</button>
{/* Durchführung (nur bei Edit) */}
{editingUnit && (
<>
<h3 style={{ marginTop: '2rem', marginBottom: '1rem' }}>Durchführung</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: '1rem', marginBottom: '1rem' }}>
<div className="form-row">
<label className="form-label">Tatsächliches Datum</label>
<input
type="date"
className="form-input"
value={formData.actual_date}
onChange={(e) => updateFormField('actual_date', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Von</label>
<input
type="time"
className="form-input"
value={formData.actual_time_start}
onChange={(e) => updateFormField('actual_time_start', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Bis</label>
<input
type="time"
className="form-input"
value={formData.actual_time_end}
onChange={(e) => updateFormField('actual_time_end', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Teilnehmer</label>
<input
type="number"
className="form-input"
value={formData.attendance_count}
onChange={(e) => updateFormField('attendance_count', e.target.value)}
/>
</div>
</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="planned">Geplant</option>
<option value="completed">Durchgeführt</option>
<option value="cancelled">Abgesagt</option>
</select>
</div>
</>
)}
{/* Notes */}
<h3 style={{ marginTop: '2rem', marginBottom: '1rem' }}>Notizen</h3>
<div className="form-row">
<label className="form-label">Öffentliche Notizen</label>
<textarea
className="form-input"
rows={3}
value={formData.notes}
onChange={(e) => updateFormField('notes', e.target.value)}
placeholder="Für alle Teilnehmer sichtbar"
/>
</div>
<div className="form-row">
<label className="form-label">Trainernotizen</label>
<textarea
className="form-input"
rows={3}
value={formData.trainer_notes}
onChange={(e) => updateFormField('trainer_notes', e.target.value)}
placeholder="Nur für Trainer sichtbar"
/>
</div>
{/* Buttons */}
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.5rem' }}>
<button type="submit" className="btn btn-primary" style={{ flex: 1 }}>
{editingUnit ? 'Speichern' : 'Erstellen'}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setShowModal(false)}
>
Abbrechen
</button>
</div>
</form>
</div>
</div>
)}
</div>
</div>
)
}
export default TrainingPlanningPage

View File

@ -256,6 +256,17 @@ export async function updateTrainingUnit(id, data) {
})
}
export async function deleteTrainingUnit(id) {
return request(`/api/training-units/${id}`, { method: 'DELETE' })
}
export async function quickCreateTrainingUnit(data) {
return request('/api/training-units/quick-create', {
method: 'POST',
body: JSON.stringify(data)
})
}
// ============================================================================
// Version & Health
// ============================================================================
@ -315,6 +326,8 @@ export const api = {
getTrainingUnit,
createTrainingUnit,
updateTrainingUnit,
deleteTrainingUnit,
quickCreateTrainingUnit,
// System
getVersion,