import React, { useCallback, useEffect, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import api from '../utils/api' import ExercisePickerModal from '../components/ExercisePickerModal' import ExercisePeekModal from '../components/ExercisePeekModal' function emptyGoal() { return { title: '', notes: '' } } function emptyExercise() { return { exercise_id: '', exercise_variant_id: '', exercise_title: '', variants: [] } } function emptySlot() { return { title: '', notes: '', training_unit_id: '', exercises: [] } } function defaultForm() { return { title: '', description: '', plan_mode: 'library', group_id: '', planned_period_start: '', planned_period_end: '', visibility: 'private', club_id: '', goals: [emptyGoal()], slots: [], } } function serverFrameworkToForm(fw) { const goalsIn = Array.isArray(fw.goals) && fw.goals.length ? fw.goals : [emptyGoal()] return { title: fw.title || '', description: fw.description || '', plan_mode: fw.plan_mode || 'library', group_id: fw.group_id != null ? String(fw.group_id) : '', planned_period_start: fw.planned_period_start || '', planned_period_end: fw.planned_period_end || '', visibility: fw.visibility || 'private', club_id: fw.club_id != null ? String(fw.club_id) : '', goals: goalsIn.map((g) => ({ title: g.title || '', notes: g.notes || '', })), slots: (fw.slots || []).map((s) => ({ title: s.title || '', notes: s.notes || '', training_unit_id: s.training_unit_id != null ? String(s.training_unit_id) : '', exercises: (s.exercises || []).map((ex) => ({ exercise_id: ex.exercise_id, exercise_variant_id: ex.exercise_variant_id != null ? String(ex.exercise_variant_id) : '', exercise_title: ex.exercise_title || '', variants: [], })), })), } } async function enrichSlotExercisesWithVariants(formSlots) { const ids = new Set() for (const s of formSlots || []) { for (const it of s.exercises || []) { if (it.exercise_id) ids.add(Number(it.exercise_id)) } } const cache = new Map() await Promise.all( [...ids].map(async (id) => { try { const ex = await api.getExercise(id) cache.set(id, { title: ex.title || '', variants: Array.isArray(ex.variants) ? ex.variants : [], }) } catch { cache.set(id, { title: '', variants: [] }) } }) ) return (formSlots || []).map((s) => ({ ...s, exercises: (s.exercises || []).map((it) => { if (!it.exercise_id) return it const c = cache.get(Number(it.exercise_id)) if (!c) return it return { ...it, exercise_title: it.exercise_title || c.title, variants: it.variants?.length ? it.variants : c.variants, } }), })) } function buildApiPayload(form) { const goals = (form.goals || []) .map((g, i) => ({ sort_order: i, title: (g.title || '').trim(), notes: (g.notes || '').trim() || null, })) .filter((g) => g.title) if (goals.length === 0) { throw new Error('Mindestens ein Entwicklungsziel mit Titel ist erforderlich.') } const slots = (form.slots || []).map((s, si) => { const tu = form.plan_mode === 'concrete' && s.training_unit_id ? parseInt(s.training_unit_id, 10) : null const exercises = (s.exercises || []) .map((ex, j) => { if (!ex.exercise_id) return null const vid = ex.exercise_variant_id return { exercise_id: parseInt(ex.exercise_id, 10), exercise_variant_id: vid !== '' && vid != null && !Number.isNaN(Number(vid)) ? parseInt(vid, 10) : null, order_index: j, } }) .filter(Boolean) return { sort_order: si, title: (s.title || '').trim() || null, notes: (s.notes || '').trim() || null, training_unit_id: tu, exercises, } }) const groupId = form.plan_mode === 'library' ? null : form.group_id && !Number.isNaN(parseInt(form.group_id, 10)) ? parseInt(form.group_id, 10) : null const clubId = form.club_id && !Number.isNaN(parseInt(form.club_id, 10)) ? parseInt(form.club_id, 10) : null return { title: (form.title || '').trim(), description: (form.description || '').trim() || null, plan_mode: form.plan_mode, group_id: groupId, planned_period_start: form.planned_period_start || null, planned_period_end: form.planned_period_end || null, visibility: form.visibility || 'private', club_id: clubId, goals, slots, } } export default function TrainingFrameworkProgramEditPage() { const { id: routeId } = useParams() const navigate = useNavigate() const isNew = routeId === 'new' const [loading, setLoading] = useState(!isNew) const [saving, setSaving] = useState(false) const [form, setForm] = useState(defaultForm()) const [groups, setGroups] = useState([]) const [clubs, setClubs] = useState([]) const [units, setUnits] = useState([]) const [pickerSlotIdx, setPickerSlotIdx] = useState(null) const [peekId, setPeekId] = useState(null) const loadMeta = useCallback(async () => { try { const [gr, cl] = await Promise.all([ api.listTrainingGroups({ status: 'active' }), api.listClubs(), ]) setGroups(Array.isArray(gr) ? gr : []) setClubs(Array.isArray(cl) ? cl : []) } catch { setGroups([]) setClubs([]) } }, []) useEffect(() => { loadMeta() }, [loadMeta]) useEffect(() => { if (form.plan_mode !== 'concrete' || !form.group_id) { setUnits([]) return } let cancelled = false ;(async () => { try { const today = new Date() const start = new Date(today) start.setFullYear(start.getFullYear() - 1) const end = new Date(today) end.setFullYear(end.getFullYear() + 1) const u = await api.listTrainingUnits({ group_id: parseInt(form.group_id, 10), start_date: start.toISOString().slice(0, 10), end_date: end.toISOString().slice(0, 10), }) if (!cancelled) setUnits(Array.isArray(u) ? u : []) } catch { if (!cancelled) setUnits([]) } })() return () => { cancelled = true } }, [form.plan_mode, form.group_id]) useEffect(() => { if (isNew) { setForm(defaultForm()) setLoading(false) return } const fid = parseInt(routeId, 10) if (Number.isNaN(fid)) { navigate('/planning/framework-programs', { replace: true }) return } setLoading(true) let cancelled = false ;(async () => { try { const fw = await api.getTrainingFrameworkProgram(fid) if (cancelled) return let next = serverFrameworkToForm(fw) next = { ...next, slots: await enrichSlotExercisesWithVariants(next.slots) } setForm(next) } catch (e) { alert(e.message || 'Laden fehlgeschlagen') navigate('/planning/framework-programs') } finally { if (!cancelled) setLoading(false) } })() return () => { cancelled = true } }, [isNew, routeId, navigate]) const updateField = (key, val) => { setForm((prev) => { const n = { ...prev, [key]: val } if (key === 'plan_mode' && val === 'library') { n.group_id = '' } return n }) } const moveGoal = (idx, dir) => { setForm((prev) => { const j = idx + dir if (j < 0 || j >= prev.goals.length) return prev const g = [...prev.goals] ;[g[idx], g[j]] = [g[j], g[idx]] return { ...prev, goals: g } }) } const addGoal = () => setForm((prev) => ({ ...prev, goals: [...prev.goals, emptyGoal()] })) const removeGoal = (idx) => setForm((prev) => { const g = prev.goals.filter((_, i) => i !== idx) return { ...prev, goals: g.length ? g : [emptyGoal()] } }) const moveSlot = (idx, dir) => { setForm((prev) => { const j = idx + dir if (j < 0 || j >= prev.slots.length) return prev const sl = [...prev.slots] ;[sl[idx], sl[j]] = [sl[j], sl[idx]] return { ...prev, slots: sl } }) } const addSlot = () => setForm((prev) => ({ ...prev, slots: [...prev.slots, emptySlot()] })) const removeSlot = (idx) => setForm((prev) => ({ ...prev, slots: prev.slots.filter((_, i) => i !== idx) })) const slotField = (sIdx, key, val) => { setForm((prev) => ({ ...prev, slots: prev.slots.map((s, i) => (i === sIdx ? { ...s, [key]: val } : s)), })) } const addExerciseToSlot = (sIdx) => { setForm((prev) => ({ ...prev, slots: prev.slots.map((s, i) => i === sIdx ? { ...s, exercises: [...(s.exercises || []), emptyExercise()] } : s ), })) } const moveExercise = (sIdx, eIdx, dir) => { setForm((prev) => ({ ...prev, slots: prev.slots.map((s, i) => { if (i !== sIdx) return s const j = eIdx + dir const ex = [...(s.exercises || [])] if (j < 0 || j >= ex.length) return s ;[ex[eIdx], ex[j]] = [ex[j], ex[eIdx]] return { ...s, exercises: ex } }), })) } const removeExercise = (sIdx, eIdx) => { setForm((prev) => ({ ...prev, slots: prev.slots.map((s, i) => { if (i !== sIdx) return s return { ...s, exercises: (s.exercises || []).filter((_, ei) => ei !== eIdx) } }), })) } const setExerciseChoice = async (sIdx, eIdx, exercise) => { const vid = '' let variants = Array.isArray(exercise.variants) ? exercise.variants : [] let title = exercise.title || '' if (!variants.length) { try { const full = await api.getExercise(exercise.id) variants = Array.isArray(full.variants) ? full.variants : [] title = full.title || title } catch { variants = [] } } setForm((prev) => ({ ...prev, slots: prev.slots.map((s, i) => { if (i !== sIdx) return s const exRows = [...(s.exercises || [])] const row = exRows[eIdx] || emptyExercise() exRows[eIdx] = { ...row, exercise_id: exercise.id, exercise_variant_id: vid, exercise_title: title, variants, } return { ...s, exercises: exRows } }), })) } const exerciseField = (sIdx, eIdx, key, val) => { setForm((prev) => ({ ...prev, slots: prev.slots.map((s, i) => { if (i !== sIdx) return s return { ...s, exercises: (s.exercises || []).map((ex, ei) => ei === eIdx ? { ...ex, [key]: val } : ex ), } }), })) } const handleSave = async () => { if (!(form.title || '').trim()) { alert('Titel ist Pflichtfeld.') return } let payload try { payload = buildApiPayload(form) } catch (e) { alert(e.message || 'Validierung') return } if (!payload.title) { alert('Titel ist Pflichtfeld.') return } setSaving(true) try { if (isNew) { const created = await api.createTrainingFrameworkProgram(payload) navigate(`/planning/framework-programs/${created.id}`, { replace: true }) } else { const fid = parseInt(routeId, 10) await api.updateTrainingFrameworkProgram(fid, payload) const refreshed = await api.getTrainingFrameworkProgram(fid) let next = serverFrameworkToForm(refreshed) next = { ...next, slots: await enrichSlotExercisesWithVariants(next.slots) } setForm(next) } } catch (e) { alert(e.message || 'Speichern fehlgeschlagen') } finally { setSaving(false) } } async function handleDelete() { if (isNew) return const fid = parseInt(routeId, 10) if (!confirm('Dieses Rahmenprogramm wirklich löschen?')) return try { await api.deleteTrainingFrameworkProgram(fid) navigate('/planning/framework-programs') } catch (e) { alert(e.message || 'Löschen fehlgeschlagen') } } if (loading) { return (
Laden…
← Alle Rahmenprogramme
Bibliothek: keine Trainingsgruppe und keine Slot‑Zuordnung zu Terminen. Konkret: optional Gruppe wählen und Slots mit geplanten Trainingseinheiten verknüpfen.
Noch keine Slots — mit + Slot legst du z. B. „Woche 1 / Einheit A“ an und ordnest Übungen zu.
) : null} {form.slots.map((slot, si) => (Wähle oben eine Trainingsgruppe, um geplante Einheiten zu laden.
) : null}Über Übung hinzufügen auswählen.
) : null} {(slot.exercises || []).map((ex, ei) => (