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]
|
||||
|
||||
|
||||
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
|
||||
# ============================================================================
|
||||
|
|
@ -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")
|
||||
|
||||
|
||||
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:
|
||||
cur.execute("SELECT COUNT(*) AS c FROM exercise_media WHERE exercise_id = %s", (exercise_id,))
|
||||
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["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(
|
||||
"""SELECT id, variant_name, description, execution_changes,
|
||||
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
|
||||
WHERE exercise_id = %s
|
||||
ORDER BY progression_level, sequence_order""",
|
||||
ORDER BY sequence_order NULLS LAST, progression_level, id""",
|
||||
(exercise_id,)
|
||||
)
|
||||
exercise["variants"] = [r2d(r) for r in cur.fetchall()]
|
||||
|
|
@ -605,7 +677,7 @@ def list_exercises(
|
|||
'variant_name', ev.variant_name,
|
||||
'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
|
||||
)
|
||||
|
|
@ -840,6 +912,211 @@ def delete_exercise(
|
|||
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) ---
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.7.8"
|
||||
APP_VERSION = "0.7.9"
|
||||
BUILD_DATE = "2026-04-27"
|
||||
DB_SCHEMA_VERSION = "20260427030"
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ MODULE_VERSIONS = {
|
|||
"groups": "0.1.0",
|
||||
"skills": "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_programs": "0.1.0",
|
||||
"planning": "0.2.0",
|
||||
|
|
@ -23,6 +23,16 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
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",
|
||||
"date": "2026-04-27",
|
||||
|
|
|
|||
|
|
@ -263,24 +263,55 @@ function ExerciseDetailPage() {
|
|||
{(exercise.variants || []).length > 0 && (
|
||||
<section className="card exercise-detail-section">
|
||||
<h2>Varianten</h2>
|
||||
{exercise.variants.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
paddingBottom: '1rem',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontSize: '15px' }}>{v.variant_name}</strong>
|
||||
{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>
|
||||
))}
|
||||
{exercise.variants.map((v) => {
|
||||
const dur =
|
||||
v.duration_min != null || v.duration_max != null
|
||||
? v.duration_min != null && v.duration_max != null && v.duration_min !== v.duration_max
|
||||
? `${v.duration_min}–${v.duration_max} Min.`
|
||||
: v.duration_min != null
|
||||
? `ca. ${v.duration_min} Min.`
|
||||
: `bis ca. ${v.duration_max} Min.`
|
||||
: null
|
||||
const diffLabel =
|
||||
v.difficulty_adjustment === 'easier'
|
||||
? 'einfacher'
|
||||
: v.difficulty_adjustment === 'harder'
|
||||
? 'schwerer'
|
||||
: v.difficulty_adjustment === 'same'
|
||||
? 'gleiche Schwierigkeit'
|
||||
: 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>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,82 @@ const INTENSITY_OPTIONS = [
|
|||
{ 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() {
|
||||
return {
|
||||
title: '',
|
||||
|
|
@ -157,6 +233,10 @@ function ExerciseFormPage() {
|
|||
const [loading, setLoading] = useState(!!isEdit)
|
||||
const [saving, setSaving] = useState(false)
|
||||
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 [mediaType, setMediaType] = useState('image')
|
||||
|
|
@ -202,6 +282,8 @@ function ExerciseFormPage() {
|
|||
if (!isEdit) {
|
||||
setFormData(emptyForm())
|
||||
setMediaList([])
|
||||
setVariants([])
|
||||
setVariantDraft(emptyVariantDraft())
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
|
@ -213,6 +295,8 @@ function ExerciseFormPage() {
|
|||
if (cancelled) return
|
||||
setFormData(detailToForm(exercise))
|
||||
setMediaList(exercise.media || [])
|
||||
setVariants((exercise.variants || []).map(apiVariantToRow))
|
||||
setVariantDraft(emptyVariantDraft())
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
alert(err.message || 'Übung nicht ladbar')
|
||||
|
|
@ -304,6 +388,7 @@ function ExerciseFormPage() {
|
|||
await api.updateExercise(exerciseId, payload)
|
||||
const ex = await api.getExercise(exerciseId)
|
||||
setMediaList(ex.media || [])
|
||||
setVariants((ex.variants || []).map(apiVariantToRow))
|
||||
alert('Gespeichert.')
|
||||
} else {
|
||||
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))
|
||||
|
||||
if (loading) {
|
||||
|
|
@ -689,6 +853,307 @@ function ExerciseFormPage() {
|
|||
</form>
|
||||
</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 && (
|
||||
<div className="card" style={{ marginTop: '16px' }}>
|
||||
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2>
|
||||
|
|
@ -783,10 +1248,9 @@ function ExerciseFormPage() {
|
|||
)}
|
||||
|
||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
|
||||
Varianten-Editor folgt später (API teilweise vorhanden).{' '}
|
||||
<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/{'{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>api.suggestExerciseAi</code>).
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -352,6 +352,31 @@ export async function deleteExercise(id) {
|
|||
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. */
|
||||
export async function suggestExerciseAi(payload) {
|
||||
return request('/api/exercises/ai/suggest', {
|
||||
|
|
@ -811,6 +836,10 @@ export const api = {
|
|||
createExercise,
|
||||
updateExercise,
|
||||
deleteExercise,
|
||||
createExerciseVariant,
|
||||
updateExerciseVariant,
|
||||
deleteExerciseVariant,
|
||||
reorderExerciseVariants,
|
||||
buildExerciseApiPayload,
|
||||
suggestExerciseAi,
|
||||
regenerateExerciseAi,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user