From d7e1a82a373f3e9960452fd414a3424940323aef Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 28 Apr 2026 16:31:45 +0200 Subject: [PATCH] feat: update version to 0.8.0 and enhance training planning features - Incremented application version to 0.8.0 and updated database schema version to 20260428031. - Introduced support for training plan templates, allowing users to create and manage reusable training structures. - Enhanced the Training Planning UI to include sections and exercises, improving the organization of training units. - Updated API endpoints for training plan templates, enabling CRUD operations for better integration with the frontend. - Improved validation and permission checks for creating training units, ensuring proper access control. --- ...1_training_plan_templates_and_sections.sql | 113 ++ backend/routers/training_planning.py | 850 ++++++++++---- backend/version.py | 17 +- frontend/src/pages/TrainingPlanningPage.jsx | 1012 ++++++++++++----- frontend/src/utils/api.js | 31 + frontend/src/version.js | 2 +- 6 files changed, 1509 insertions(+), 516 deletions(-) create mode 100644 backend/migrations/031_training_plan_templates_and_sections.sql diff --git a/backend/migrations/031_training_plan_templates_and_sections.sql b/backend/migrations/031_training_plan_templates_and_sections.sql new file mode 100644 index 0000000..0a9ae1d --- /dev/null +++ b/backend/migrations/031_training_plan_templates_and_sections.sql @@ -0,0 +1,113 @@ +-- Migration 031: Trainingsvorlagen (Sektionen) und strukturierter Ablauf pro Einheit +-- Freie Anmerkungszeilen (note) zwischen Übungen (exercise) mit optionaler Variante/Dauer. + +-- ── Vorlagen (wiederverwendbare Gliederung für Gruppen/Trainer) ───────────── +CREATE TABLE IF NOT EXISTS training_plan_templates ( + id SERIAL PRIMARY KEY, + club_id INT REFERENCES clubs(id) ON DELETE SET NULL, + created_by INT REFERENCES profiles(id) ON DELETE SET NULL, + name VARCHAR(200) NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS training_plan_template_sections ( + id SERIAL PRIMARY KEY, + template_id INT NOT NULL REFERENCES training_plan_templates(id) ON DELETE CASCADE, + order_index INT NOT NULL, + title VARCHAR(200) NOT NULL, + guidance_text TEXT, + UNIQUE (template_id, order_index) +); + +CREATE INDEX IF NOT EXISTS idx_training_plan_templates_club ON training_plan_templates(club_id); +CREATE INDEX IF NOT EXISTS idx_training_plan_templates_creator ON training_plan_templates(created_by); +CREATE INDEX IF NOT EXISTS idx_training_plan_template_sections_template ON training_plan_template_sections(template_id); + +DROP TRIGGER IF EXISTS training_plan_templates_update ON training_plan_templates; +CREATE TRIGGER training_plan_templates_update + BEFORE UPDATE ON training_plan_templates + FOR EACH ROW EXECUTE FUNCTION update_timestamp(); + +-- ── Verknüpfung Einheit ↔ genutzte Vorlage (nur Metadaten) ────────────────── +ALTER TABLE training_units + ADD COLUMN IF NOT EXISTS plan_template_id INT REFERENCES training_plan_templates(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_training_units_plan_template ON training_units(plan_template_id); + +-- ── Konkrete Sektionen je Trainingseinheit ──────────────────────────────── +CREATE TABLE IF NOT EXISTS training_unit_sections ( + id SERIAL PRIMARY KEY, + training_unit_id INT NOT NULL REFERENCES training_units(id) ON DELETE CASCADE, + order_index INT NOT NULL, + title VARCHAR(200) NOT NULL DEFAULT 'Abschnitt', + guidance_notes TEXT, + source_template_section_id INT REFERENCES training_plan_template_sections(id) ON DELETE SET NULL, + UNIQUE (training_unit_id, order_index) +); + +CREATE INDEX IF NOT EXISTS idx_training_unit_sections_unit ON training_unit_sections(training_unit_id); + +-- ── Positionen: Übung oder freie Anmerkung ──────────────────────────────── +CREATE TABLE IF NOT EXISTS training_unit_section_items ( + id SERIAL PRIMARY KEY, + section_id INT NOT NULL REFERENCES training_unit_sections(id) ON DELETE CASCADE, + order_index INT NOT NULL, + item_type VARCHAR(20) NOT NULL CHECK (item_type IN ('exercise', 'note')), + exercise_id INT REFERENCES exercises(id) ON DELETE SET NULL, + exercise_variant_id INT REFERENCES exercise_variants(id) ON DELETE SET NULL, + planned_duration_min INT, + actual_duration_min INT, + notes TEXT, + modifications TEXT, + note_body TEXT, + UNIQUE (section_id, order_index), + CHECK ( + (item_type = 'exercise' AND exercise_id IS NOT NULL AND note_body IS NULL) + OR + (item_type = 'note' AND exercise_id IS NULL) + ) +); + +CREATE INDEX IF NOT EXISTS idx_training_unit_section_items_section ON training_unit_section_items(section_id); +CREATE INDEX IF NOT EXISTS idx_training_unit_section_items_exercise ON training_unit_section_items(exercise_id) + WHERE exercise_id IS NOT NULL; + +-- ── Bestehende Zeilen migrieren: eine Sektion „Übungen“ pro Einheit ───────── +INSERT INTO training_unit_sections (training_unit_id, order_index, title) +SELECT id, 0, 'Übungen' +FROM training_units tu +WHERE NOT EXISTS ( + SELECT 1 FROM training_unit_sections tus WHERE tus.training_unit_id = tu.id +); + +INSERT INTO training_unit_section_items ( + section_id, + order_index, + item_type, + exercise_id, + exercise_variant_id, + planned_duration_min, + actual_duration_min, + notes, + modifications, + note_body +) +SELECT + tus.id, + tue.order_index, + 'exercise', + tue.exercise_id, + tue.exercise_variant_id, + tue.planned_duration_min, + tue.actual_duration_min, + tue.notes, + tue.modifications, + NULL +FROM training_unit_exercises tue +INNER JOIN training_unit_sections tus + ON tus.training_unit_id = tue.training_unit_id + AND tus.order_index = 0; + +DROP TABLE IF EXISTS training_unit_exercises; diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index 54b2906..c54b3b6 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -1,11 +1,12 @@ """ -Training Planning Endpoints for Shinkan Jinkendo +Training Planning – Trainingseinheiten, strukturierter Ablauf (Sektionen + Übungen/Notizen) +und wiederverwendbare Trainingsvorlagen (Sektions-Gliederung). -Handles CRUD operations for training units and their exercises. +Governance (Vorlagen-rechte über Vereine/„offiziell“) kann später nachgezogen werden. """ -from typing import Optional -from datetime import date, time -from fastapi import APIRouter, HTTPException, Depends, Query +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query from db import get_db, get_cursor, r2d from auth import require_auth @@ -19,46 +20,479 @@ def _optional_positive_int(val, field_name: str) -> Optional[int]: try: i = int(val) except (TypeError, ValueError): - raise HTTPException(400, detail=f"Ungültige {field_name}") + raise HTTPException(status_code=400, detail=f"Ungültige {field_name}") if i < 1: - raise HTTPException(400, detail=f"Ungültige {field_name}") + raise HTTPException(status_code=400, detail=f"Ungültige {field_name}") return i def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id: Optional[int]): - """Prüft, dass exercise_variant_id zur gewählten Übung gehört.""" if not variant_id: return if not exercise_id: - raise HTTPException(400, detail="exercise_variant_id nur zusammen mit exercise_id erlaubt") + raise HTTPException( + status_code=400, detail="exercise_variant_id nur zusammen mit exercise_id erlaubt" + ) cur.execute( "SELECT 1 FROM exercise_variants WHERE id = %s AND exercise_id = %s", (variant_id, exercise_id), ) if not cur.fetchone(): - raise HTTPException(400, detail="Variante passt nicht zur gewählten Übung") + raise HTTPException(status_code=400, detail="Variante passt nicht zur gewählten Übung") + + +def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str) -> None: + cur.execute( + "SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s", + (group_id,), + ) + group = cur.fetchone() + if not group: + raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden") + co_trainers = group["co_trainer_ids"] or [] + if role not in ["admin", "superadmin", "trainer"]: + raise HTTPException(status_code=403, detail="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( + status_code=403, detail="Nur der zuständige Trainer darf für diese Gruppe planen" + ) + + +def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]: + cur.execute( + """ + SELECT tu.id, tu.created_by, tu.group_id, tu.plan_template_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,), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden") + return r2d(row) + + +def _assert_training_unit_permission( + cur, unit_row: Dict[str, Any], profile_id: int, role: str +) -> None: + co_trainers = unit_row["co_trainer_ids"] or [] + if role not in ["admin", "superadmin"]: + if ( + unit_row["created_by"] != profile_id + and unit_row["trainer_id"] != profile_id + and profile_id not in co_trainers + ): + raise HTTPException(status_code=403, detail="Keine Berechtigung") + + +def _assert_delete_training_unit(role: str, created_by: int, profile_id: int) -> None: + if role not in ["admin", "superadmin"] and created_by != profile_id: + raise HTTPException(status_code=403, detail="Keine Berechtigung") + + +def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]: + cur.execute( + """ + SELECT id, training_unit_id, order_index, title, guidance_notes, source_template_section_id + FROM training_unit_sections + WHERE training_unit_id = %s + ORDER BY order_index + """, + (unit_id,), + ) + secs = [] + for sec_row in cur.fetchall(): + sec = r2d(sec_row) + cur.execute( + """ + SELECT tusi.*, + e.title AS exercise_title, + e.summary AS exercise_summary, + e.focus_area AS exercise_focus_area, + ev.variant_name AS exercise_variant_name + FROM training_unit_section_items tusi + LEFT JOIN exercises e ON tusi.exercise_id = e.id + LEFT JOIN exercise_variants ev ON tusi.exercise_variant_id = ev.id + WHERE tusi.section_id = %s + ORDER BY tusi.order_index + """, + (sec["id"],), + ) + sec["items"] = [r2d(r) for r in cur.fetchall()] + secs.append(sec) + return secs + + +def _flatten_exercises_from_sections(unit: Dict[str, Any]) -> None: + flat: List[Dict[str, Any]] = [] + for sec in sorted(unit.get("sections", []), key=lambda s: s.get("order_index", 0)): + for item in sorted(sec.get("items", []), key=lambda i: i.get("order_index", 0)): + if item.get("item_type") == "exercise": + flat.append(item) + unit["exercises"] = flat + + +def _hydrate_training_unit_payload(cur, unit: Dict[str, Any]) -> Dict[str, Any]: + uid = unit["id"] + unit["sections"] = _fetch_sections(cur, uid) + _flatten_exercises_from_sections(unit) + return unit + + +def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], start_order: int = 0): + if items_in is None: + items_in = [] + for i, raw in enumerate(items_in): + itype = raw.get("item_type") + if not itype: + itype = "exercise" if raw.get("exercise_id") else "note" + order_ix = raw.get("order_index") + if order_ix is None: + order_ix = start_order + i + if itype == "note": + body = raw.get("note_body") + if body is None: + body = "" + cur.execute( + """ + INSERT INTO training_unit_section_items ( + section_id, order_index, item_type, + exercise_id, exercise_variant_id, + planned_duration_min, actual_duration_min, + notes, modifications, note_body + ) VALUES (%s, %s, 'note', + NULL, NULL, NULL, NULL, NULL, NULL, %s + ) + """, + (section_id, order_ix, body), + ) + continue + + eid = raw.get("exercise_id") + if not eid: + continue + eid = int(eid) + vid = _optional_positive_int(raw.get("exercise_variant_id"), "exercise_variant_id") + _validate_variant_for_exercise(cur, eid, vid) + cur.execute( + """ + INSERT INTO training_unit_section_items ( + section_id, order_index, item_type, + exercise_id, exercise_variant_id, + planned_duration_min, actual_duration_min, + notes, modifications, note_body + ) VALUES (%s, %s, 'exercise', + %s, %s, %s, %s, %s, %s, NULL + ) + """, + ( + section_id, + order_ix, + eid, + vid, + raw.get("planned_duration_min"), + raw.get("actual_duration_min"), + raw.get("notes"), + raw.get("modifications"), + ), + ) + + +def _replace_unit_sections(cur, unit_id: int, sections_in: List[Any]): + cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,)) + for si, sec in enumerate(sections_in): + title = (sec.get("title") or "").strip() or "Abschnitt" + order_ix = sec.get("order_index") + if order_ix is None: + order_ix = si + src_tsec = _optional_positive_int(sec.get("source_template_section_id"), "source_template_section_id") + cur.execute( + """ + INSERT INTO training_unit_sections ( + training_unit_id, order_index, title, guidance_notes, source_template_section_id + ) VALUES (%s, %s, %s, %s, %s) + RETURNING id + """, + ( + unit_id, + order_ix, + title, + sec.get("guidance_notes"), + src_tsec, + ), + ) + sid = cur.fetchone()["id"] + _insert_section_items(cur, sid, sec.get("items")) + + +def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List[Any]): + if not exercises_in: + return + cur.execute( + """ + INSERT INTO training_unit_sections (training_unit_id, order_index, title, guidance_notes) + VALUES (%s, 0, %s, NULL) + RETURNING id + """, + (unit_id, "Übungen"), + ) + sid = cur.fetchone()["id"] + slot = 0 + filtered: List[Dict[str, Any]] = [] + for ex in exercises_in: + eid = ex.get("exercise_id") + if not eid: + continue + eid = int(eid) + vid = _optional_positive_int(ex.get("exercise_variant_id"), "exercise_variant_id") + _validate_variant_for_exercise(cur, eid, vid) + filtered.append( + { + "item_type": "exercise", + "order_index": slot, + "exercise_id": eid, + "exercise_variant_id": vid, + "planned_duration_min": ex.get("planned_duration_min"), + "actual_duration_min": ex.get("actual_duration_min"), + "notes": ex.get("notes"), + "modifications": ex.get("modifications"), + } + ) + slot += 1 + _insert_section_items(cur, sid, filtered, start_order=0) + + +def _instantiate_from_template(cur, unit_id: int, template_id: int): + cur.execute( + """ + SELECT id, title, guidance_text + FROM training_plan_template_sections + WHERE template_id = %s + ORDER BY order_index + """, + (template_id,), + ) + rows = cur.fetchall() + for row in rows: + r = r2d(row) + cur.execute( + """ + INSERT INTO training_unit_sections ( + training_unit_id, order_index, title, guidance_notes, source_template_section_id + ) VALUES (%s, ( + SELECT COALESCE(MAX(order_index), -1) + 1 FROM training_unit_sections u2 + WHERE u2.training_unit_id = %s + ), %s, %s, %s) + """, + (unit_id, unit_id, r["title"], r["guidance_text"], r["id"]), + ) + + # Fallback: keine Sektionen in Vorlage → ein leerer Block + if not rows: + cur.execute( + """ + INSERT INTO training_unit_sections (training_unit_id, order_index, title, guidance_notes) + SELECT %s, 0, %s, NULL + WHERE NOT EXISTS (SELECT 1 FROM training_unit_sections WHERE training_unit_id = %s) + """, + (unit_id, "Hauptteil", unit_id), + ) + + +def _template_access(cur, tid: int, profile_id: int, role: str) -> Dict[str, Any]: + cur.execute( + """ + SELECT * + FROM training_plan_templates + WHERE id = %s + """, + (tid,), + ) + r = cur.fetchone() + if not r: + raise HTTPException(status_code=404, detail="Trainingsvorlage nicht gefunden") + row = r2d(r) + if role in ["admin", "superadmin"]: + return row + if row["created_by"] != profile_id: + raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Vorlage") + return row + + +# ── Vorlagen ──────────────────────────────────────────────────────────── + + +@router.get("/training-plan-templates") +def list_training_plan_templates(session=Depends(require_auth)): + profile_id = session["profile_id"] + role = session.get("role") + with get_db() as conn: + cur = get_cursor(conn) + if role in ["admin", "superadmin"]: + cur.execute( + """ + SELECT t.*, + (SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id) + AS sections_count + FROM training_plan_templates t + ORDER BY t.updated_at DESC NULLS LAST, t.name + """ + ) + else: + cur.execute( + """ + SELECT t.*, + (SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id) + AS sections_count + FROM training_plan_templates t + WHERE t.created_by = %s + ORDER BY t.updated_at DESC NULLS LAST, t.name + """, + (profile_id,), + ) + return [r2d(r) for r in cur.fetchall()] + + +@router.get("/training-plan-templates/{template_id}") +def get_training_plan_template(template_id: int, session=Depends(require_auth)): + profile_id = session["profile_id"] + role = session.get("role") + with get_db() as conn: + cur = get_cursor(conn) + row = _template_access(cur, template_id, profile_id, role) + cur.execute( + """ + SELECT * + FROM training_plan_template_sections + WHERE template_id = %s + ORDER BY order_index + """, + (template_id,), + ) + row["sections"] = [r2d(r) for r in cur.fetchall()] + return row + + +@router.post("/training-plan-templates") +def create_training_plan_template(data: dict, session=Depends(require_auth)): + profile_id = session["profile_id"] + role = session.get("role") + if role not in ["admin", "superadmin", "trainer"]: + raise HTTPException(status_code=403, detail="Nur Trainer dürfen Vorlagen anlegen") + name = (data.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name ist Pflicht") + club_id = data.get("club_id") + sections_in = data.get("sections") or [] + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + INSERT INTO training_plan_templates (club_id, created_by, name, description) + VALUES (%s, %s, %s, %s) + RETURNING id + """, + (club_id, profile_id, name, data.get("description")), + ) + tid = cur.fetchone()["id"] + for si, sec in enumerate(sections_in): + title = (sec.get("title") or "").strip() or f"Abschnitt {si + 1}" + order_ix = sec.get("order_index") + if order_ix is None: + order_ix = si + cur.execute( + """ + INSERT INTO training_plan_template_sections (template_id, order_index, title, guidance_text) + VALUES (%s, %s, %s, %s) + """, + (tid, order_ix, title, sec.get("guidance_text")), + ) + conn.commit() + return get_training_plan_template(tid, session) + + +@router.put("/training-plan-templates/{template_id}") +def update_training_plan_template(template_id: int, data: dict, session=Depends(require_auth)): + profile_id = session["profile_id"] + role = session.get("role") + with get_db() as conn: + cur = get_cursor(conn) + _template_access(cur, template_id, profile_id, role) + fields = [] + params: List[Any] = [] + if "name" in data: + name = data.get("name") + name = name.strip() if isinstance(name, str) else "" + if not name: + raise HTTPException(status_code=400, detail="name ist Pflicht") + fields.append("name = %s") + params.append(name) + if "description" in data: + fields.append("description = %s") + params.append(data.get("description")) + if "club_id" in data: + fields.append("club_id = %s") + params.append(data.get("club_id")) + fields.append("updated_at = NOW()") + params.append(template_id) + cur.execute( + f""" + UPDATE training_plan_templates SET {", ".join(fields)} + WHERE id = %s + """, + tuple(params), + ) + if "sections" in data: + cur.execute( + "DELETE FROM training_plan_template_sections WHERE template_id = %s", (template_id,) + ) + sections_in = data["sections"] or [] + for si, sec in enumerate(sections_in): + title = (sec.get("title") or "").strip() or f"Abschnitt {si + 1}" + order_ix = sec.get("order_index") + if order_ix is None: + order_ix = si + cur.execute( + """ + INSERT INTO training_plan_template_sections (template_id, order_index, title, guidance_text) + VALUES (%s, %s, %s, %s) + """, + (template_id, order_ix, title, sec.get("guidance_text")), + ) + conn.commit() + return get_training_plan_template(template_id, session) + + +@router.delete("/training-plan-templates/{template_id}") +def delete_training_plan_template(template_id: int, session=Depends(require_auth)): + profile_id = session["profile_id"] + role = session.get("role") + with get_db() as conn: + cur = get_cursor(conn) + _template_access(cur, template_id, profile_id, role) + cur.execute("DELETE FROM training_plan_templates WHERE id = %s", (template_id,)) + conn.commit() + return {"ok": True} + + +# ── Einheiten ───────────────────────────────────────────────────────────── -# ── 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) + 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') + profile_id = session["profile_id"] + role = session.get("role") with get_db() as conn: cur = get_cursor(conn) @@ -78,8 +512,7 @@ def list_training_units( where = [] params = [] - # Access control: show only own units unless admin - if role not in ['admin', 'superadmin']: + if role not in ["admin", "superadmin"]: where.append("(tu.created_by = %s OR tg.trainer_id = %s)") params.extend([profile_id, profile_id]) @@ -109,18 +542,16 @@ def list_training_units( 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') + profile_id = session["profile_id"] + role = session.get("role") with get_db() as conn: cur = get_cursor(conn) - # Get training unit - cur.execute(""" + cur.execute( + """ SELECT tu.*, tg.name as group_name, tg.weekday as group_weekday, @@ -134,160 +565,113 @@ def get_training_unit(unit_id: int, session=Depends(require_auth)): 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_id,), + ) unit = cur.fetchone() - if not unit: - raise HTTPException(404, "Trainingseinheit nicht gefunden") + raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden") unit = r2d(unit) - # Access control - cur.execute("SELECT trainer_id FROM training_groups WHERE id = %s", (unit['group_id'],)) + 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, - ev.variant_name as exercise_variant_name - FROM training_unit_exercises tue - LEFT JOIN exercises e ON tue.exercise_id = e.id - LEFT JOIN exercise_variants ev ON tue.exercise_variant_id = ev.id - WHERE tue.training_unit_id = %s - ORDER BY tue.order_index - """, (unit_id,)) - unit['exercises'] = [r2d(r) for r in cur.fetchall()] + if role not in ["admin", "superadmin"]: + if unit["created_by"] != profile_id and (not group or group["trainer_id"] != profile_id): + raise HTTPException(status_code=403, detail="Keine Berechtigung") + _hydrate_training_unit_payload(cur, unit) 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') + profile_id = session["profile_id"] + role = session.get("role") - group_id = data.get('group_id') - planned_date = data.get('planned_date') + 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") + raise HTTPException(status_code=400, detail="group_id und planned_date sind Pflichtfelder") + + plan_template_id = _optional_positive_int(data.get("plan_template_id"), "plan_template_id") 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() + _can_access_group_for_create(cur, group_id, profile_id, role) - if not group: - raise HTTPException(404, "Trainingsgruppe nicht gefunden") + tpl_id_safe = None + if plan_template_id: + _template_access(cur, plan_template_id, profile_id, role) + tpl_id_safe = plan_template_id - # 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(""" + 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) + planned_focus, status, notes, trainer_notes, created_by, + plan_template_id + ) VALUES (%s, %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 - )) + """, + ( + 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, + tpl_id_safe, + ), + ) - unit_id = cur.fetchone()['id'] + unit_id = cur.fetchone()["id"] - exercises_in = data.get('exercises', []) - slot = 0 - for ex in exercises_in: - eid = ex.get('exercise_id') - if not eid: - continue - eid = int(eid) - vid = _optional_positive_int(ex.get('exercise_variant_id'), 'exercise_variant_id') - _validate_variant_for_exercise(cur, eid, vid) - cur.execute(""" - INSERT INTO training_unit_exercises ( - training_unit_id, exercise_id, exercise_variant_id, order_index, - planned_duration_min, notes - ) VALUES (%s, %s, %s, %s, %s, %s) - """, ( - unit_id, - eid, - vid, - slot, - ex.get('planned_duration_min'), - ex.get('notes') - )) - slot += 1 + sections_in = data.get("sections") + exercises_in = data.get("exercises") + + if sections_in is not None: + _replace_unit_sections(cur, unit_id, sections_in) + elif tpl_id_safe: + _instantiate_from_template(cur, unit_id, tpl_id_safe) + elif exercises_in is not None: + _insert_sections_from_legacy_exercises(cur, unit_id, exercises_in) 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') + 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_row = _training_unit_guard_row(cur, unit_id) + _assert_training_unit_permission(cur, unit_row, profile_id, role) - unit = cur.fetchone() + tpl_upd = data.get("plan_template_id") if "plan_template_id" in data else None + tpl_id_val = None + if tpl_upd not in (None, ""): + tid = _optional_positive_int(tpl_upd, "plan_template_id") + if tid: + _template_access(cur, tid, profile_id, role) + tpl_id_val = tid - 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(""" + cur.execute( + """ UPDATE training_units SET - planned_date = %s, + planned_date = COALESCE(%s, planned_date), planned_time_start = %s, planned_time_end = %s, planned_focus = %s, @@ -298,151 +682,149 @@ def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth) status = %s, notes = %s, trainer_notes = %s, + plan_template_id = COALESCE(%s, plan_template_id), 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 - )) + """, + ( + 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"), + tpl_id_val, + 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,)) + content_handled = False + if data.get("reset_from_template"): + tid = tpl_id_val or unit_row.get("plan_template_id") + if not tid: + raise HTTPException( + status_code=400, + detail="reset_from_template erfordert plan_template_id auf der Einheit oder im Request", + ) + _template_access(cur, tid, profile_id, role) + cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,)) + cur.execute( + "UPDATE training_units SET plan_template_id = %s WHERE id = %s", (tid, unit_id) + ) + _instantiate_from_template(cur, unit_id, tid) + content_handled = True - # Add new exercises - exercises_in = data['exercises'] - slot = 0 - for ex in exercises_in: - eid = ex.get('exercise_id') - if not eid: - continue - eid = int(eid) - vid = _optional_positive_int(ex.get('exercise_variant_id'), 'exercise_variant_id') - _validate_variant_for_exercise(cur, eid, vid) - cur.execute(""" - INSERT INTO training_unit_exercises ( - training_unit_id, exercise_id, exercise_variant_id, order_index, - planned_duration_min, actual_duration_min, - notes, modifications - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) - """, ( - unit_id, - eid, - vid, - slot, - ex.get('planned_duration_min'), - ex.get('actual_duration_min'), - ex.get('notes'), - ex.get('modifications') - )) - slot += 1 + if not content_handled and "sections" in data: + _replace_unit_sections(cur, unit_id, data["sections"] or []) + elif not content_handled and "exercises" in data: + cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,)) + _insert_sections_from_legacy_exercises(cur, unit_id, data["exercises"] or []) 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') + 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,)) + 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") + raise HTTPException(status_code=404, detail="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") + _assert_delete_training_unit(role, unit["created_by"], profile_id) - # 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'] + profile_id = session["profile_id"] - group_id = data.get('group_id') - planned_date = data.get('planned_date') + group_id = data.get("group_id") + planned_date = data.get("planned_date") + plan_template_id = _optional_positive_int(data.get("plan_template_id"), "plan_template_id") if not group_id or not planned_date: - raise HTTPException(400, "group_id und planned_date sind Pflichtfelder") + raise HTTPException(status_code=400, detail="group_id und planned_date sind Pflichtfelder") with get_db() as conn: cur = get_cursor(conn) - # Get group defaults - cur.execute(""" + cur.execute( + """ SELECT weekday, time_start, time_end, trainer_id, co_trainer_ids FROM training_groups WHERE id = %s - """, (group_id,)) + """, + (group_id,), + ) group = cur.fetchone() if not group: - raise HTTPException(404, "Trainingsgruppe nicht gefunden") + raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden") - # Check permission - role = session.get('role') - co_trainers = group['co_trainer_ids'] or [] + 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", "trainer"]: + raise HTTPException(status_code=403, detail="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") + if role not in ["admin", "superadmin"]: + if group["trainer_id"] != profile_id and profile_id not in co_trainers: + raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe") - # Create with group defaults - cur.execute(""" + tpl_id_safe = None + if plan_template_id: + _template_access(cur, plan_template_id, profile_id, role) + tpl_id_safe = plan_template_id + + 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) + status, created_by, plan_template_id + ) VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id - """, ( - group_id, - planned_date, - group['time_start'], - group['time_end'], - 'planned', - profile_id - )) + """, + ( + group_id, + planned_date, + group["time_start"], + group["time_end"], + "planned", + profile_id, + tpl_id_safe, + ), + ) + + unit_id = cur.fetchone()["id"] + + if tpl_id_safe: + _instantiate_from_template(cur, unit_id, tpl_id_safe) - unit_id = cur.fetchone()['id'] conn.commit() return get_training_unit(unit_id, session) + diff --git a/backend/version.py b/backend/version.py index 015ecb5..aa11f78 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.7.9" -BUILD_DATE = "2026-04-27" -DB_SCHEMA_VERSION = "20260427030" +APP_VERSION = "0.8.0" +BUILD_DATE = "2026-04-28" +DB_SCHEMA_VERSION = "20260428031" MODULE_VERSIONS = { "auth": "1.0.0", @@ -14,7 +14,7 @@ MODULE_VERSIONS = { "exercises": "2.1.0", # Varianten-CRUD API + UI; Listen mit include_variants "training_units": "0.1.0", "training_programs": "0.1.0", - "planning": "0.2.0", + "planning": "0.3.0", "import_wiki": "1.0.0", "admin": "1.0.0", "membership": "1.0.0", @@ -24,7 +24,14 @@ MODULE_VERSIONS = { CHANGELOG = [ { - "version": "0.7.9", + "version": "0.8.0", + "date": "2026-04-28", + "changes": [ + "DB 031: Trainingsvorlagen (Sektionen) + Struktur pro Einheit (Sektionen, Übungen/Notizen, Dauer)", + "API: /api/training-plan-templates CRUD; Trainingseinheiten mit sections[] + plan_template_id", + "Trainingsplanung UI: Abschnitte, Zwischen-Anmerkungen, Vorlagen auswählen / speichern", + ], + }, "date": "2026-04-27", "changes": [ "Übungsvarianten: POST/PUT/DELETE /api/exercises/{id}/variants + reorder", diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx index e447f3a..4edeee3 100644 --- a/frontend/src/pages/TrainingPlanningPage.jsx +++ b/frontend/src/pages/TrainingPlanningPage.jsx @@ -1,25 +1,147 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useCallback } from 'react' import api from '../utils/api' import { useAuth } from '../context/AuthContext' +function defaultSection(title = 'Hauptteil') { + return { title, guidance_notes: '', items: [] } +} + +function exerciseRow() { + return { + item_type: 'exercise', + exercise_id: '', + exercise_variant_id: '', + planned_duration_min: '', + actual_duration_min: '', + notes: '', + modifications: '' + } +} + +function noteRow() { + return { item_type: 'note', note_body: '' } +} + +function normalizeUnitToForm(fullUnit) { + if (fullUnit.sections && fullUnit.sections.length) { + return fullUnit.sections.map((sec) => ({ + title: sec.title, + guidance_notes: sec.guidance_notes || '', + items: (sec.items || []).map((it) => { + if (it.item_type === 'note') { + return { item_type: 'note', note_body: it.note_body || '' } + } + return { + item_type: 'exercise', + exercise_id: it.exercise_id, + exercise_variant_id: it.exercise_variant_id ?? '', + planned_duration_min: + it.planned_duration_min !== null && it.planned_duration_min !== undefined + ? String(it.planned_duration_min) + : '', + actual_duration_min: + it.actual_duration_min !== null && it.actual_duration_min !== undefined + ? String(it.actual_duration_min) + : '', + notes: it.notes ?? '', + modifications: it.modifications ?? '' + } + }) + })) + } + if (fullUnit.exercises && fullUnit.exercises.length) { + return [ + { + title: 'Übungen', + guidance_notes: '', + items: fullUnit.exercises.map((ex) => ({ + item_type: 'exercise', + exercise_id: ex.exercise_id, + exercise_variant_id: ex.exercise_variant_id ?? '', + planned_duration_min: + ex.planned_duration_min !== null && ex.planned_duration_min !== undefined + ? String(ex.planned_duration_min) + : '', + actual_duration_min: + ex.actual_duration_min !== null && ex.actual_duration_min !== undefined + ? String(ex.actual_duration_min) + : '', + notes: ex.notes ?? '', + modifications: ex.modifications ?? '' + })) + } + ] + } + return [defaultSection()] +} + +function parseMin(v) { + if (v === '' || v === null || v === undefined) return null + const n = parseInt(String(v), 10) + return Number.isFinite(n) ? n : null +} + +function buildSectionsPayload(sections) { + return sections.map((sec, si) => ({ + order_index: si, + title: (sec.title || '').trim() || 'Abschnitt', + guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null, + items: (sec.items || []) + .map((it, ii) => { + if (it.item_type === 'note') { + return { + item_type: 'note', + order_index: ii, + note_body: it.note_body ?? '' + } + } + if (it.exercise_id === '' || it.exercise_id == null || Number.isNaN(Number(it.exercise_id))) { + return null + } + const vid = it.exercise_variant_id + return { + item_type: 'exercise', + order_index: ii, + exercise_id: parseInt(it.exercise_id, 10), + exercise_variant_id: + vid !== '' && vid != null && !Number.isNaN(Number(vid)) ? parseInt(vid, 10) : null, + planned_duration_min: parseMin(it.planned_duration_min), + actual_duration_min: parseMin(it.actual_duration_min), + notes: it.notes?.trim() ? it.notes.trim() : null, + modifications: it.modifications?.trim() ? it.modifications.trim() : null + } + }) + .filter(Boolean) + })) +} + +function sectionPlannedMinutes(sec) { + return (sec.items || []).reduce((sum, it) => { + if (it.item_type !== 'exercise') return sum + const m = parseMin(it.planned_duration_min) + return sum + (m || 0) + }, 0) +} + function TrainingPlanningPage() { const { user } = useAuth() const [groups, setGroups] = useState([]) const [selectedGroupId, setSelectedGroupId] = useState('') const [units, setUnits] = useState([]) const [exercises, setExercises] = useState([]) + const [planTemplates, setPlanTemplates] = useState([]) const [loading, setLoading] = useState(true) const [showModal, setShowModal] = useState(false) const [editingUnit, setEditingUnit] = useState(null) + const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('') + const [quickTemplateId, setQuickTemplateId] = useState('') - // 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: '', @@ -33,7 +155,7 @@ function TrainingPlanningPage() { status: 'planned', notes: '', trainer_notes: '', - exercises: [] + sections: [defaultSection()] }) useEffect(() => { @@ -46,6 +168,15 @@ function TrainingPlanningPage() { } }, [selectedGroupId, startDate, endDate]) + const loadPlanTemplates = useCallback(async () => { + try { + const tpl = await api.listTrainingPlanTemplates() + setPlanTemplates(tpl) + } catch (e) { + console.error('Vorlagen laden:', e) + } + }, []) + const loadData = async () => { try { const [groupsData, exercisesData] = await Promise.all([ @@ -54,10 +185,10 @@ function TrainingPlanningPage() { ]) setGroups(groupsData) setExercises(exercisesData) + await loadPlanTemplates() - // Auto-select first group if trainer owns it if (groupsData.length > 0) { - const ownGroup = groupsData.find(g => g.trainer_id === user?.id) + const ownGroup = groupsData.find((g) => g.trainer_id === user?.id) if (ownGroup) { setSelectedGroupId(ownGroup.id) } else if (groupsData.length === 1) { @@ -74,9 +205,12 @@ function TrainingPlanningPage() { const loadUnits = async () => { if (!selectedGroupId) return - try { - const unitsData = await api.listTrainingUnits({ group_id: selectedGroupId, start_date: startDate, end_date: endDate }) + 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) @@ -88,15 +222,17 @@ function TrainingPlanningPage() { 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), + const body = { + group_id: parseInt(selectedGroupId, 10), planned_date: date - }) + } + if (quickTemplateId) { + body.plan_template_id = parseInt(quickTemplateId, 10) + } + await api.quickCreateTrainingUnit(body) await loadUnits() } catch (err) { alert('Fehler beim Erstellen: ' + err.message) @@ -108,10 +244,9 @@ function TrainingPlanningPage() { alert('Bitte wähle zuerst eine Trainingsgruppe') return } - - const group = groups.find(g => g.id === parseInt(selectedGroupId)) - + const group = groups.find((g) => g.id === parseInt(selectedGroupId, 10)) setEditingUnit(null) + setDraftPlanTemplateId('') setFormData({ group_id: selectedGroupId, planned_date: today, @@ -125,15 +260,36 @@ function TrainingPlanningPage() { status: 'planned', notes: '', trainer_notes: '', - exercises: [] + sections: [defaultSection('Hauptteil')] }) setShowModal(true) } + const applyTemplateFromSelect = async (templateId) => { + setDraftPlanTemplateId(templateId) + if (!templateId) return + try { + const tpl = await api.getTrainingPlanTemplate(parseInt(templateId, 10)) + setFormData((fd) => ({ + ...fd, + sections: (tpl.sections || []).length + ? tpl.sections.map((s) => ({ + title: s.title, + guidance_notes: s.guidance_text || '', + items: [] + })) + : [defaultSection()] + })) + } catch (err) { + alert('Vorlage laden: ' + err.message) + } + } + const handleEdit = async (unit) => { try { const fullUnit = await api.getTrainingUnit(unit.id) setEditingUnit(fullUnit) + setDraftPlanTemplateId(fullUnit.plan_template_id ? String(fullUnit.plan_template_id) : '') setFormData({ group_id: fullUnit.group_id, planned_date: fullUnit.planned_date || '', @@ -143,11 +299,11 @@ function TrainingPlanningPage() { 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 || '', + attendance_count: fullUnit.attendance_count ?? '', status: fullUnit.status || 'planned', notes: fullUnit.notes || '', trainer_notes: fullUnit.trainer_notes || '', - exercises: fullUnit.exercises || [] + sections: normalizeUnitToForm(fullUnit) }) setShowModal(true) } catch (err) { @@ -155,9 +311,26 @@ function TrainingPlanningPage() { } } + const handleSaveAsTemplate = async () => { + const name = window.prompt('Name für die neue Trainingsvorlage (nur Abschnitts-Gliederung):') + if (!name?.trim()) return + try { + await api.createTrainingPlanTemplate({ + name: name.trim(), + sections: formData.sections.map((s) => ({ + title: s.title || 'Abschnitt', + guidance_text: s.guidance_notes?.trim() ? s.guidance_notes.trim() : null + })) + }) + await loadPlanTemplates() + alert('Vorlage gespeichert.') + } catch (err) { + alert('Speichern: ' + 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() @@ -168,36 +341,31 @@ function TrainingPlanningPage() { const handleSubmit = async (e) => { e.preventDefault() - if (!formData.group_id || !formData.planned_date) { alert('Gruppe und Datum sind Pflichtfelder') return } - try { + const sectionsPayload = buildSectionsPayload(formData.sections) const payload = { - ...formData, - group_id: parseInt(formData.group_id), - attendance_count: formData.attendance_count ? parseInt(formData.attendance_count) : null, - exercises: formData.exercises - .filter( - (ex) => - ex.exercise_id !== '' && - ex.exercise_id != null && - !Number.isNaN(Number(ex.exercise_id)) - ) - .map((ex, idx) => ({ - exercise_id: ex.exercise_id, - order_index: idx, - exercise_variant_id: - ex.exercise_variant_id !== '' && ex.exercise_variant_id != null - ? parseInt(ex.exercise_variant_id, 10) - : null, - 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 - })) + planned_date: formData.planned_date, + planned_time_start: formData.planned_time_start || null, + planned_time_end: formData.planned_time_end || null, + planned_focus: formData.planned_focus || null, + actual_date: formData.actual_date || null, + actual_time_start: formData.actual_time_start || null, + actual_time_end: formData.actual_time_end || null, + attendance_count: formData.attendance_count ? parseInt(formData.attendance_count, 10) : null, + status: formData.status || 'planned', + notes: formData.notes || null, + trainer_notes: formData.trainer_notes || null, + sections: sectionsPayload + } + if (!editingUnit) { + payload.group_id = parseInt(formData.group_id, 10) + if (draftPlanTemplateId) { + payload.plan_template_id = parseInt(draftPlanTemplateId, 10) + } } if (editingUnit) { @@ -205,7 +373,6 @@ function TrainingPlanningPage() { } else { await api.createTrainingUnit(payload) } - setShowModal(false) await loadUnits() } catch (err) { @@ -214,52 +381,90 @@ function TrainingPlanningPage() { } const updateFormField = (field, value) => { - setFormData(prev => ({ ...prev, [field]: value })) + setFormData((prev) => ({ ...prev, [field]: value })) } - const addExercise = () => { - setFormData(prev => ({ + const updateSectionField = (sIdx, field, value) => { + setFormData((prev) => ({ ...prev, - exercises: [...prev.exercises, { - exercise_id: '', - exercise_variant_id: '', - planned_duration_min: '', - actual_duration_min: '', - notes: '', - modifications: '' - }] + sections: prev.sections.map((s, i) => (i === sIdx ? { ...s, [field]: value } : s)) })) } - const updateExercise = (index, field, value) => { - setFormData(prev => ({ + const addSection = () => { + setFormData((prev) => ({ ...prev, - exercises: prev.exercises.map((ex, i) => { - if (i !== index) return ex - const next = { ...ex, [field]: value } - if (field === 'exercise_id') { - next.exercise_variant_id = '' - } - return next + sections: [...prev.sections, defaultSection(`Abschnitt ${prev.sections.length + 1}`)] + })) + } + + const removeSection = (sIdx) => { + setFormData((prev) => { + const next = prev.sections.filter((_, i) => i !== sIdx) + return { ...prev, sections: next.length ? next : [defaultSection()] } + }) + } + + const moveSection = (sIdx, dir) => { + setFormData((prev) => { + const ta = sIdx + dir + if (ta < 0 || ta >= prev.sections.length) return prev + const copy = [...prev.sections] + ;[copy[sIdx], copy[ta]] = [copy[ta], copy[sIdx]] + return { ...prev, sections: copy } + }) + } + + const addItem = (sIdx, kind) => { + setFormData((prev) => ({ + ...prev, + sections: prev.sections.map((s, i) => { + if (i !== sIdx) return s + const row = kind === 'note' ? noteRow() : exerciseRow() + return { ...s, items: [...s.items, row] } }) })) } - const removeExercise = (index) => { - setFormData(prev => ({ + const updateItem = (sIdx, iIdx, field, value) => { + setFormData((prev) => ({ ...prev, - exercises: prev.exercises.filter((_, i) => i !== index) + sections: prev.sections.map((s, si) => { + if (si !== sIdx) return s + return { + ...s, + items: s.items.map((it, ii) => { + if (ii !== iIdx) return it + const next = { ...it, [field]: value } + if (field === 'exercise_id') next.exercise_variant_id = '' + return next + }) + } + }) })) } - const moveExercise = (index, direction) => { - const newExercises = [...formData.exercises] - const target = index + direction + const removeItem = (sIdx, iIdx) => { + setFormData((prev) => ({ + ...prev, + sections: prev.sections.map((s, i) => + i !== sIdx ? s : { ...s, items: s.items.filter((_, j) => j !== iIdx) } + ) + })) + } - if (target < 0 || target >= newExercises.length) return - - [newExercises[index], newExercises[target]] = [newExercises[target], newExercises[index]] - setFormData(prev => ({ ...prev, exercises: newExercises })) + const moveItem = (sIdx, iIdx, dir) => { + setFormData((prev) => ({ + ...prev, + sections: prev.sections.map((s, si) => { + if (si !== sIdx) return s + const items = [...s.items] + const ta = iIdx + dir + if (ta < 0 || ta >= items.length) return s + ;[items[iIdx], items[ta]] = [items[ta], items[iIdx]] + return { ...s, items } + }) + })) } if (loading) { @@ -271,16 +476,21 @@ function TrainingPlanningPage() { ) } - const selectedGroup = groups.find(g => g.id === parseInt(selectedGroupId)) + const selectedGroup = groups.find((g) => g.id === parseInt(selectedGroupId, 10)) return (

Trainingsplanung

- {/* Group & Date Controls */}
-
+
setQuickTemplateId(e.target.value)} + > + + {planTemplates.map((t) => ( + + ))} + + +
)} - {/* Units List */} {!selectedGroupId ? (

@@ -356,59 +599,78 @@ function TrainingPlanningPage() {

) : (
- {units.map(unit => ( + {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_time_start && + ` · ${unit.planned_time_start.slice(0, 5)} - ${unit.planned_time_end?.slice(0, 5)}`}

{unit.planned_focus && ( -

- 🎯 {unit.planned_focus} +

+ Fokus: {unit.planned_focus}

)}
- - {unit.status === 'planned' && '📅 Geplant'} - {unit.status === 'completed' && '✓ Durchgeführt'} - {unit.status === 'cancelled' && '✗ Abgesagt'} - - {unit.attendance_count !== null && ( - - 👥 {unit.attendance_count} Teilnehmer + 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'} + + {unit.attendance_count !== null && unit.attendance_count !== undefined && ( + + {unit.attendance_count} Teilnehmer )}
-
@@ -426,41 +688,71 @@ function TrainingPlanningPage() {
)} - {/* Create/Edit Modal */} {showModal && ( -
-
-

+
+
+

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

+ {!editingUnit && ( +
+ + +

+ Lädt die Abschnitte und Hinweise aus der Vorlage; Übungen fügst du hier ein. +

+
+ )} +
- {/* Basic Info */}

Planung

-
+
updateFormField('planned_focus', e.target.value)} - placeholder="z.B. Kihon Grundlagen, Kata Heian Shodan" + placeholder="z.B. Grundlagen, Kinder altersgerecht" />
- {/* Exercises */} -

Übungen

+
+

Abschnitte & Übungen

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

- Noch keine Übungen hinzugefügt -

- ) : ( -
- {formData.exercises.map((ex, idx) => { - const picked = exercises.find((e) => e.id === ex.exercise_id) - const variantOpts = Array.isArray(picked?.variants) ? picked.variants : [] - - return ( -
{ + const planMin = sectionPlannedMinutes(sec) + return ( +
-
+ borderRadius: '10px', + border: '1px solid var(--border, rgba(0,0,0,0.08))' + }} + > +
+ updateSectionField(sIdx, 'title', e.target.value)} + placeholder="Abschnittstitel (z. B. Aufwärmen)" + /> +
- -
- - -
- - updateExercise(idx, 'planned_duration_min', e.target.value)} - placeholder="min" - style={{ margin: 0 }} - /> - -
- ) - })} -
- )} +