diff --git a/backend/migrations/037_training_framework_blueprint_units.sql b/backend/migrations/037_training_framework_blueprint_units.sql new file mode 100644 index 0000000..25953f6 --- /dev/null +++ b/backend/migrations/037_training_framework_blueprint_units.sql @@ -0,0 +1,130 @@ +-- Migration 037: Rahmen-Slot-„Blueprint“ = eine training_units-Zeile (Ablauf wie echte Einheit) +-- training_framework_slot_exercises migriert nach training_unit_sections / training_unit_section_items, +-- dann entfernt. + +-- ── Neue Spalten ─────────────────────────────────────────────────────────────── +ALTER TABLE training_units + ADD COLUMN IF NOT EXISTS framework_slot_id INT REFERENCES training_framework_slots(id) + ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS origin_framework_slot_id INT REFERENCES training_framework_slots(id) + ON DELETE SET NULL; + +-- Genau eine Blueprint-Einheit pro Slot (PostgreSQL UNIQUE erlaubt mehrere NULLs — hier Partial Index) +DROP INDEX IF EXISTS uq_training_units_blueprint_slot; + +CREATE UNIQUE INDEX uq_training_units_blueprint_slot + ON training_units(framework_slot_id) + WHERE framework_slot_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_training_units_framework_blueprint_calendar + ON training_units(planned_date, group_id) + WHERE framework_slot_id IS NULL; + +CREATE INDEX IF NOT EXISTS idx_training_units_origin_slot + ON training_units(origin_framework_slot_id) + WHERE origin_framework_slot_id IS NOT NULL; + +-- ── Nullable für Blueprint-Zeilen ──────────────────────────────────────────── +ALTER TABLE training_units ALTER COLUMN planned_date DROP NOT NULL; +ALTER TABLE training_units ALTER COLUMN group_id DROP NOT NULL; + +-- ── Für jeden Slot eine Blueprint-Einheit; vorhandene Übungen in erste Sektion ─ +DO $$ +DECLARE + rec RECORD; + new_uid INTEGER; + new_sec INTEGER; +BEGIN + FOR rec IN + SELECT s.id AS sid, fp.created_by AS fp_created_by + FROM training_framework_slots s + JOIN training_framework_programs fp ON fp.id = s.framework_program_id + LOOP + IF EXISTS (SELECT 1 FROM training_units tu WHERE tu.framework_slot_id = rec.sid) THEN + CONTINUE; + END IF; + + INSERT INTO training_units ( + group_id, + planned_date, + planned_time_start, + planned_time_end, + planned_focus, + status, + notes, + trainer_notes, + created_by, + plan_template_id, + framework_slot_id + ) + VALUES ( + NULL, + NULL, + NULL, + NULL, + NULL, + 'planned', + NULL, + NULL, + rec.fp_created_by, + NULL, + rec.sid + ) + RETURNING id INTO new_uid; + + INSERT INTO training_unit_sections ( + training_unit_id, + order_index, + title, + guidance_notes + ) + VALUES (new_uid, 0, 'Ablauf', NULL) + RETURNING id INTO new_sec; + + 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 + new_sec, + sf.order_index, + 'exercise'::character varying(20), + sf.exercise_id, + sf.exercise_variant_id, + NULL::integer, + NULL::integer, + NULL::text, + NULL::text, + NULL::text + FROM training_framework_slot_exercises sf + WHERE sf.slot_id = rec.sid + ORDER BY sf.order_index; + END LOOP; +END $$; + +DROP TABLE IF EXISTS training_framework_slot_exercises; + +ALTER TABLE training_units DROP CONSTRAINT IF EXISTS chk_training_units_blueprint_vs_scheduled; + +ALTER TABLE training_units + ADD CONSTRAINT chk_training_units_blueprint_vs_scheduled CHECK ( + ( + framework_slot_id IS NOT NULL + AND group_id IS NULL + AND planned_date IS NULL + AND origin_framework_slot_id IS NULL + ) + OR ( + framework_slot_id IS NULL + AND group_id IS NOT NULL + AND planned_date IS NOT NULL + ) + ); diff --git a/backend/routers/training_framework_programs.py b/backend/routers/training_framework_programs.py index 82ed4fe..2785ed0 100644 --- a/backend/routers/training_framework_programs.py +++ b/backend/routers/training_framework_programs.py @@ -14,7 +14,10 @@ from db import get_db, get_cursor, r2d from routers.training_planning import ( _has_planning_role, + _hydrate_training_unit_payload, _optional_positive_int, + _insert_sections_from_legacy_exercises, + _replace_unit_sections, _validate_variant_for_exercise, ) @@ -35,23 +38,6 @@ def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> Dic return row -def _fetch_slot_exercises(cur, slot_id: int) -> List[Dict[str, Any]]: - cur.execute( - """ - SELECT t.id, t.slot_id, t.exercise_id, t.exercise_variant_id, t.order_index, - e.title AS exercise_title, - ev.variant_name AS exercise_variant_name - FROM training_framework_slot_exercises t - LEFT JOIN exercises e ON e.id = t.exercise_id - LEFT JOIN exercise_variants ev ON ev.id = t.exercise_variant_id - WHERE t.slot_id = %s - ORDER BY t.order_index - """, - (slot_id,), - ) - return [r2d(x) for x in cur.fetchall()] - - def _training_type_ids(cur, framework_id: int) -> List[int]: cur.execute( """ @@ -101,7 +87,22 @@ def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]: ) slots = [r2d(s) for s in cur.fetchall()] for s in slots: - s["exercises"] = _fetch_slot_exercises(cur, s["id"]) + cur.execute( + "SELECT id FROM training_units WHERE framework_slot_id = %s", + (s["id"],), + ) + row_b = cur.fetchone() + if not row_b: + s["blueprint_training_unit_id"] = None + s["sections"] = [] + s["exercises"] = [] + continue + uid = row_b["id"] + s["blueprint_training_unit_id"] = uid + unit_min: Dict[str, Any] = {"id": uid} + _hydrate_training_unit_payload(cur, unit_min) + s["sections"] = unit_min.get("sections", []) + s["exercises"] = unit_min.get("exercises", []) row["slots"] = slots row["training_type_ids"] = _training_type_ids(cur, fid) row["target_group_ids"] = _target_group_ids(cur, fid) @@ -191,10 +192,26 @@ def _insert_goal_rows(cur, framework_id: int, goals_in: List[Any]) -> None: ) -def _insert_slots_and_exercises( +def _insert_default_blueprint_section(cur, blueprint_unit_id: int) -> None: + """Leerer Ablauf, falls noch keine Sektionen existieren.""" + cur.execute( + "SELECT 1 FROM training_unit_sections WHERE training_unit_id = %s", + (blueprint_unit_id,), + ) + if cur.fetchone(): + return + _replace_unit_sections( + cur, + blueprint_unit_id, + [{"title": "Ablauf", "order_index": 0, "guidance_notes": None, "items": []}], + ) + + +def _insert_slots_and_blueprints( cur, framework_id: int, slots_in: Optional[List[Any]], + profile_id: int, ) -> None: if slots_in is None: return @@ -221,25 +238,44 @@ def _insert_slots_and_exercises( ), ) sid = cur.fetchone()["id"] - ex_items = slot.get("exercises") or [] - for ej, raw in enumerate(ex_items): - 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) - oidx = raw.get("order_index") - if oidx is None: - oidx = ej - cur.execute( - """ - INSERT INTO training_framework_slot_exercises ( - slot_id, exercise_id, exercise_variant_id, order_index - ) VALUES (%s, %s, %s, %s) - """, - (sid, eid, vid, int(oidx)), + + cur.execute( + """ + INSERT INTO training_units ( + group_id, planned_date, + planned_time_start, planned_time_end, planned_focus, + status, notes, trainer_notes, + created_by, plan_template_id, framework_slot_id + ) VALUES ( + NULL, NULL, + NULL, NULL, NULL, + 'planned', NULL, NULL, + %s, NULL, %s ) + RETURNING id + """, + (profile_id, sid), + ) + bid = cur.fetchone()["id"] + + sections_in = slot.get("sections") + exercises_in = slot.get("exercises") + + if sections_in is not None: + if len(sections_in) == 0: + _insert_default_blueprint_section(cur, bid) + else: + _replace_unit_sections(cur, bid, sections_in) + elif exercises_in is not None and len(exercises_in) > 0: + for raw in exercises_in: + eid = raw.get("exercise_id") + if not eid: + continue + vid = _optional_positive_int(raw.get("exercise_variant_id"), "exercise_variant_id") + _validate_variant_for_exercise(cur, int(eid), vid) + _insert_sections_from_legacy_exercises(cur, bid, exercises_in) + else: + _insert_default_blueprint_section(cur, bid) @router.get("/training-framework-programs") @@ -334,7 +370,7 @@ def create_training_framework_program(data: dict, session=Depends(require_auth)) ) fid = cur.fetchone()["id"] _insert_goal_rows(cur, fid, goals_in) - _insert_slots_and_exercises(cur, fid, slots_in) + _insert_slots_and_blueprints(cur, fid, slots_in, profile_id) _replace_training_types(cur, fid, tt_ids) _replace_target_groups(cur, fid, tg_ids) conn.commit() @@ -431,7 +467,7 @@ def update_training_framework_program(framework_id: int, data: dict, session=Dep "DELETE FROM training_framework_slots WHERE framework_program_id = %s", (framework_id,), ) - _insert_slots_and_exercises(cur, framework_id, data.get("slots") or []) + _insert_slots_and_blueprints(cur, framework_id, data.get("slots") or [], profile_id) if header_fields or "goals" in data or "slots" in data or "training_type_ids" in data or "target_group_ids" in data: cur.execute( diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index 6208193..9fd7e1d 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -67,10 +67,13 @@ def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str) 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 + SELECT tu.id, tu.created_by, tu.group_id, tu.plan_template_id, tu.framework_slot_id, + tg.trainer_id, tg.co_trainer_ids, + fwp.created_by AS framework_created_by FROM training_units tu LEFT JOIN training_groups tg ON tu.group_id = tg.id + LEFT JOIN training_framework_slots fs ON fs.id = tu.framework_slot_id + LEFT JOIN training_framework_programs fwp ON fwp.id = fs.framework_program_id WHERE tu.id = %s """, (unit_id,), @@ -84,6 +87,16 @@ def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]: def _assert_training_unit_permission( cur, unit_row: Dict[str, Any], profile_id: int, role: str ) -> None: + if unit_row.get("framework_slot_id"): + if role in ["admin", "superadmin"]: + return + if unit_row.get("created_by") == profile_id: + return + fw_by = unit_row.get("framework_created_by") + if fw_by is not None and fw_by == profile_id: + return + raise HTTPException(status_code=403, detail="Keine Berechtigung") + co_trainers = unit_row["co_trainer_ids"] or [] if role not in ["admin", "superadmin"]: if ( @@ -138,6 +151,116 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]: return secs +def _sections_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]: + """Sektionen/Items für eine tiefe Kopie (ohne DB-IDs / Join-Felder).""" + secs = _fetch_sections(cur, unit_id) + out: List[Dict[str, Any]] = [] + for sec in secs: + items_clean: List[Dict[str, Any]] = [] + for it in sorted(sec.get("items", []), key=lambda x: x.get("order_index", 0)): + itype = it.get("item_type") or ("exercise" if it.get("exercise_id") else "note") + oix = it.get("order_index") + if itype == "note": + items_clean.append( + { + "item_type": "note", + "order_index": oix, + "note_body": it.get("note_body") or "", + } + ) + continue + if itype != "exercise" or not it.get("exercise_id"): + continue + items_clean.append( + { + "item_type": "exercise", + "order_index": oix, + "exercise_id": it["exercise_id"], + "exercise_variant_id": it.get("exercise_variant_id"), + "planned_duration_min": it.get("planned_duration_min"), + "actual_duration_min": it.get("actual_duration_min"), + "notes": it.get("notes"), + "modifications": it.get("modifications"), + } + ) + out.append( + { + "title": sec.get("title"), + "order_index": sec.get("order_index"), + "guidance_notes": sec.get("guidance_notes"), + "items": items_clean, + } + ) + return out + + +def _copy_blueprint_into_scheduled_unit( + cur, + blueprint_unit_id: int, + group_id: int, + planned_date: str, + profile_id: int, + origin_framework_slot_id: Optional[int], +) -> int: + cur.execute( + """ + INSERT INTO training_units ( + group_id, + planned_date, + planned_time_start, + planned_time_end, + planned_focus, + actual_date, + actual_time_start, + actual_time_end, + attendance_count, + status, + notes, + trainer_notes, + created_by, + plan_template_id, + origin_framework_slot_id, + framework_slot_id + ) + SELECT + %s, + %s, + planned_time_start, + planned_time_end, + planned_focus, + NULL::DATE, + NULL::TIME WITHOUT TIME ZONE, + NULL::TIME WITHOUT TIME ZONE, + NULL::INT, + COALESCE(status, 'planned'), + notes, + trainer_notes, + %s, + NULL::INT, + %s, + NULL::INT + FROM training_units + WHERE id = %s + AND framework_slot_id IS NOT NULL + RETURNING id + """, + ( + group_id, + planned_date, + profile_id, + origin_framework_slot_id, + blueprint_unit_id, + ), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Blueprint-Einheit nicht gefunden") + nu = row["id"] + cloned = _sections_clone_payload(cur, blueprint_unit_id) + _replace_unit_sections(cur, nu, cloned) + return nu + + 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)): @@ -527,6 +650,8 @@ def list_training_units( where.append("(tu.created_by = %s OR tg.trainer_id = %s)") params.extend([profile_id, profile_id]) + where.append("tu.framework_slot_id IS NULL") + if group_id: where.append("tu.group_id = %s") params.append(group_id) @@ -586,12 +711,31 @@ def get_training_unit(unit_id: int, session=Depends(require_auth)): unit = r2d(unit) - cur.execute("SELECT trainer_id FROM training_groups WHERE id = %s", (unit["group_id"],)) - group = cur.fetchone() + if unit.get("framework_slot_id"): + if role not in ["admin", "superadmin"]: + cur.execute( + """ + SELECT fp.created_by FROM training_framework_slots s + JOIN training_framework_programs fp ON fp.id = s.framework_program_id + WHERE s.id = %s + """, + (unit["framework_slot_id"],), + ) + fr = cur.fetchone() + cb = fr["created_by"] if fr else None + if unit["created_by"] != profile_id and cb != profile_id: + raise HTTPException(status_code=403, detail="Keine Berechtigung") + else: + gid = unit.get("group_id") + if not gid: + raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden") - 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") + cur.execute("SELECT trainer_id FROM training_groups WHERE id = %s", (gid,)) + 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(status_code=403, detail="Keine Berechtigung") _hydrate_training_unit_payload(cur, unit) return unit @@ -671,6 +815,8 @@ def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth) unit_row = _training_unit_guard_row(cur, unit_id) _assert_training_unit_permission(cur, unit_row, profile_id, role) + is_blueprint = unit_row.get("framework_slot_id") is not None + 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, ""): @@ -690,43 +836,81 @@ def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth) else: trainer_notes_val = data.get("trainer_notes") - cur.execute( - """ - UPDATE training_units SET - planned_date = COALESCE(%s, planned_date), - 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, - 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"), - trainer_notes_val, - tpl_id_val, - unit_id, - ), - ) + if is_blueprint: + if data.get("reset_from_template"): + raise HTTPException( + status_code=400, + detail="Rahmen-Blueprints können nicht aus einer Vorlage zurückgesetzt werden", + ) + if tpl_upd not in (None, ""): + raise HTTPException( + status_code=400, + detail="plan_template_id ist bei Rahmen-Blueprints nicht zulässig", + ) + blueprint_fields = [] + blueprint_params: List[Any] = [] + if "planned_focus" in data: + blueprint_fields.append("planned_focus = %s") + blueprint_params.append(data.get("planned_focus")) + if "planned_time_start" in data: + blueprint_fields.append("planned_time_start = %s") + blueprint_params.append(data.get("planned_time_start")) + if "planned_time_end" in data: + blueprint_fields.append("planned_time_end = %s") + blueprint_params.append(data.get("planned_time_end")) + if "notes" in data: + blueprint_fields.append("notes = %s") + blueprint_params.append(data.get("notes")) + blueprint_fields.append("trainer_notes = %s") + blueprint_params.append(trainer_notes_val) + blueprint_params.append(unit_id) + cur.execute( + f""" + UPDATE training_units SET + {", ".join(blueprint_fields)}, + updated_at = NOW() + WHERE id = %s + """, + tuple(blueprint_params), + ) + else: + cur.execute( + """ + UPDATE training_units SET + planned_date = COALESCE(%s, planned_date), + 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, + 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"), + trainer_notes_val, + tpl_id_val, + unit_id, + ), + ) content_handled = False - if data.get("reset_from_template"): + if not is_blueprint and data.get("reset_from_template"): tid = tpl_id_val or unit_row.get("plan_template_id") if not tid: raise HTTPException( @@ -761,7 +945,7 @@ def delete_training_unit(unit_id: int, session=Depends(require_auth)): cur = get_cursor(conn) cur.execute( - "SELECT created_by FROM training_units WHERE id = %s", + "SELECT created_by, framework_slot_id FROM training_units WHERE id = %s", (unit_id,), ) @@ -770,6 +954,12 @@ def delete_training_unit(unit_id: int, session=Depends(require_auth)): if not unit: raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden") + if unit.get("framework_slot_id"): + raise HTTPException( + status_code=400, + detail="Blueprint-Einheiten werden über das Rahmenprogramm verwaltet, nicht hier gelöscht.", + ) + _assert_delete_training_unit(role, unit["created_by"], profile_id) cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,)) @@ -778,6 +968,74 @@ def delete_training_unit(unit_id: int, session=Depends(require_auth)): return {"ok": True} +@router.post("/training-units/from-framework-slot") +def create_training_unit_from_framework_slot(data: dict, session=Depends(require_auth)): + """Geplante Einheit aus Rahmen-Slot-Blueprint kopieren (Lineage über origin_framework_slot_id).""" + profile_id = session["profile_id"] + role = session.get("role") + + if not _has_planning_role(role): + raise HTTPException(status_code=403, detail="Nur Planungsberechtigte dürfen Trainingseinheiten erstellen") + + raw_sid = data.get("framework_slot_id") + try: + slot_id = int(raw_sid) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="framework_slot_id ist ungültig") + if slot_id < 1: + raise HTTPException(status_code=400, detail="framework_slot_id ist ungültig") + + group_id = data.get("group_id") + planned_date = data.get("planned_date") + if not group_id or not planned_date: + raise HTTPException(status_code=400, detail="group_id und planned_date sind Pflichtfelder") + + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute( + """ + SELECT fp.created_by FROM training_framework_slots s + JOIN training_framework_programs fp ON fp.id = s.framework_program_id + WHERE s.id = %s + """, + (slot_id,), + ) + fw_row = cur.fetchone() + if not fw_row: + raise HTTPException(status_code=404, detail="Rahmen-Slot nicht gefunden") + + if role not in ["admin", "superadmin"]: + if fw_row["created_by"] is not None and fw_row["created_by"] != profile_id: + raise HTTPException( + status_code=403, + detail="Keine Berechtigung für dieses Rahmenprogramm", + ) + + cur.execute( + "SELECT id FROM training_units WHERE framework_slot_id = %s", + (slot_id,), + ) + blueprint = cur.fetchone() + if not blueprint: + raise HTTPException(status_code=404, detail="Keine Blueprint-Einheit für diesen Slot") + + _can_access_group_for_create(cur, int(group_id), profile_id, role) + + new_id = _copy_blueprint_into_scheduled_unit( + cur, + int(blueprint["id"]), + int(group_id), + str(planned_date), + profile_id, + slot_id, + ) + + conn.commit() + + return get_training_unit(new_id, session) + + @router.post("/training-units/quick-create") def quick_create_training_unit(data: dict, session=Depends(require_auth)): profile_id = session["profile_id"] diff --git a/backend/version.py b/backend/version.py index 7ba8959..3bf97bb 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.9" +APP_VERSION = "0.8.10" BUILD_DATE = "2026-05-05" -DB_SCHEMA_VERSION = "20260505036" +DB_SCHEMA_VERSION = "20260505037" MODULE_VERSIONS = { "auth": "1.0.0", @@ -14,7 +14,7 @@ MODULE_VERSIONS = { "exercises": "2.3.0", # Progressionsgraph: Sequenz-API, Varianten-Knoten, UX Ketten (Migration 034) "training_units": "0.1.0", "training_programs": "0.1.0", - "planning": "0.4.0", + "planning": "0.5.0", "import_wiki": "1.0.0", "admin": "1.0.0", "membership": "1.0.0", @@ -23,6 +23,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.10", + "date": "2026-05-05", + "changes": [ + "DB 037: Rahmen-Slot-Blueprints als training_units (framework_slot_id); migration training_framework_slot_exercises → Sektionen/Items; origin_framework_slot_id für Kopien", + "API: Rahmen-Slots mit sections/exercises aus Blueprint; Kalender list_training_units ohne Blueprints; POST /api/training-units/from-framework-slot", + ], + }, { "version": "0.8.9", "date": "2026-05-05", diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 209645f..1222b0e 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -924,6 +924,14 @@ export async function quickCreateTrainingUnit(data) { }) } +/** Rahmen-Slot → geplante Einheit (tiefe Kopie, origin_framework_slot_id). */ +export async function createTrainingUnitFromFrameworkSlot(data) { + return request('/api/training-units/from-framework-slot', { + method: 'POST', + body: JSON.stringify(data) + }) +} + export async function listTrainingPlanTemplates() { return request('/api/training-plan-templates') }