diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9675635..069dd33 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -21,6 +21,8 @@ import ExerciseFormPage from './pages/ExerciseFormPage' import ClubsPage from './pages/ClubsPage' import SkillsPage from './pages/SkillsPage' import TrainingPlanningPage from './pages/TrainingPlanningPage' +import TrainingFrameworkProgramsListPage from './pages/TrainingFrameworkProgramsListPage' +import TrainingFrameworkProgramEditPage from './pages/TrainingFrameworkProgramEditPage' import TrainingUnitRunPage from './pages/TrainingUnitRunPage' import TrainingCoachPage from './pages/TrainingCoachPage' import AdminCatalogsPage from './pages/AdminCatalogsPage' @@ -157,9 +159,12 @@ function AppRoutes() { } /> } /> - } /> + } /> + } /> + } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx new file mode 100644 index 0000000..5b2d806 --- /dev/null +++ b/frontend/src/pages/TrainingFrameworkProgramEditPage.jsx @@ -0,0 +1,809 @@ +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…

+
+ ) + } + + return ( +
+
+

+ + ← Alle Rahmenprogramme + +

+ +

{isNew ? 'Neues Rahmenprogramm' : 'Rahmenprogramm bearbeiten'}

+ +
+

Stammdaten

+
+ + updateField('title', e.target.value)} + placeholder="z. B. Vorbereitung Gürtelprüfung — 8 Wochen" + /> +
+
+ +