From 7f156ba085735d7b33d3909c18a80081684d4841 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 22 Apr 2026 16:54:34 +0200 Subject: [PATCH] feat: Training Planning (core feature) complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/main.py | 8 +- backend/migrations/006_training_planning.sql | 60 ++ backend/routers/training_planning.py | 403 +++++++++++ frontend/src/App.jsx | 9 + frontend/src/pages/TrainingPlanningPage.jsx | 701 +++++++++++++++++++ frontend/src/utils/api.js | 13 + 6 files changed, 1188 insertions(+), 6 deletions(-) create mode 100644 backend/migrations/006_training_planning.sql create mode 100644 backend/routers/training_planning.py create mode 100644 frontend/src/pages/TrainingPlanningPage.jsx diff --git a/backend/main.py b/backend/main.py index 4924f04..4a883fc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 diff --git a/backend/migrations/006_training_planning.sql b/backend/migrations/006_training_planning.sql new file mode 100644 index 0000000..11fe566 --- /dev/null +++ b/backend/migrations/006_training_planning.sql @@ -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); diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py new file mode 100644 index 0000000..c9882c9 --- /dev/null +++ b/backend/routers/training_planning.py @@ -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) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a0e523d..06bbdef 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() { } /> + + + + } + /> {/* Catch all - redirect to dashboard or login */} } /> diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx new file mode 100644 index 0000000..4ac5f1a --- /dev/null +++ b/frontend/src/pages/TrainingPlanningPage.jsx @@ -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 ( +
+
+

Laden...

+
+ ) + } + + const selectedGroup = groups.find(g => g.id === parseInt(selectedGroupId)) + + return ( +
+
+

Trainingsplanung

+ + {/* Group & Date Controls */} +
+
+
+ + +
+ +
+ + setStartDate(e.target.value)} + /> +
+ +
+ + setEndDate(e.target.value)} + /> +
+
+ + {selectedGroup && ( +
+

+ 📍 {selectedGroup.location || 'Kein Ort angegeben'} + {selectedGroup.weekday && ` · ${selectedGroup.weekday}`} + {selectedGroup.time_start && ` · ${selectedGroup.time_start.slice(0, 5)} - ${selectedGroup.time_end?.slice(0, 5)}`} +

+
+ )} +
+ + {/* Action Buttons */} + {selectedGroupId && ( +
+ + +
+ )} + + {/* Units List */} + {!selectedGroupId ? ( +
+

+ Bitte wähle eine Trainingsgruppe aus +

+
+ ) : units.length === 0 ? ( +
+

+ Keine Trainingseinheiten im gewählten Zeitraum +

+
+ ) : ( +
+ {units.map(unit => ( +
+
+
+

+ {unit.planned_date} + {unit.planned_time_start && ` · ${unit.planned_time_start.slice(0, 5)} - ${unit.planned_time_end?.slice(0, 5)}`} +

+ {unit.planned_focus && ( +

+ 🎯 {unit.planned_focus} +

+ )} +
+ + {unit.status === 'planned' && '📅 Geplant'} + {unit.status === 'completed' && '✓ Durchgeführt'} + {unit.status === 'cancelled' && '✗ Abgesagt'} + + {unit.attendance_count !== null && ( + + 👥 {unit.attendance_count} Teilnehmer + + )} +
+
+ +
+ + +
+
+ + {unit.notes && ( +

+ 💬 {unit.notes} +

+ )} +
+ ))} +
+ )} + + {/* Create/Edit Modal */} + {showModal && ( +
+
+

+ {editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'} +

+ +
+ {/* Basic Info */} +

Planung

+ +
+
+ + updateFormField('planned_date', e.target.value)} + required + /> +
+ +
+ + updateFormField('planned_time_start', e.target.value)} + /> +
+ +
+ + updateFormField('planned_time_end', e.target.value)} + /> +
+
+ +
+ + updateFormField('planned_focus', e.target.value)} + placeholder="z.B. Kihon Grundlagen, Kata Heian Shodan" + /> +
+ + {/* Exercises */} +

Übungen

+ + {formData.exercises.length === 0 ? ( +

+ Noch keine Übungen hinzugefügt +

+ ) : ( +
+ {formData.exercises.map((ex, idx) => ( +
+
+ + +
+ + + + updateExercise(idx, 'planned_duration_min', e.target.value)} + placeholder="min" + style={{ margin: 0 }} + /> + + +
+ ))} +
+ )} + + + + {/* Durchführung (nur bei Edit) */} + {editingUnit && ( + <> +

Durchführung

+ +
+
+ + updateFormField('actual_date', e.target.value)} + /> +
+ +
+ + updateFormField('actual_time_start', e.target.value)} + /> +
+ +
+ + updateFormField('actual_time_end', e.target.value)} + /> +
+ +
+ + updateFormField('attendance_count', e.target.value)} + /> +
+
+ +
+ + +
+ + )} + + {/* Notes */} +

Notizen

+ +
+ +