import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import api from '../utils/api' import ExercisePickerModal from '../components/ExercisePickerModal' import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm' import { useAuth } from '../context/AuthContext' import { useToast } from '../context/ToastContext' import UnsavedChangesPrompt from '../components/UnsavedChangesPrompt' import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../hooks/useUnsavedChangesBlocker' import { activeClubMemberships, getDefaultClubIdForGovernanceForms, getTenantClubDependencyKey } from '../utils/activeClub' function moduleFormSnapshot({ title, summary, goal, recommendedDurationMin, targetGroupNotes, deploymentContextNotes, visibility, clubIdField, primaryMethodId, items, }) { const itemRows = items.map((it) => { if (it.item_type === 'note') { return { k: 'n', b: it.note_body ?? '' } } return { k: 'e', id: it.exercise_id, v: it.exercise_variant_id, d: it.planned_duration_min, n: it.notes ?? '', } }) return JSON.stringify({ title: (title || '').trim(), summary: (summary || '').trim(), goal: goal || '', recommendedDurationMin: recommendedDurationMin || '', targetGroupNotes: targetGroupNotes || '', deploymentContextNotes: deploymentContextNotes || '', visibility: visibility || '', clubIdField: (clubIdField || '').trim(), primaryMethodId: (primaryMethodId || '').trim(), items: itemRows, }) } function nextLocalKey() { return `m-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } function swapItems(arr, i, j) { if (i === j || i < 0 || j < 0 || i >= arr.length || j >= arr.length) return [...arr] const n = [...arr] ;[n[i], n[j]] = [n[j], n[i]] return n } export default function TrainingModuleEditPage() { const { id: routeId } = useParams() const navigate = useNavigate() const isNew = !routeId || routeId === 'new' const moduleId = !isNew ? parseInt(routeId, 10) : NaN const [loading, setLoading] = useState(!isNew) const [saving, setSaving] = useState(false) const [methods, setMethods] = useState([]) const [pickerOpen, setPickerOpen] = useState(false) const [error, setError] = useState('') const [title, setTitle] = useState('') const [summary, setSummary] = useState('') const [goal, setGoal] = useState('') const [recommendedDurationMin, setRecommendedDurationMin] = useState('') const [targetGroupNotes, setTargetGroupNotes] = useState('') const [deploymentContextNotes, setDeploymentContextNotes] = useState('') const [visibility, setVisibility] = useState('club') const [clubIdField, setClubIdField] = useState('') const [primaryMethodId, setPrimaryMethodId] = useState('') const [items, setItems] = useState([]) const toast = useToast() const baselineRef = useRef(null) const latestFormRef = useRef({}) const [baselineReady, setBaselineReady] = useState(false) const [bypassDirty, setBypassDirty] = useState(false) latestFormRef.current = { title, summary, goal, recommendedDurationMin, targetGroupNotes, deploymentContextNotes, visibility, clubIdField, primaryMethodId, items, } const dirtySignature = moduleFormSnapshot(latestFormRef.current) useEffect(() => { baselineRef.current = null setBaselineReady(false) setBypassDirty(false) }, [isNew, moduleId]) useEffect(() => { if (loading) return const handle = window.setTimeout(() => { baselineRef.current = moduleFormSnapshot(latestFormRef.current) setBaselineReady(true) }, 120) return () => clearTimeout(handle) }, [loading, isNew, moduleId]) const formDirtyEffective = baselineReady && baselineRef.current != null && !bypassDirty && !loading && dirtySignature !== baselineRef.current const blocker = useUnsavedChangesBlocker(Boolean(formDirtyEffective && !saving)) useBeforeUnloadWhen(Boolean(formDirtyEffective && !saving)) const { user } = useAuth() const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) const [clubsForGovernanceForms, setClubsForGovernanceForms] = useState([]) useEffect(() => { if (!isPlatformAdmin) { setClubsForGovernanceForms([]) return undefined } let cancelled = false ;(async () => { try { const list = await api.listClubs() if (!cancelled) setClubsForGovernanceForms(Array.isArray(list) ? list : []) } catch { if (!cancelled) setClubsForGovernanceForms([]) } })() return () => { cancelled = true } }, [isPlatformAdmin, tenantClubDepKey]) const membershipClubRows = useMemo(() => activeClubMemberships(user?.clubs ?? []), [user?.clubs]) const visibilityClubChoices = useMemo(() => { if (isPlatformAdmin && clubsForGovernanceForms.length > 0) { return [...clubsForGovernanceForms].sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'de'), ) } return [...membershipClubRows].sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'de'), ) }, [isPlatformAdmin, clubsForGovernanceForms, membershipClubRows]) useEffect(() => { if (!isNew || visibility !== 'club') return if ((clubIdField || '').trim() !== '') return const xs = visibilityClubChoices if (xs.length === 1) setClubIdField(String(xs[0].id)) else { const r = getDefaultClubIdForGovernanceForms(user) if (r != null && xs.some((c) => Number(c.id) === Number(r))) setClubIdField(String(r)) } }, [isNew, visibility, clubIdField, visibilityClubChoices, user]) const itemsPayload = items.map((it, i) => { if (it.item_type === 'note') { return { item_type: 'note', order_index: i, note_body: it.note_body ?? '' } } const vid = it.exercise_variant_id !== '' && it.exercise_variant_id != null ? parseInt(it.exercise_variant_id, 10) : null return { item_type: 'exercise', order_index: i, exercise_id: parseInt(it.exercise_id, 10), exercise_variant_id: Number.isFinite(vid) ? vid : null, planned_duration_min: it.planned_duration_min !== '' && it.planned_duration_min != null ? parseInt(String(it.planned_duration_min), 10) : null, notes: it.notes?.trim() ? it.notes.trim() : null, } }) const loadCatalogs = useCallback(async () => { try { const m = await api.listMethods({}) setMethods(Array.isArray(m) ? m : []) } catch { setMethods([]) } }, []) useEffect(() => { loadCatalogs() }, [loadCatalogs]) useEffect(() => { if (isNew || !Number.isFinite(moduleId)) { setLoading(false) return } let cancelled = false async function load() { setLoading(true) setError('') try { const m = await api.getTrainingModule(moduleId) if (cancelled) return setTitle((m.title || '').trim()) setSummary((m.summary || '').trim()) setGoal(m.goal || '') setRecommendedDurationMin( m.recommended_duration_min != null && m.recommended_duration_min !== '' ? String(m.recommended_duration_min) : '' ) setTargetGroupNotes(m.target_group_notes || '') setDeploymentContextNotes(m.deployment_context_notes || '') setVisibility((m.visibility || 'club').trim()) setClubIdField(m.club_id != null ? String(m.club_id) : '') setPrimaryMethodId(m.primary_method_id != null ? String(m.primary_method_id) : '') const nextItems = [] for (const row of Array.isArray(m.items) ? m.items : []) { if (row.item_type === 'note') { nextItems.push({ localKey: nextLocalKey(), item_type: 'note', note_body: row.note_body || '' }) continue } const ex = await hydrateExercisePlanningRow({ id: row.exercise_id, title: '', variants: [], }) if (ex) { ex.localKey = nextLocalKey() if (row.exercise_variant_id) ex.exercise_variant_id = String(row.exercise_variant_id) ex.planned_duration_min = row.planned_duration_min != null && row.planned_duration_min !== '' ? String(row.planned_duration_min) : '' ex.notes = row.notes || '' nextItems.push(ex) } } setItems(nextItems) } catch (e) { if (!cancelled) setError(e.message || 'Laden fehlgeschlagen') } finally { if (!cancelled) setLoading(false) } } load() return () => { cancelled = true } }, [isNew, moduleId]) const buildBody = () => { let cid = null if (visibility === 'club') { const raw = (clubIdField || '').trim() if (raw !== '') { const p = parseInt(raw, 10) if (Number.isFinite(p) && p >= 1) cid = p } else if (visibilityClubChoices.length === 1) { cid = visibilityClubChoices[0].id } } const pm = primaryMethodId !== '' && primaryMethodId != null ? parseInt(primaryMethodId, 10) : null return { title: title.trim(), summary: summary.trim() || null, goal: goal.trim() || null, recommended_duration_min: recommendedDurationMin !== '' ? parseInt(recommendedDurationMin, 10) : null, target_group_notes: targetGroupNotes.trim() || null, deployment_context_notes: deploymentContextNotes.trim() || null, visibility, club_id: cid != null && Number.isFinite(cid) && cid >= 1 ? cid : visibility === 'club' ? undefined : null, primary_method_id: pm != null && Number.isFinite(pm) && pm >= 1 ? pm : null, items: itemsPayload.filter((row) => row.item_type === 'note' ? true : Number.isFinite(row.exercise_id) && row.exercise_id >= 1 ), } } const performModuleSave = async ({ fromUnsavedDialog = false } = {}) => { if (!title.trim()) { toast.error('Titel ist Pflicht.') return false } setSaving(true) setError('') try { const body = buildBody() if (isNew) { const created = await api.createTrainingModule(body) toast.success('Trainingsmodul angelegt.') if (!fromUnsavedDialog) { navigate(`/planning/training-modules/${created.id}`, { replace: true }) } return true } await api.updateTrainingModule(moduleId, body) baselineRef.current = moduleFormSnapshot(latestFormRef.current) setBypassDirty(false) toast.success('Gespeichert.') return true } catch (err) { const msg = err.message || 'Speichern fehlgeschlagen' setError(msg) toast.error(msg) return false } finally { setSaving(false) } } const handleSave = async (e) => { e.preventDefault() await performModuleSave({ fromUnsavedDialog: false }) } const handleUnsavedDialogSave = async () => { const ok = await performModuleSave({ fromUnsavedDialog: true }) if (ok) blocker.proceed() } const pickExercise = async (ex) => { if (!ex?.id) return const row = await hydrateExercisePlanningRow(ex) if (row) row.localKey = nextLocalKey() if (row) setItems((prev) => [...prev, row]) setPickerOpen(false) } return (
← Zurück zur Modul‑Bibliothek
Reihenfolge der Positionen entspricht der späteren Übernahme in einen Abschnitt der Einheit (Kopie).
{error ?{error}
: null} {loading ? (Laden …
) : ( )}