diff --git a/backend/migrations/056_combination_exercises.sql b/backend/migrations/056_combination_exercises.sql new file mode 100644 index 0000000..781ec1b --- /dev/null +++ b/backend/migrations/056_combination_exercises.sql @@ -0,0 +1,33 @@ +-- Migration 056: Kombinationsübungen (Phase 2 MVP) — Slots + Pool-Kandidaten +-- Fachgrundlage: functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md §6 + +ALTER TABLE exercises + ADD COLUMN IF NOT EXISTS exercise_kind VARCHAR(20) NOT NULL DEFAULT 'simple' + CHECK (exercise_kind IN ('simple', 'combination')), + ADD COLUMN IF NOT EXISTS method_archetype VARCHAR(80), + ADD COLUMN IF NOT EXISTS method_profile JSONB NOT NULL DEFAULT '{}'::jsonb; + +CREATE INDEX IF NOT EXISTS idx_exercises_exercise_kind ON exercises(exercise_kind); +CREATE INDEX IF NOT EXISTS idx_exercises_method_archetype ON exercises(method_archetype) + WHERE method_archetype IS NOT NULL; + +CREATE TABLE IF NOT EXISTS combination_exercise_slots ( + id SERIAL PRIMARY KEY, + exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, + slot_index INT NOT NULL, + title VARCHAR(200), + UNIQUE (exercise_id, slot_index) +); + +CREATE INDEX IF NOT EXISTS idx_combination_exercise_slots_exercise ON combination_exercise_slots(exercise_id); + +CREATE TABLE IF NOT EXISTS combination_slot_candidates ( + id SERIAL PRIMARY KEY, + slot_id INT NOT NULL REFERENCES combination_exercise_slots(id) ON DELETE CASCADE, + candidate_exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, + sort_order INT NOT NULL DEFAULT 0, + UNIQUE (slot_id, candidate_exercise_id) +); + +CREATE INDEX IF NOT EXISTS idx_combination_slot_candidates_slot ON combination_slot_candidates(slot_id); +CREATE INDEX IF NOT EXISTS idx_combination_slot_candidates_exercise ON combination_slot_candidates(candidate_exercise_id); diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 39e0075..5509383 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -10,12 +10,13 @@ import logging import os import re from pathlib import Path -from typing import Any, Dict, Iterator, List, Optional, Tuple +from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple from urllib.parse import quote from fastapi import APIRouter, HTTPException, Depends, Query, Request, UploadFile, File, Form from fastapi.responses import FileResponse, Response, StreamingResponse from pydantic import BaseModel, Field, model_validator +from psycopg2.extras import Json from db import get_db, get_cursor, r2d from club_tenancy import ( @@ -198,6 +199,26 @@ def _upload_limit_bytes(tenant: TenantContext) -> int: # Pydantic Models # ============================================================================ +# Archetyp-IDs (Maschinenlesbare Ablaufmuster) — konsistent zur technischen Entwurfsspezifikation +COMBINATION_ARCHETYPE_IDS = frozenset( + { + "circuit_rotate_time", + "circuit_all_parallel", + "sequence_linear", + "station_parcour", + "pair_superset", + "time_domain_interval", + "free_method_block", + } +) + + +class CombinationSlotIn(BaseModel): + slot_index: int = Field(ge=0, le=99) + title: Optional[str] = Field(None, max_length=200) + candidate_exercise_ids: list[int] = Field(default_factory=list) + + class ExerciseCreate(BaseModel): # Basis-Felder (goal/execution: DB-Constraint mind. eines; Wiki oft nur eines) title: str = Field(..., min_length=3, max_length=300) @@ -231,6 +252,12 @@ class ExerciseCreate(BaseModel): status: str = "draft" club_id: Optional[int] = None + # Kombinationsübung (Phase 2) + exercise_kind: Literal["simple", "combination"] = "simple" + method_archetype: Optional[str] = Field(None, max_length=80) + method_profile: Dict[str, Any] = Field(default_factory=dict) + combination_slots: list[CombinationSlotIn] = Field(default_factory=list) + @model_validator(mode="after") def normalize_goal_execution(self): g = (self.goal or "").strip() or None @@ -270,6 +297,11 @@ class ExerciseUpdate(BaseModel): # Vereins-Übung: fehlende Copyrights an Datei-Assets nach Prompt-Text setzen (PUT-Retry) default_club_media_copyright: Optional[str] = Field(default=None, max_length=2000) + exercise_kind: Optional[Literal["simple", "combination"]] = None + method_archetype: Optional[str] = Field(None, max_length=80) + method_profile: Optional[Dict[str, Any]] = None + combination_slots: Optional[list[CombinationSlotIn]] = None + @model_validator(mode="after") def normalize_goal_execution(self): if self.goal is not None: @@ -811,6 +843,137 @@ def _resolve_local_media_file( return path_under_media_root(media_root, asset_storage_key) return _abs_media_path(file_path_db or "", media_root) if file_path_db else None + +def _normalize_method_profile_store(raw: Any) -> Dict[str, Any]: + if raw is None: + return {} + if isinstance(raw, dict): + return raw + raise HTTPException(status_code=400, detail="method_profile muss ein JSON-Objekt sein") + + +def _validate_archetype_for_kind(kind: str, archetype: Optional[str]) -> None: + if kind != "combination": + return + if archetype is None or not str(archetype).strip(): + return + a = str(archetype).strip() + if a not in COMBINATION_ARCHETYPE_IDS: + raise HTTPException( + status_code=400, + detail=( + "Unbekannter method_archetype. Erlaubt: " + + ", ".join(sorted(COMBINATION_ARCHETYPE_IDS)) + ), + ) + + +def _assert_candidate_exercises_for_combination(cur, tenant: TenantContext, ids: List[int]) -> None: + if not ids: + return + seen: set[int] = set() + for cid_raw in ids: + cid = int(cid_raw) + if cid in seen: + continue + seen.add(cid) + cur.execute( + """SELECT id, exercise_kind, visibility, club_id, created_by + FROM exercises WHERE id = %s""", + (cid,), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=400, detail=f"Slot-Verweis: Übung #{cid} nicht gefunden") + rd = r2d(row) + k = str(rd.get("exercise_kind") or "simple").strip().lower() + if k != "simple": + raise HTTPException( + status_code=400, + detail=f"Slot-Verweis: Übung #{cid} ist eine Kombinationsübung — nur Einzelübungen erlaubt", + ) + if not library_content_visible_to_profile( + cur, + tenant.profile_id, + rd.get("visibility"), + rd.get("club_id"), + rd.get("created_by"), + tenant.global_role, + ): + raise HTTPException(status_code=403, detail=f"Slot-Verweis: keine Leserechte für Übung #{cid}") + + +def _validate_and_normalize_combination_slots_payload( + cur, + tenant: TenantContext, + slots: Optional[List[CombinationSlotIn]], +) -> List[Tuple[int, Optional[str], List[int]]]: + if slots is None: + return [] + normalized: Dict[int, Tuple[Optional[str], List[int]]] = {} + for s in sorted(slots, key=lambda x: x.slot_index): + cid_list_raw = list(s.candidate_exercise_ids or []) + cid_list: List[int] = [] + for x in cid_list_raw: + cid_list.append(int(x)) + title = ((s.title or "").strip()) or None + normalized[int(s.slot_index)] = (title, cid_list) + out: List[Tuple[int, Optional[str], List[int]]] = [] + for idx in sorted(normalized.keys()): + title, cands = normalized[idx] + if not cands: + raise HTTPException( + status_code=400, + detail=f"Station (Index {idx}): mindestens eine Einzelübung (Pool) ist erforderlich", + ) + out.append((idx, title, cands)) + return out + + +def replace_combination_slots( + cur, + tenant: TenantContext, + exercise_id: int, + slots_norm: List[Tuple[int, Optional[str], List[int]]], +) -> None: + flat_ids = [cid for _, __, xs in slots_norm for cid in xs] + _assert_candidate_exercises_for_combination(cur, tenant, flat_ids) + cur.execute("DELETE FROM combination_exercise_slots WHERE exercise_id = %s", (exercise_id,)) + for slot_index, title, cand_ids in slots_norm: + cur.execute( + """INSERT INTO combination_exercise_slots (exercise_id, slot_index, title) + VALUES (%s, %s, %s) RETURNING id""", + (exercise_id, slot_index, title), + ) + row = cur.fetchone() + sid = row["id"] if isinstance(row, dict) else row[0] + for so, cid in enumerate(cand_ids): + cur.execute( + """INSERT INTO combination_slot_candidates (slot_id, candidate_exercise_id, sort_order) + VALUES (%s, %s, %s)""", + (sid, int(cid), int(so)), + ) + + +def wipe_combination_structure(cur, exercise_id: int) -> None: + cur.execute("DELETE FROM combination_exercise_slots WHERE exercise_id = %s", (exercise_id,)) + + +def assert_exercise_not_combination(cur, exercise_id: int) -> None: + cur.execute( + "SELECT COALESCE(exercise_kind, 'simple') AS exercise_kind FROM exercises WHERE id = %s", + (exercise_id,), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Übung nicht gefunden") + if str(r2d(row).get("exercise_kind") or "simple").strip().lower() == "combination": + raise HTTPException( + status_code=400, + detail="Kombinationsübungen unterstützen keine Varianten.", + ) + + def enrich_exercise_detail(exercise_id: int, cur) -> dict: """ Lädt alle M:N Relations für eine Übung und gibt ein vollständiges @@ -933,6 +1096,48 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict: ) exercise["media"] = [r2d(r) for r in cur.fetchall()] + mp_raw = exercise.get("method_profile") + exercise["method_profile"] = mp_raw if isinstance(mp_raw, dict) else {} + exercise["exercise_kind"] = str(exercise.get("exercise_kind") or "simple").strip().lower() + + exercise["combination_slots"] = [] + if exercise["exercise_kind"] == "combination": + cur.execute( + """SELECT id, slot_index, title FROM combination_exercise_slots + WHERE exercise_id = %s ORDER BY slot_index ASC, id ASC""", + (exercise_id,), + ) + slot_rows = [r2d(r) for r in cur.fetchall()] + slots_out: List[dict] = [] + for sr in slot_rows: + slot_pk = sr["id"] + cur.execute( + """SELECT candidate_exercise_id FROM combination_slot_candidates + WHERE slot_id = %s ORDER BY sort_order ASC, id ASC""", + (slot_pk,), + ) + crows = cur.fetchall() + cids = [int(r2d(c)["candidate_exercise_id"]) for c in crows] + cand_meta: Dict[int, Optional[str]] = {} + if cids: + ph = ",".join(["%s"] * len(cids)) + cur.execute( + f"SELECT id, title FROM exercises WHERE id IN ({ph})", + tuple(cids), + ) + cand_meta = {int(r2d(x)["id"]): r2d(x).get("title") for x in cur.fetchall()} + slots_out.append( + { + "slot_index": sr["slot_index"], + "title": sr.get("title"), + "candidate_exercise_ids": cids, + "candidates": [ + {"exercise_id": cid, "title": cand_meta.get(cid)} for cid in cids + ], + } + ) + exercise["combination_slots"] = slots_out + return exercise @@ -1528,6 +1733,10 @@ def list_exercises( default=False, description="Nur Übungen, die vom aktuellen Profil angelegt wurden (created_by = Profil)", ), + exercise_kind_any: list[str] = Query( + default=[], + description="ODER: mind. einer dieser Übungsarten — simple oder combination", + ), tenant: TenantContext = Depends(get_tenant_context), ): """ @@ -1558,6 +1767,21 @@ def list_exercises( where.append("e.created_by = %s") params.append(profile_id) + ek_filtered: List[str] = [] + if exercise_kind_any: + for raw in exercise_kind_any: + s = str(raw or "").strip().lower() + if not s: + continue + if s not in ("simple", "combination"): + raise HTTPException(status_code=400, detail="exercise_kind_any: nur simple oder combination") + if s not in ek_filtered: + ek_filtered.append(s) + if ek_filtered: + ph = ",".join(["%s"] * len(ek_filtered)) + where.append(f"(LOWER(TRIM(COALESCE(e.exercise_kind::text,''))) IN ({ph}))") + params.extend(ek_filtered) + vis_list = _merge_str_any(visibility_any, visibility) if vis_list: ph = ",".join(["%s"] * len(vis_list)) @@ -1776,6 +2000,7 @@ def list_exercises( # Query (primary_focus_name für Listen-Ansicht gemäß Spec-Beispiel „focus_area“-Label) query = f""" SELECT e.id, e.title, e.summary, e.visibility, e.status, + e.exercise_kind, e.method_archetype, e.created_by, p.name as creator_name, e.club_id, c.name as club_name, e.created_at, e.updated_at, @@ -1829,6 +2054,7 @@ def list_exercises( out = [] for r in rows: d = r2d(r) + d["exercise_kind"] = str(d.get("exercise_kind") or "simple").strip().lower() pfn = d.get("primary_focus_name") d["focus_area"] = pfn d["focus_area_names"] = _coerce_json_str_list(d.get("focus_area_names")) @@ -1913,16 +2139,39 @@ def create_exercise( cur, profile_id, tenant.global_role, body.visibility, club_id ) + kind_clean = str(body.exercise_kind or "simple").strip().lower() + if kind_clean not in ("simple", "combination"): + raise HTTPException(status_code=400, detail="exercise_kind: simple oder combination") + + prof_dict = _normalize_method_profile_store(body.method_profile) + arch_raw = body.method_archetype + arch_val = (arch_raw.strip() if isinstance(arch_raw, str) and arch_raw.strip() else None) + _validate_archetype_for_kind(kind_clean, arch_val) + + slots_norm: List[Tuple[int, Optional[str], List[int]]] = [] + if kind_clean == "combination": + slots_norm = _validate_and_normalize_combination_slots_payload( + cur, tenant, body.combination_slots or [] + ) + if not slots_norm: + raise HTTPException( + status_code=400, + detail="Kombinationsübung: mindestens eine Station mit Übungen nötig", + ) + + mp_json = Json(prof_dict if kind_clean == "combination" else {}) + arch_db = arch_val if kind_clean == "combination" else None + # Equipment als JSONB equipment_json = json.dumps(body.equipment) if body.equipment else None - # INSERT cur.execute( """INSERT INTO exercises (title, summary, goal, execution, preparation, trainer_notes, duration_min, duration_max, group_size_min, group_size_max, - equipment, visibility, status, created_by, club_id) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + equipment, visibility, status, created_by, club_id, + exercise_kind, method_archetype, method_profile) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id""", ( body.title, body.summary, body.goal, body.execution, @@ -1931,11 +2180,15 @@ def create_exercise( body.group_size_min, body.group_size_max, equipment_json, body.visibility, body.status, profile_id, club_id, - ) + kind_clean, arch_db, mp_json, + ), ) row = cur.fetchone() exercise_id = row['id'] if isinstance(row, dict) else row[0] + if kind_clean == "combination": + replace_combination_slots(cur, tenant, exercise_id, slots_norm) + data = body.dict() assign_exercise_relations(cur, conn, exercise_id, data, do_commit=False) if (body.visibility or "").strip().lower() == "club": @@ -1963,7 +2216,7 @@ def update_exercise( cur = get_cursor(conn) cur.execute( - f"""SELECT created_by, visibility, club_id, + f"""SELECT created_by, visibility, club_id, exercise_kind, method_archetype, method_profile, {", ".join(sorted(RICH_HTML_EXERCISE_FIELDS))} FROM exercises WHERE id = %s""", (exercise_id,), @@ -1993,6 +2246,84 @@ def update_exercise( default_official_copy = data.pop("default_official_media_copyright", None) default_club_copy = data.pop("default_club_media_copyright", None) + combo_slots_provided = "combination_slots" in data + combo_slots_payload = data.pop("combination_slots", None) + + ec_kind_was = str(rd_full.get("exercise_kind") or "simple").strip().lower() + ek_provided = "exercise_kind" in data + next_kind = ec_kind_was + if ek_provided: + next_kind = str(data.pop("exercise_kind") or "simple").strip().lower() + + arche_provided = "method_archetype" in data + meth_prof_provided = "method_profile" in data + + next_ma_db = rd_full.get("method_archetype") + if isinstance(next_ma_db, str): + next_ma_db = next_ma_db.strip() or None + else: + next_ma_db = None + + mp_row = rd_full.get("method_profile") + next_mp_db = mp_row if isinstance(mp_row, dict) else {} + + if ek_provided and next_kind not in ("simple", "combination"): + raise HTTPException(status_code=400, detail="exercise_kind: simple oder combination") + + if arche_provided: + va = data.pop("method_archetype") + if va is None or (isinstance(va, str) and not va.strip()): + next_ma_db = None + elif isinstance(va, str): + next_ma_db = va.strip() or None + else: + next_ma_db = None + + if meth_prof_provided: + next_mp_db = _normalize_method_profile_store(data.pop("method_profile")) + + if next_kind == "simple": + next_ma_db = None + next_mp_db = {} + + _validate_archetype_for_kind(next_kind, next_ma_db) + + if ec_kind_was == "simple" and next_kind == "combination": + if not combo_slots_provided: + raise HTTPException( + status_code=400, + detail='Umschalten auf Kombinationsübung: Feld "combination_slots" ist erforderlich', + ) + + combo_slots_normalized: Optional[List[Tuple[int, Optional[str], List[int]]]] = None + if combo_slots_provided: + if next_kind != "combination": + raise HTTPException( + status_code=400, + detail="combination_slots nur bei exercise_kind=combination erlaubt", + ) + slots_in_raw = combo_slots_payload if combo_slots_payload is not None else [] + slots_in: List[CombinationSlotIn] = [] + for s in slots_in_raw: + if isinstance(s, CombinationSlotIn): + slots_in.append(s) + elif isinstance(s, dict): + slots_in.append(CombinationSlotIn(**s)) + else: + raise HTTPException(status_code=400, detail="Ungültige combination_slots Payload-Struktur") + combo_slots_normalized = _validate_and_normalize_combination_slots_payload( + cur, tenant, slots_in + ) + if not combo_slots_normalized: + raise HTTPException(status_code=400, detail="Kombinationsübung: mindestens eine Station mit Übungen") + + update_combo_cols = ( + ek_provided + or arche_provided + or meth_prof_provided + or (next_kind == "simple" and ec_kind_was != "simple") + ) + merged_rich = {fld: rich_row.get(fld) for fld in RICH_HTML_EXERCISE_FIELDS} for fld in RICH_HTML_EXERCISE_FIELDS: if fld not in data: @@ -2064,11 +2395,27 @@ def update_exercise( fields.append("equipment = %s") params.append(json.dumps(data["equipment"]) if data["equipment"] else None) + if update_combo_cols: + if ek_provided: + fields.append("exercise_kind = %s") + params.append(next_kind) + fields.append("method_archetype = %s") + params.append(next_ma_db) + fields.append("method_profile = %s") + params.append(Json(next_mp_db)) + if fields: fields.append("updated_at = NOW()") params.append(exercise_id) query = f"UPDATE exercises SET {', '.join(fields)} WHERE id = %s" cur.execute(query, params) + elif combo_slots_normalized is not None: + cur.execute("UPDATE exercises SET updated_at = NOW() WHERE id = %s", (exercise_id,)) + + if combo_slots_normalized is not None: + replace_combination_slots(cur, tenant, exercise_id, combo_slots_normalized) + elif ec_kind_was == "combination" and next_kind == "simple": + wipe_combination_structure(cur, exercise_id) assign_exercise_relations(cur, conn, exercise_id, data, do_commit=False) try: @@ -2144,6 +2491,7 @@ def reorder_exercise_variants( with get_db() as conn: cur = get_cursor(conn) _assert_can_edit_exercise(cur, exercise_id, tenant) + assert_exercise_not_combination(cur, exercise_id) cur.execute( "SELECT id FROM exercise_variants WHERE exercise_id = %s", @@ -2178,6 +2526,7 @@ def create_exercise_variant( with get_db() as conn: cur = get_cursor(conn) _assert_can_edit_exercise(cur, exercise_id, tenant) + assert_exercise_not_combination(cur, exercise_id) _validate_variant_prerequisite(cur, exercise_id, body.prerequisite_variant_id) eq_json = _variant_equipment_json(body.equipment_changes) @@ -2242,6 +2591,7 @@ def update_exercise_variant( with get_db() as conn: cur = get_cursor(conn) _assert_can_edit_exercise(cur, exercise_id, tenant) + assert_exercise_not_combination(cur, exercise_id) old = _fetch_variant_row(cur, exercise_id, variant_id) if "variant_name" in data and data["variant_name"] is not None: @@ -2323,6 +2673,7 @@ def delete_exercise_variant( with get_db() as conn: cur = get_cursor(conn) _assert_can_edit_exercise(cur, exercise_id, tenant) + assert_exercise_not_combination(cur, exercise_id) _fetch_variant_row(cur, exercise_id, variant_id) cur.execute( diff --git a/backend/version.py b/backend/version.py index 03d94db..4012a16 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.98" +APP_VERSION = "0.8.99" BUILD_DATE = "2026-05-12" -DB_SCHEMA_VERSION = "20260512055" +DB_SCHEMA_VERSION = "20260512056" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) @@ -21,7 +21,7 @@ MODULE_VERSIONS = { "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "2.23.0", # P-11: enrich_exercise_detail + download_file blocken Legal-Hold-Assets (451) + "exercises": "2.24.0", # Phase 2: Kombinationsübungen exercise_kind/combination_slots + Archetyp/Profil (Migration 056) "training_units": "0.2.0", "training_programs": "0.1.0", "planning": "0.9.0", # apply-training-module; Trainingsmodule-Bibliothek (Phase 1) @@ -35,6 +35,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.99", + "date": "2026-05-12", + "changes": [ + "exercises Phase 2 (Kombinationsübungen): Migration 056 (`exercise_kind`, `method_archetype`, `method_profile`; Tabellen Slots/Kandidaten); CRUD über POST/PUT/GET Übung mit `combination_slots`; Liste-Filter `exercise_kind_any`; Varianten-Endpoints verbieten `exercise_kind=combination`.", + ], + }, { "version": "0.8.98", "date": "2026-05-12", diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index bcf9e15..f2fb54d 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -225,6 +225,7 @@ export default function ExercisePickerModal({ ...queryBase, include_archived: true, include_variants: true, + exercise_kind_any: ['simple'], limit: PAGE_SIZE, offset: 0, }) @@ -253,6 +254,7 @@ export default function ExercisePickerModal({ ...queryBase, include_archived: true, include_variants: true, + exercise_kind_any: ['simple'], limit: PAGE_SIZE, offset, }) diff --git a/frontend/src/pages/ExerciseDetailPage.jsx b/frontend/src/pages/ExerciseDetailPage.jsx index c4781d4..a3403ea 100644 --- a/frontend/src/pages/ExerciseDetailPage.jsx +++ b/frontend/src/pages/ExerciseDetailPage.jsx @@ -136,6 +136,42 @@ function ExerciseDetailPage() { {meta.length > 0 &&
{meta.join(' · ')}
} + {(exercise.exercise_kind || '').toLowerCase().trim() === 'combination' && + Array.isArray(exercise.combination_slots) && + exercise.combination_slots.length > 0 && ( +
+ Archetyp: {String(exercise.method_archetype)}
+
+ Index (Reihenfolge), optional Stationstitel und kommaseparierte IDs von{' '} + Einzelübungen im Pool (nur Übungen mit Art „Einzelübung“). +
+ {(formData.combination_slots || []).map((row, idx) => ( +