feat: update version to 0.7.9 and enhance exercise variant management
- Incremented application version to 0.7.9 and updated changelog to reflect new features. - Added support for creating, updating, and deleting exercise variants via new API endpoints. - Implemented functionality for reordering exercise variants, improving user experience in managing exercise options. - Enhanced frontend components to display and manage exercise variants, including detailed information and editing capabilities.
This commit is contained in:
parent
cf9f95377c
commit
1ee1a2f2d9
|
|
@ -167,6 +167,36 @@ class ExerciseMediaReorder(BaseModel):
|
||||||
media_ids: list[int]
|
media_ids: list[int]
|
||||||
|
|
||||||
|
|
||||||
|
class ExerciseVariantCreate(BaseModel):
|
||||||
|
variant_name: str = Field(..., min_length=3, max_length=200)
|
||||||
|
description: Optional[str] = None
|
||||||
|
execution_changes: Optional[str] = None
|
||||||
|
duration_min: Optional[int] = None
|
||||||
|
duration_max: Optional[int] = None
|
||||||
|
equipment_changes: Optional[list[str]] = None
|
||||||
|
difficulty_adjustment: Optional[str] = Field(None, max_length=50)
|
||||||
|
progression_level: int = Field(default=1, ge=1, le=10)
|
||||||
|
sequence_order: Optional[int] = None
|
||||||
|
prerequisite_variant_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExerciseVariantUpdate(BaseModel):
|
||||||
|
variant_name: Optional[str] = Field(None, min_length=3, max_length=200)
|
||||||
|
description: Optional[str] = None
|
||||||
|
execution_changes: Optional[str] = None
|
||||||
|
duration_min: Optional[int] = None
|
||||||
|
duration_max: Optional[int] = None
|
||||||
|
equipment_changes: Optional[list[str]] = None
|
||||||
|
difficulty_adjustment: Optional[str] = Field(None, max_length=50)
|
||||||
|
progression_level: Optional[int] = Field(None, ge=1, le=10)
|
||||||
|
sequence_order: Optional[int] = None
|
||||||
|
prerequisite_variant_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExerciseVariantsReorder(BaseModel):
|
||||||
|
variant_ids: list[int]
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Helper Functions
|
# Helper Functions
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
@ -209,6 +239,48 @@ def _assert_can_edit_exercise(cur, exercise_id: int, profile_id: int):
|
||||||
raise HTTPException(status_code=403, detail="Nur der Ersteller darf diese Übung bearbeiten")
|
raise HTTPException(status_code=403, detail="Nur der Ersteller darf diese Übung bearbeiten")
|
||||||
|
|
||||||
|
|
||||||
|
def _variant_equipment_json(changes: Optional[list]) -> str:
|
||||||
|
return json.dumps(changes if changes else [])
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_variant_equipment_list(val) -> list:
|
||||||
|
if val is None:
|
||||||
|
return []
|
||||||
|
if isinstance(val, list):
|
||||||
|
return val
|
||||||
|
if isinstance(val, str):
|
||||||
|
try:
|
||||||
|
return json.loads(val)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_variant_prerequisite(cur, exercise_id: int, prereq_id: Optional[int]) -> None:
|
||||||
|
if prereq_id is None:
|
||||||
|
return
|
||||||
|
cur.execute(
|
||||||
|
"SELECT 1 FROM exercise_variants WHERE id = %s AND exercise_id = %s",
|
||||||
|
(prereq_id, exercise_id),
|
||||||
|
)
|
||||||
|
if not cur.fetchone():
|
||||||
|
raise HTTPException(status_code=400, detail="Voraussetzungs-Variante gehört nicht zu dieser Übung")
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_variant_row(cur, exercise_id: int, variant_id: int) -> dict:
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT id, variant_name, description, execution_changes,
|
||||||
|
duration_min, duration_max, equipment_changes, difficulty_adjustment,
|
||||||
|
progression_level, sequence_order, prerequisite_variant_id, created_at
|
||||||
|
FROM exercise_variants WHERE id = %s AND exercise_id = %s""",
|
||||||
|
(variant_id, exercise_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Variante nicht gefunden")
|
||||||
|
return r2d(row)
|
||||||
|
|
||||||
|
|
||||||
def _count_exercise_media(cur, exercise_id: int) -> int:
|
def _count_exercise_media(cur, exercise_id: int) -> int:
|
||||||
cur.execute("SELECT COUNT(*) AS c FROM exercise_media WHERE exercise_id = %s", (exercise_id,))
|
cur.execute("SELECT COUNT(*) AS c FROM exercise_media WHERE exercise_id = %s", (exercise_id,))
|
||||||
r = cur.fetchone()
|
r = cur.fetchone()
|
||||||
|
|
@ -323,14 +395,14 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict:
|
||||||
sk["required_level"] = normalize_exercise_skill_level(sk.get("required_level"))
|
sk["required_level"] = normalize_exercise_skill_level(sk.get("required_level"))
|
||||||
sk["target_level"] = normalize_exercise_skill_level(sk.get("target_level"))
|
sk["target_level"] = normalize_exercise_skill_level(sk.get("target_level"))
|
||||||
|
|
||||||
# Variants (1:N) - mit Progression
|
# Variants (1:N) - mit Progression (Reihenfolge: sequence_order, dann progression_level)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""SELECT id, variant_name, description, execution_changes,
|
"""SELECT id, variant_name, description, execution_changes,
|
||||||
duration_min, duration_max, equipment_changes, difficulty_adjustment,
|
duration_min, duration_max, equipment_changes, difficulty_adjustment,
|
||||||
progression_level, sequence_order, prerequisite_variant_id
|
progression_level, sequence_order, prerequisite_variant_id, created_at
|
||||||
FROM exercise_variants
|
FROM exercise_variants
|
||||||
WHERE exercise_id = %s
|
WHERE exercise_id = %s
|
||||||
ORDER BY progression_level, sequence_order""",
|
ORDER BY sequence_order NULLS LAST, progression_level, id""",
|
||||||
(exercise_id,)
|
(exercise_id,)
|
||||||
)
|
)
|
||||||
exercise["variants"] = [r2d(r) for r in cur.fetchall()]
|
exercise["variants"] = [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
@ -605,7 +677,7 @@ def list_exercises(
|
||||||
'variant_name', ev.variant_name,
|
'variant_name', ev.variant_name,
|
||||||
'sequence_order', ev.sequence_order
|
'sequence_order', ev.sequence_order
|
||||||
)
|
)
|
||||||
ORDER BY ev.sequence_order NULLS LAST, ev.id
|
ORDER BY ev.sequence_order NULLS LAST, ev.progression_level, ev.id
|
||||||
),
|
),
|
||||||
'[]'::json
|
'[]'::json
|
||||||
)
|
)
|
||||||
|
|
@ -840,6 +912,211 @@ def delete_exercise(
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Übungsvarianten (EXERCISES_API_SPEC.md) ---
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/exercises/{exercise_id}/variants/reorder")
|
||||||
|
def reorder_exercise_variants(
|
||||||
|
exercise_id: int,
|
||||||
|
body: ExerciseVariantsReorder,
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
profile_id = session["profile_id"]
|
||||||
|
|
||||||
|
if len(body.variant_ids) != len(set(body.variant_ids)):
|
||||||
|
raise HTTPException(status_code=400, detail="variant_ids dürfen keine Duplikate enthalten")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
_assert_can_edit_exercise(cur, exercise_id, profile_id)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id FROM exercise_variants WHERE exercise_id = %s",
|
||||||
|
(exercise_id,),
|
||||||
|
)
|
||||||
|
existing = [r["id"] for r in cur.fetchall()]
|
||||||
|
if set(existing) != set(body.variant_ids):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="variant_ids müssen alle Varianten dieser Übung genau einmal enthalten",
|
||||||
|
)
|
||||||
|
|
||||||
|
for pos, vid in enumerate(body.variant_ids):
|
||||||
|
cur.execute(
|
||||||
|
"""UPDATE exercise_variants SET sequence_order = %s
|
||||||
|
WHERE id = %s AND exercise_id = %s""",
|
||||||
|
(pos + 1, vid, exercise_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {"ok": True, "reordered": len(body.variant_ids)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/exercises/{exercise_id}/variants", status_code=201)
|
||||||
|
def create_exercise_variant(
|
||||||
|
exercise_id: int,
|
||||||
|
body: ExerciseVariantCreate,
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
profile_id = session["profile_id"]
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
_assert_can_edit_exercise(cur, exercise_id, profile_id)
|
||||||
|
_validate_variant_prerequisite(cur, exercise_id, body.prerequisite_variant_id)
|
||||||
|
|
||||||
|
eq_json = _variant_equipment_json(body.equipment_changes)
|
||||||
|
seq = body.sequence_order
|
||||||
|
if seq is None:
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT COALESCE(MAX(sequence_order), 0) + 1 AS n
|
||||||
|
FROM exercise_variants WHERE exercise_id = %s""",
|
||||||
|
(exercise_id,),
|
||||||
|
)
|
||||||
|
seq = cur.fetchone()["n"]
|
||||||
|
|
||||||
|
desc = (body.description or "").strip() or None
|
||||||
|
exec_ch = (body.execution_changes or "").strip() or None
|
||||||
|
diff = (body.difficulty_adjustment or "").strip() or None
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""INSERT INTO exercise_variants (
|
||||||
|
exercise_id, variant_name, description, execution_changes,
|
||||||
|
duration_min, duration_max, equipment_changes, difficulty_adjustment,
|
||||||
|
progression_level, sequence_order, prerequisite_variant_id
|
||||||
|
) VALUES (%s,%s,%s,%s,%s,%s,%s::jsonb,%s,%s,%s,%s)
|
||||||
|
RETURNING id""",
|
||||||
|
(
|
||||||
|
exercise_id,
|
||||||
|
body.variant_name.strip(),
|
||||||
|
desc,
|
||||||
|
exec_ch,
|
||||||
|
body.duration_min,
|
||||||
|
body.duration_max,
|
||||||
|
eq_json,
|
||||||
|
diff,
|
||||||
|
body.progression_level,
|
||||||
|
seq,
|
||||||
|
body.prerequisite_variant_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
new_id = cur.fetchone()["id"]
|
||||||
|
row = _fetch_variant_row(cur, exercise_id, new_id)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/exercises/{exercise_id}/variants/{variant_id}")
|
||||||
|
def update_exercise_variant(
|
||||||
|
exercise_id: int,
|
||||||
|
variant_id: int,
|
||||||
|
body: ExerciseVariantUpdate,
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
profile_id = session["profile_id"]
|
||||||
|
|
||||||
|
data = body.dict(exclude_unset=True)
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
_assert_can_edit_exercise(cur, exercise_id, profile_id)
|
||||||
|
old = _fetch_variant_row(cur, exercise_id, variant_id)
|
||||||
|
|
||||||
|
if "variant_name" in data and data["variant_name"] is not None:
|
||||||
|
old["variant_name"] = data["variant_name"].strip()
|
||||||
|
if "description" in data:
|
||||||
|
old["description"] = (data["description"] or "").strip() or None
|
||||||
|
if "execution_changes" in data:
|
||||||
|
old["execution_changes"] = (data["execution_changes"] or "").strip() or None
|
||||||
|
if "duration_min" in data:
|
||||||
|
old["duration_min"] = data["duration_min"]
|
||||||
|
if "duration_max" in data:
|
||||||
|
old["duration_max"] = data["duration_max"]
|
||||||
|
if "equipment_changes" in data:
|
||||||
|
old["equipment_changes"] = _normalize_variant_equipment_list(data["equipment_changes"])
|
||||||
|
if "difficulty_adjustment" in data:
|
||||||
|
old["difficulty_adjustment"] = (data["difficulty_adjustment"] or "").strip() or None
|
||||||
|
if "progression_level" in data and data["progression_level"] is not None:
|
||||||
|
old["progression_level"] = data["progression_level"]
|
||||||
|
if "sequence_order" in data:
|
||||||
|
old["sequence_order"] = data["sequence_order"]
|
||||||
|
if "prerequisite_variant_id" in data:
|
||||||
|
old["prerequisite_variant_id"] = data["prerequisite_variant_id"]
|
||||||
|
|
||||||
|
prereq = old.get("prerequisite_variant_id")
|
||||||
|
if prereq == variant_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Variante kann nicht ihre eigene Voraussetzung sein")
|
||||||
|
_validate_variant_prerequisite(cur, exercise_id, prereq)
|
||||||
|
|
||||||
|
eq_db = _variant_equipment_json(_normalize_variant_equipment_list(old.get("equipment_changes")))
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""UPDATE exercise_variants SET
|
||||||
|
variant_name = %s,
|
||||||
|
description = %s,
|
||||||
|
execution_changes = %s,
|
||||||
|
duration_min = %s,
|
||||||
|
duration_max = %s,
|
||||||
|
equipment_changes = %s::jsonb,
|
||||||
|
difficulty_adjustment = %s,
|
||||||
|
progression_level = %s,
|
||||||
|
sequence_order = %s,
|
||||||
|
prerequisite_variant_id = %s
|
||||||
|
WHERE id = %s AND exercise_id = %s""",
|
||||||
|
(
|
||||||
|
old["variant_name"],
|
||||||
|
old.get("description"),
|
||||||
|
old.get("execution_changes"),
|
||||||
|
old.get("duration_min"),
|
||||||
|
old.get("duration_max"),
|
||||||
|
eq_db,
|
||||||
|
old.get("difficulty_adjustment"),
|
||||||
|
old.get("progression_level"),
|
||||||
|
old.get("sequence_order"),
|
||||||
|
old.get("prerequisite_variant_id"),
|
||||||
|
variant_id,
|
||||||
|
exercise_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = _fetch_variant_row(cur, exercise_id, variant_id)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return row
|
||||||
|
def delete_exercise_variant(
|
||||||
|
exercise_id: int,
|
||||||
|
variant_id: int,
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
|
profile_id = session["profile_id"]
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
_assert_can_edit_exercise(cur, exercise_id, profile_id)
|
||||||
|
_fetch_variant_row(cur, exercise_id, variant_id)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) AS c FROM exercise_variants WHERE prerequisite_variant_id = %s",
|
||||||
|
(variant_id,),
|
||||||
|
)
|
||||||
|
cnt = int(cur.fetchone()["c"])
|
||||||
|
if cnt > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Variante ist Voraussetzung anderer Varianten — zuerst dort ändern oder entfernen",
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM exercise_variants WHERE id = %s AND exercise_id = %s",
|
||||||
|
(variant_id, exercise_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
# --- Medien (MEDIA_UPLOAD_SPEC.md / EXERCISES_API_SPEC.md) ---
|
# --- Medien (MEDIA_UPLOAD_SPEC.md / EXERCISES_API_SPEC.md) ---
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.7.8"
|
APP_VERSION = "0.7.9"
|
||||||
BUILD_DATE = "2026-04-27"
|
BUILD_DATE = "2026-04-27"
|
||||||
DB_SCHEMA_VERSION = "20260427030"
|
DB_SCHEMA_VERSION = "20260427030"
|
||||||
|
|
||||||
|
|
@ -11,7 +11,7 @@ MODULE_VERSIONS = {
|
||||||
"groups": "0.1.0",
|
"groups": "0.1.0",
|
||||||
"skills": "0.1.0",
|
"skills": "0.1.0",
|
||||||
"methods": "0.1.0",
|
"methods": "0.1.0",
|
||||||
"exercises": "2.0.0", # BREAKING: Clean-Room Rebuild, Legacy-Felder entfernt, nur M:N
|
"exercises": "2.1.0", # Varianten-CRUD API + UI; Listen mit include_variants
|
||||||
"training_units": "0.1.0",
|
"training_units": "0.1.0",
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.2.0",
|
"planning": "0.2.0",
|
||||||
|
|
@ -23,6 +23,16 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.7.9",
|
||||||
|
"date": "2026-04-27",
|
||||||
|
"changes": [
|
||||||
|
"Übungsvarianten: POST/PUT/DELETE /api/exercises/{id}/variants + reorder",
|
||||||
|
"Übung bearbeiten: voller Varianten-Editor (Speichern pro Variante, Reihenfolge, Voraussetzung)",
|
||||||
|
"Übung Ansehen: Varianten-Metadaten (Dauer, Schwierigkeit, Material, Progression)",
|
||||||
|
"GET /exercises Detail: Varianten-Sortierung sequence_order → progression_level",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.7.8",
|
"version": "0.7.8",
|
||||||
"date": "2026-04-27",
|
"date": "2026-04-27",
|
||||||
|
|
|
||||||
|
|
@ -263,24 +263,55 @@ function ExerciseDetailPage() {
|
||||||
{(exercise.variants || []).length > 0 && (
|
{(exercise.variants || []).length > 0 && (
|
||||||
<section className="card exercise-detail-section">
|
<section className="card exercise-detail-section">
|
||||||
<h2>Varianten</h2>
|
<h2>Varianten</h2>
|
||||||
{exercise.variants.map((v) => (
|
{exercise.variants.map((v) => {
|
||||||
<div
|
const dur =
|
||||||
key={v.id}
|
v.duration_min != null || v.duration_max != null
|
||||||
style={{
|
? v.duration_min != null && v.duration_max != null && v.duration_min !== v.duration_max
|
||||||
marginBottom: '1rem',
|
? `${v.duration_min}–${v.duration_max} Min.`
|
||||||
paddingBottom: '1rem',
|
: v.duration_min != null
|
||||||
borderBottom: '1px solid var(--border)',
|
? `ca. ${v.duration_min} Min.`
|
||||||
}}
|
: `bis ca. ${v.duration_max} Min.`
|
||||||
>
|
: null
|
||||||
<strong style={{ fontSize: '15px' }}>{v.variant_name}</strong>
|
const diffLabel =
|
||||||
{v.description && <p style={{ color: 'var(--text2)', marginTop: '4px' }}>{v.description}</p>}
|
v.difficulty_adjustment === 'easier'
|
||||||
{v.execution_changes && (
|
? 'einfacher'
|
||||||
<div style={{ marginTop: '8px' }}>
|
: v.difficulty_adjustment === 'harder'
|
||||||
<HtmlBlock html={v.execution_changes} />
|
? 'schwerer'
|
||||||
</div>
|
: v.difficulty_adjustment === 'same'
|
||||||
)}
|
? 'gleiche Schwierigkeit'
|
||||||
</div>
|
: v.difficulty_adjustment === 'adapted'
|
||||||
))}
|
? 'angepasst'
|
||||||
|
: null
|
||||||
|
const equip =
|
||||||
|
Array.isArray(v.equipment_changes) && v.equipment_changes.length > 0
|
||||||
|
? v.equipment_changes.join(', ')
|
||||||
|
: null
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={v.id}
|
||||||
|
style={{
|
||||||
|
marginBottom: '1rem',
|
||||||
|
paddingBottom: '1rem',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ fontSize: '15px' }}>{v.variant_name}</strong>
|
||||||
|
{(dur || diffLabel || equip || v.progression_level != null) && (
|
||||||
|
<div style={{ fontSize: '13px', color: 'var(--text3)', marginTop: '4px' }}>
|
||||||
|
{[dur, diffLabel, equip && `Material: ${equip}`, v.progression_level != null && `Progression ${v.progression_level}`]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{v.description && <p style={{ color: 'var(--text2)', marginTop: '4px' }}>{v.description}</p>}
|
||||||
|
{v.execution_changes && (
|
||||||
|
<div style={{ marginTop: '8px' }}>
|
||||||
|
<HtmlBlock html={v.execution_changes} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,82 @@ const INTENSITY_OPTIONS = [
|
||||||
{ value: 'hoch', label: 'hoch' },
|
{ value: 'hoch', label: 'hoch' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const VARIANT_DIFFICULTY = [
|
||||||
|
{ value: '', label: '—' },
|
||||||
|
{ value: 'easier', label: 'Einfacher' },
|
||||||
|
{ value: 'same', label: 'Gleich' },
|
||||||
|
{ value: 'harder', label: 'Schwerer' },
|
||||||
|
{ value: 'adapted', label: 'Angepasst' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function emptyVariantDraft() {
|
||||||
|
return {
|
||||||
|
variant_name: '',
|
||||||
|
description: '',
|
||||||
|
execution_changes: '',
|
||||||
|
duration_min: '',
|
||||||
|
duration_max: '',
|
||||||
|
equipment_lines: '',
|
||||||
|
difficulty_adjustment: '',
|
||||||
|
progression_level: 1,
|
||||||
|
prerequisite_variant_id: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiVariantToRow(v) {
|
||||||
|
let lines = ''
|
||||||
|
const eq = v.equipment_changes
|
||||||
|
if (Array.isArray(eq)) {
|
||||||
|
lines = eq.join('\n')
|
||||||
|
} else if (typeof eq === 'string' && eq.trim()) {
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(eq)
|
||||||
|
lines = Array.isArray(p) ? p.join('\n') : eq
|
||||||
|
} catch {
|
||||||
|
lines = eq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...v,
|
||||||
|
duration_min: v.duration_min ?? '',
|
||||||
|
duration_max: v.duration_max ?? '',
|
||||||
|
equipment_lines: lines,
|
||||||
|
progression_level: v.progression_level ?? 1,
|
||||||
|
prerequisite_variant_id: v.prerequisite_variant_id ?? '',
|
||||||
|
difficulty_adjustment: v.difficulty_adjustment ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVariantPayloadFromRow(row) {
|
||||||
|
const lines = (row.equipment_lines || '')
|
||||||
|
.split(/[\n,]+/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
const pl =
|
||||||
|
row.progression_level === '' || row.progression_level == null
|
||||||
|
? 1
|
||||||
|
: parseInt(row.progression_level, 10)
|
||||||
|
const so =
|
||||||
|
row.sequence_order === '' || row.sequence_order == null
|
||||||
|
? null
|
||||||
|
: parseInt(row.sequence_order, 10)
|
||||||
|
return {
|
||||||
|
variant_name: (row.variant_name || '').trim(),
|
||||||
|
description: (row.description || '').trim() || null,
|
||||||
|
execution_changes: (row.execution_changes || '').trim() || null,
|
||||||
|
duration_min: row.duration_min === '' || row.duration_min == null ? null : parseInt(row.duration_min, 10),
|
||||||
|
duration_max: row.duration_max === '' || row.duration_max == null ? null : parseInt(row.duration_max, 10),
|
||||||
|
equipment_changes: lines,
|
||||||
|
difficulty_adjustment: row.difficulty_adjustment || null,
|
||||||
|
progression_level: Number.isNaN(pl) ? 1 : pl,
|
||||||
|
sequence_order: so !== null && Number.isNaN(so) ? null : so,
|
||||||
|
prerequisite_variant_id:
|
||||||
|
row.prerequisite_variant_id === '' || row.prerequisite_variant_id == null
|
||||||
|
? null
|
||||||
|
: parseInt(row.prerequisite_variant_id, 10),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function emptyForm() {
|
function emptyForm() {
|
||||||
return {
|
return {
|
||||||
title: '',
|
title: '',
|
||||||
|
|
@ -157,6 +233,10 @@ function ExerciseFormPage() {
|
||||||
const [loading, setLoading] = useState(!!isEdit)
|
const [loading, setLoading] = useState(!!isEdit)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [skillPick, setSkillPick] = useState('')
|
const [skillPick, setSkillPick] = useState('')
|
||||||
|
const [variants, setVariants] = useState([])
|
||||||
|
const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft())
|
||||||
|
const [variantSavingId, setVariantSavingId] = useState(null)
|
||||||
|
const [variantBusy, setVariantBusy] = useState(false)
|
||||||
|
|
||||||
const [mediaFile, setMediaFile] = useState(null)
|
const [mediaFile, setMediaFile] = useState(null)
|
||||||
const [mediaType, setMediaType] = useState('image')
|
const [mediaType, setMediaType] = useState('image')
|
||||||
|
|
@ -202,6 +282,8 @@ function ExerciseFormPage() {
|
||||||
if (!isEdit) {
|
if (!isEdit) {
|
||||||
setFormData(emptyForm())
|
setFormData(emptyForm())
|
||||||
setMediaList([])
|
setMediaList([])
|
||||||
|
setVariants([])
|
||||||
|
setVariantDraft(emptyVariantDraft())
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -213,6 +295,8 @@ function ExerciseFormPage() {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setFormData(detailToForm(exercise))
|
setFormData(detailToForm(exercise))
|
||||||
setMediaList(exercise.media || [])
|
setMediaList(exercise.media || [])
|
||||||
|
setVariants((exercise.variants || []).map(apiVariantToRow))
|
||||||
|
setVariantDraft(emptyVariantDraft())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
alert(err.message || 'Übung nicht ladbar')
|
alert(err.message || 'Übung nicht ladbar')
|
||||||
|
|
@ -304,6 +388,7 @@ function ExerciseFormPage() {
|
||||||
await api.updateExercise(exerciseId, payload)
|
await api.updateExercise(exerciseId, payload)
|
||||||
const ex = await api.getExercise(exerciseId)
|
const ex = await api.getExercise(exerciseId)
|
||||||
setMediaList(ex.media || [])
|
setMediaList(ex.media || [])
|
||||||
|
setVariants((ex.variants || []).map(apiVariantToRow))
|
||||||
alert('Gespeichert.')
|
alert('Gespeichert.')
|
||||||
} else {
|
} else {
|
||||||
const created = await api.createExercise(payload)
|
const created = await api.createExercise(payload)
|
||||||
|
|
@ -376,6 +461,85 @@ function ExerciseFormPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshVariants = async () => {
|
||||||
|
if (!exerciseId) return
|
||||||
|
const ex = await api.getExercise(exerciseId)
|
||||||
|
setVariants((ex.variants || []).map(apiVariantToRow))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateVariantField = (id, patch) => {
|
||||||
|
setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveVariantRow = async (row) => {
|
||||||
|
const payload = buildVariantPayloadFromRow(row)
|
||||||
|
if (payload.variant_name.length < 3) {
|
||||||
|
alert('Variantenname mindestens 3 Zeichen')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setVariantSavingId(row.id)
|
||||||
|
try {
|
||||||
|
await api.updateExerciseVariant(exerciseId, row.id, payload)
|
||||||
|
await refreshVariants()
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || String(e))
|
||||||
|
} finally {
|
||||||
|
setVariantSavingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteVariantRow = async (id) => {
|
||||||
|
if (!confirm('Variante wirklich löschen?')) return
|
||||||
|
setVariantBusy(true)
|
||||||
|
try {
|
||||||
|
await api.deleteExerciseVariant(exerciseId, id)
|
||||||
|
await refreshVariants()
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || String(e))
|
||||||
|
} finally {
|
||||||
|
setVariantBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveVariantRow = async (idx, dir) => {
|
||||||
|
const j = idx + dir
|
||||||
|
if (j < 0 || j >= variants.length) return
|
||||||
|
const next = [...variants]
|
||||||
|
const tmp = next[idx]
|
||||||
|
next[idx] = next[j]
|
||||||
|
next[j] = tmp
|
||||||
|
const ids = next.map((x) => x.id)
|
||||||
|
setVariantBusy(true)
|
||||||
|
try {
|
||||||
|
await api.reorderExerciseVariants(exerciseId, ids)
|
||||||
|
await refreshVariants()
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || String(e))
|
||||||
|
} finally {
|
||||||
|
setVariantBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createVariantSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!exerciseId) return
|
||||||
|
const payload = buildVariantPayloadFromRow(variantDraft)
|
||||||
|
if (payload.variant_name.length < 3) {
|
||||||
|
alert('Variantenname mindestens 3 Zeichen')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setVariantBusy(true)
|
||||||
|
try {
|
||||||
|
await api.createExerciseVariant(exerciseId, payload)
|
||||||
|
setVariantDraft(emptyVariantDraft())
|
||||||
|
await refreshVariants()
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || String(err))
|
||||||
|
} finally {
|
||||||
|
setVariantBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const availableSkills = skillsCatalog.filter((s) => !formData.skills.some((x) => x.skill_id === s.id))
|
const availableSkills = skillsCatalog.filter((s) => !formData.skills.some((x) => x.skill_id === s.id))
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -689,6 +853,307 @@ function ExerciseFormPage() {
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isEdit && (
|
||||||
|
<div className="card" style={{ marginTop: '16px' }}>
|
||||||
|
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Übungsvarianten</h2>
|
||||||
|
<p style={{ color: 'var(--text2)', fontSize: '13px', marginBottom: '12px' }}>
|
||||||
|
Alternative Ausführungen zur Stammübung. Reihenfolge (Nach oben/unten) steuert Darstellung und Auswahl in der
|
||||||
|
Trainingsplanung. „Voraussetzung“ verknüpft Varianten für spätere Progressions-Serien als Blöcke.
|
||||||
|
</p>
|
||||||
|
{variants.length === 0 && (
|
||||||
|
<p style={{ fontSize: '13px', color: 'var(--text3)', marginBottom: '12px' }}>Noch keine Varianten angelegt.</p>
|
||||||
|
)}
|
||||||
|
{variants.map((v, idx) => (
|
||||||
|
<div
|
||||||
|
key={v.id}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
padding: '12px',
|
||||||
|
marginBottom: '12px',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', marginBottom: '10px' }}>
|
||||||
|
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>#{idx + 1}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||||
|
disabled={variantBusy || idx === 0}
|
||||||
|
onClick={() => moveVariantRow(idx, -1)}
|
||||||
|
>
|
||||||
|
Nach oben
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||||
|
disabled={variantBusy || idx === variants.length - 1}
|
||||||
|
onClick={() => moveVariantRow(idx, 1)}
|
||||||
|
>
|
||||||
|
Nach unten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ fontSize: '12px', marginLeft: 'auto' }}
|
||||||
|
disabled={variantSavingId === v.id || variantBusy}
|
||||||
|
onClick={() => saveVariantRow(v)}
|
||||||
|
>
|
||||||
|
{variantSavingId === v.id ? 'Speichern…' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
style={{ fontSize: '12px', background: 'var(--danger)', color: '#fff', border: 'none' }}
|
||||||
|
disabled={variantBusy}
|
||||||
|
onClick={() => deleteVariantRow(v.id)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Variantenname *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={v.variant_name || ''}
|
||||||
|
onChange={(e) => updateVariantField(v.id, { variant_name: e.target.value })}
|
||||||
|
minLength={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Kurzbeschreibung</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
rows={2}
|
||||||
|
value={v.description || ''}
|
||||||
|
onChange={(e) => updateVariantField(v.id, { description: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Abweichungen zur Durchführung</label>
|
||||||
|
<RichTextEditor
|
||||||
|
value={v.execution_changes || ''}
|
||||||
|
onChange={(html) => updateVariantField(v.id, { execution_changes: html })}
|
||||||
|
placeholder="Was unterscheidet diese Variante?"
|
||||||
|
minHeight="100px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Dauer Min</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
value={v.duration_min}
|
||||||
|
onChange={(e) => updateVariantField(v.id, { duration_min: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Dauer Max</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
value={v.duration_max}
|
||||||
|
onChange={(e) => updateVariantField(v.id, { duration_max: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Materialänderungen (eine Zeile pro Eintrag)</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
rows={2}
|
||||||
|
value={v.equipment_lines || ''}
|
||||||
|
onChange={(e) => updateVariantField(v.id, { equipment_lines: e.target.value })}
|
||||||
|
placeholder="+ Pratzen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: '10px' }}>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Schwere relativ</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={v.difficulty_adjustment || ''}
|
||||||
|
onChange={(e) => updateVariantField(v.id, { difficulty_adjustment: e.target.value })}
|
||||||
|
>
|
||||||
|
{VARIANT_DIFFICULTY.map((o) => (
|
||||||
|
<option key={o.label} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Progressions-Stufe (1–10)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
className="form-input"
|
||||||
|
value={v.progression_level}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateVariantField(v.id, {
|
||||||
|
progression_level: e.target.value === '' ? '' : parseInt(e.target.value, 10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Voraussetzungs-Variante</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={
|
||||||
|
v.prerequisite_variant_id === '' || v.prerequisite_variant_id == null
|
||||||
|
? ''
|
||||||
|
: String(v.prerequisite_variant_id)
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateVariantField(v.id, {
|
||||||
|
prerequisite_variant_id: e.target.value === '' ? '' : parseInt(e.target.value, 10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">— keine —</option>
|
||||||
|
{variants
|
||||||
|
.filter((o) => o.id !== v.id)
|
||||||
|
.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>
|
||||||
|
{o.variant_name || `Variante #${o.id}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<form
|
||||||
|
onSubmit={createVariantSubmit}
|
||||||
|
style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Neue Variante anlegen</h3>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Variantenname *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
value={variantDraft.variant_name}
|
||||||
|
onChange={(e) => setVariantDraft((d) => ({ ...d, variant_name: e.target.value }))}
|
||||||
|
minLength={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Kurzbeschreibung</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
rows={2}
|
||||||
|
value={variantDraft.description}
|
||||||
|
onChange={(e) => setVariantDraft((d) => ({ ...d, description: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Abweichungen zur Durchführung</label>
|
||||||
|
<RichTextEditor
|
||||||
|
value={variantDraft.execution_changes}
|
||||||
|
onChange={(html) => setVariantDraft((d) => ({ ...d, execution_changes: html }))}
|
||||||
|
placeholder="Optional: abweichende Schritte"
|
||||||
|
minHeight="100px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Dauer Min</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
value={variantDraft.duration_min}
|
||||||
|
onChange={(e) => setVariantDraft((d) => ({ ...d, duration_min: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Dauer Max</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
value={variantDraft.duration_max}
|
||||||
|
onChange={(e) => setVariantDraft((d) => ({ ...d, duration_max: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Materialänderungen</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
rows={2}
|
||||||
|
value={variantDraft.equipment_lines}
|
||||||
|
onChange={(e) => setVariantDraft((d) => ({ ...d, equipment_lines: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: '10px' }}>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Schwere relativ</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={variantDraft.difficulty_adjustment}
|
||||||
|
onChange={(e) => setVariantDraft((d) => ({ ...d, difficulty_adjustment: e.target.value }))}
|
||||||
|
>
|
||||||
|
{VARIANT_DIFFICULTY.map((o) => (
|
||||||
|
<option key={o.label} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Progressions-Stufe (1–10)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
className="form-input"
|
||||||
|
value={variantDraft.progression_level}
|
||||||
|
onChange={(e) =>
|
||||||
|
setVariantDraft((d) => ({
|
||||||
|
...d,
|
||||||
|
progression_level: e.target.value === '' ? 1 : parseInt(e.target.value, 10),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Voraussetzungs-Variante</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={
|
||||||
|
variantDraft.prerequisite_variant_id === '' || variantDraft.prerequisite_variant_id == null
|
||||||
|
? ''
|
||||||
|
: String(variantDraft.prerequisite_variant_id)
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setVariantDraft((d) => ({
|
||||||
|
...d,
|
||||||
|
prerequisite_variant_id: e.target.value === '' ? '' : parseInt(e.target.value, 10),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">— keine —</option>
|
||||||
|
{variants.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>
|
||||||
|
{o.variant_name || `Variante #${o.id}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={variantBusy}>
|
||||||
|
{variantBusy ? 'Anlegen…' : 'Variante anlegen'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isEdit && (
|
{isEdit && (
|
||||||
<div className="card" style={{ marginTop: '16px' }}>
|
<div className="card" style={{ marginTop: '16px' }}>
|
||||||
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2>
|
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2>
|
||||||
|
|
@ -783,10 +1248,9 @@ function ExerciseFormPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
|
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
|
||||||
Varianten-Editor folgt später (API teilweise vorhanden).{' '}
|
|
||||||
<strong>KI-Ausbaustufe:</strong> Backend laut Spec{' '}
|
<strong>KI-Ausbaustufe:</strong> Backend laut Spec{' '}
|
||||||
<code style={{ fontSize: '11px' }}>POST /api/exercises/ai/suggest</code> und{' '}
|
<code style={{ fontSize: '11px' }}>POST /api/exercises/ai/suggest</code> und{' '}
|
||||||
<code style={{ fontSize: '11px' }}>POST /api/exercises/{'{id}'}/ai/regenerate</code> — z. B.{' '}
|
<code style={{ fontSize: '11px' }}>POST /api/exercises/{'{id}'}/ai/regenerate</code> — z. B.{' '}
|
||||||
<code>OPENROUTER_API_KEY</code>, Vorschläge nur nach Trainer-Bestätigung übernehmen (siehe{' '}
|
<code>OPENROUTER_API_KEY</code>, Vorschläge nur nach Trainer-Bestätigung übernehmen (siehe{' '}
|
||||||
<code>api.suggestExerciseAi</code>).
|
<code>api.suggestExerciseAi</code>).
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -352,6 +352,31 @@ export async function deleteExercise(id) {
|
||||||
return request(`/api/exercises/${id}`, { method: 'DELETE' })
|
return request(`/api/exercises/${id}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createExerciseVariant(exerciseId, data) {
|
||||||
|
return request(`/api/exercises/${exerciseId}/variants`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateExerciseVariant(exerciseId, variantId, data) {
|
||||||
|
return request(`/api/exercises/${exerciseId}/variants/${variantId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteExerciseVariant(exerciseId, variantId) {
|
||||||
|
return request(`/api/exercises/${exerciseId}/variants/${variantId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reorderExerciseVariants(exerciseId, variantIds) {
|
||||||
|
return request(`/api/exercises/${exerciseId}/variants/reorder`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ variant_ids: variantIds }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** KI-Ausbaustufe (EXERCISES_API_SPEC): benötigt Backend + z. B. OPENROUTER_API_KEY. */
|
/** KI-Ausbaustufe (EXERCISES_API_SPEC): benötigt Backend + z. B. OPENROUTER_API_KEY. */
|
||||||
export async function suggestExerciseAi(payload) {
|
export async function suggestExerciseAi(payload) {
|
||||||
return request('/api/exercises/ai/suggest', {
|
return request('/api/exercises/ai/suggest', {
|
||||||
|
|
@ -811,6 +836,10 @@ export const api = {
|
||||||
createExercise,
|
createExercise,
|
||||||
updateExercise,
|
updateExercise,
|
||||||
deleteExercise,
|
deleteExercise,
|
||||||
|
createExerciseVariant,
|
||||||
|
updateExerciseVariant,
|
||||||
|
deleteExerciseVariant,
|
||||||
|
reorderExerciseVariants,
|
||||||
buildExerciseApiPayload,
|
buildExerciseApiPayload,
|
||||||
suggestExerciseAi,
|
suggestExerciseAi,
|
||||||
regenerateExerciseAi,
|
regenerateExerciseAi,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user