import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { Link, useSearchParams } from 'react-router-dom' import api from '../../utils/api' import { useAuth } from '../../context/AuthContext' import { useToast } from '../../context/ToastContext' import { activeClubMemberships, getTenantClubDependencyKey } from '../../utils/activeClub' import ExercisePickerModal from '../ExercisePickerModal' import ExercisePeekModal from '../ExercisePeekModal' import PageSectionNav from '../PageSectionNav' import TrainingPlanningFrameworkImportModal from './TrainingPlanningFrameworkImportModal' import TrainingPlanningModuleApplyModal from './TrainingPlanningModuleApplyModal' import TrainingPlanningTrainerAssignModal from './TrainingPlanningTrainerAssignModal' import TrainingPlanningUnitFormModal from './TrainingPlanningUnitFormModal' /* Parallele Streams: Editor bleibt flache Abschnittsliste; `planLoc` pro Abschnitt steuert PUT `phases` vs. Legacy `sections`. */ import { defaultSection, normalizeUnitToForm, enrichSectionsWithVariants, buildPlanPayloadForSave, hydrateExercisePlanningRow, insertTrainingModuleIntoPlanningSections, templateSectionsPayloadFromFormSections, formSectionsFromPlanTemplateRows, } from '../../utils/trainingUnitSectionsForm' import { addDaysIsoDate, pad2, toIsoLocal, mondayIndex, getCalendarGridRange, shiftCalendarMonth, enumerateIsoDays, WEEKDAYS_DE, toNumList, sessionAssignDefaults, normalizeGroupCoTrainerIds, filterDirectoryExcludingLead, frameworkLineageText, } from '../../utils/trainingPlanningPageHelpers' function TrainingPlanningPageRoot() { const { user } = useAuth() const toast = useToast() const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) const [searchParams, setSearchParams] = useSearchParams() const unitDeepLinkHandledRef = useRef(null) const [groups, setGroups] = useState([]) const [selectedGroupId, setSelectedGroupId] = useState('') const [units, setUnits] = useState([]) const [planTemplates, setPlanTemplates] = useState([]) const [loading, setLoading] = useState(true) const [showModal, setShowModal] = useState(false) const [editingUnit, setEditingUnit] = useState(null) /** Abschnitts-Editor bei Bearbeitung: Planung vs. Nachbereitung (Ist & Abweichungen) */ const [sectionsEditMode, setSectionsEditMode] = useState('planning') const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('') const [exercisePickerOpen, setExercisePickerOpen] = useState(false) const [exercisePickerTarget, setExercisePickerTarget] = useState(null) const [planningPeekCtx, setPlanningPeekCtx] = useState(null) const today = new Date().toISOString().split('T')[0] const thirtyDaysLater = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] const [frameworkImportOpen, setFrameworkImportOpen] = useState(false) const [frameworkProgramsList, setFrameworkProgramsList] = useState([]) const [fwImportProgramId, setFwImportProgramId] = useState('') const [fwImportDetail, setFwImportDetail] = useState(null) const [fwImportLoading, setFwImportLoading] = useState(false) const [fwImportSelectedSlots, setFwImportSelectedSlots] = useState(() => new Set()) const [fwImportSlotDates, setFwImportSlotDates] = useState({}) const [fwImportStartDate, setFwImportStartDate] = useState(today) const [fwImportIntervalDays, setFwImportIntervalDays] = useState(7) const [fwImportSubmitting, setFwImportSubmitting] = useState(false) const [moduleApplyOpen, setModuleApplyOpen] = useState(false) const [moduleApplyBusy, setModuleApplyBusy] = useState(false) const [moduleApplyList, setModuleApplyList] = useState([]) const [moduleApplyModuleId, setModuleApplyModuleId] = useState('') const [moduleApplySectionIx, setModuleApplySectionIx] = useState(0) const [moduleApplyInsertSlot, setModuleApplyInsertSlot] = useState('before:0') const [moduleApplyErr, setModuleApplyErr] = useState('') const [moduleApplyPlacementLocked, setModuleApplyPlacementLocked] = useState(false) const [moduleApplySearchQuery, setModuleApplySearchQuery] = useState('') const [modulePickPreview, setModulePickPreview] = useState({ loading: false, moduleId: '', exercises: [], notes: 0, err: '', }) const [startDate, setStartDate] = useState(today) const [endDate, setEndDate] = useState(thirtyDaysLater) const [planView, setPlanView] = useState('list') const [calendarMonthStr, setCalendarMonthStr] = useState(() => today.slice(0, 7)) const [planScope, setPlanScope] = useState('group') const [assignedToMeOnly, setAssignedToMeOnly] = useState(false) const [clubDirectory, setClubDirectory] = useState([]) const [assignModalOpen, setAssignModalOpen] = useState(false) const [assignDraft, setAssignDraft] = useState({ unit: null, lead_trainer_profile_id: '', session_assistants_inherit: true, session_assistant_profile_ids: [], }) const [assignSaving, setAssignSaving] = useState(false) const [formData, setFormData] = useState({ group_id: '', planned_date: '', planned_time_start: '', planned_time_end: '', planned_focus: '', actual_date: '', actual_time_start: '', actual_time_end: '', attendance_count: '', status: 'planned', notes: '', trainer_notes: '', debrief_completed: false, sections: [defaultSection()], ...sessionAssignDefaults() }) const planningFormRef = useRef(formData) planningFormRef.current = formData const moduleApplyFilteredList = useMemo(() => { const q = moduleApplySearchQuery.trim().toLowerCase().replace(/\s+/g, ' ') const words = q ? q.split(' ').filter(Boolean) : [] const list = Array.isArray(moduleApplyList) ? moduleApplyList : [] if (!words.length) return list return list.filter((m) => { const blob = [ m.title, m.summary, m.goal, m.target_group_notes, m.deployment_context_notes, ] .map((x) => String(x ?? '').toLowerCase()) .join('\n') return words.every((w) => blob.includes(w)) }) }, [moduleApplySearchQuery, moduleApplyList]) const modulePlacementSummary = useMemo(() => { const secs = Array.isArray(formData.sections) ? formData.sections : [] let si = typeof moduleApplySectionIx === 'number' ? moduleApplySectionIx : parseInt(String(moduleApplySectionIx), 10) if (!Number.isFinite(si)) si = 0 si = Math.max(0, Math.min(si, secs.length ? secs.length - 1 : 0)) const cap = secs[si]?.items?.length ?? 0 let beforeIx = cap if (typeof moduleApplyInsertSlot === 'string' && moduleApplyInsertSlot.startsWith('before:')) { const zi = parseInt(moduleApplyInsertSlot.slice('before:'.length), 10) if (Number.isFinite(zi)) beforeIx = Math.min(Math.max(0, zi), cap) } const rawTitle = (secs[si]?.title || '').trim() const secTitle = rawTitle || `Abschnitt ${si + 1}` let positionDescription if (cap <= 0) positionDescription = 'als erste Einträge dieses Abschnitts' else if (beforeIx <= 0) positionDescription = 'vor dem ersten Eintrag dieses Abschnitts' else if (beforeIx >= cap) positionDescription = 'nach dem letzten Eintrag dieses Abschnitts' else positionDescription = `unmittelbar vor Eintrag ${beforeIx + 1} (${cap} Einträge im Abschnitt)` return { secTitle, positionDescription } }, [formData.sections, moduleApplySectionIx, moduleApplyInsertSlot]) useEffect(() => { if (!moduleApplyOpen || !moduleApplyFilteredList.length) return if (moduleApplyFilteredList.some((m) => String(m.id) === String(moduleApplyModuleId))) return setModuleApplyModuleId(String(moduleApplyFilteredList[0].id)) }, [moduleApplyOpen, moduleApplyFilteredList, moduleApplyModuleId]) const planningModalClubId = useMemo(() => { const gid = Number(formData.group_id) if (!Number.isFinite(gid) || gid < 1) return null const g = groups.find((x) => Number(x.id) === gid) if (!g || g.club_id == null || g.club_id === '') return null const c = Number(g.club_id) return Number.isFinite(c) ? c : null }, [groups, formData.group_id]) const moduleApplyTargetItems = useMemo(() => { const secs = formData.sections || [] if (!secs.length) return [] let ix = typeof moduleApplySectionIx === 'number' ? moduleApplySectionIx : parseInt(String(moduleApplySectionIx), 10) if (!Number.isFinite(ix)) ix = 0 if (ix < 0 || ix >= secs.length) return [] const sec = secs[ix] return Array.isArray(sec?.items) ? sec.items : [] }, [formData.sections, moduleApplySectionIx]) const refreshPlanningSectionMeta = useCallback(async () => { const next = await enrichSectionsWithVariants(planningFormRef.current.sections) setFormData((prev) => ({ ...prev, sections: next })) }, []) const loadPlanTemplates = useCallback(async () => { try { const tpl = await api.listTrainingPlanTemplates() setPlanTemplates(tpl) } catch (e) { console.error('Vorlagen laden:', e) } }, []) const loadData = useCallback(async () => { try { const groupsData = await api.listTrainingGroups({ status: 'active' }) setGroups(groupsData) await loadPlanTemplates() if (groupsData.length > 0) { setSelectedGroupId((prev) => { const prevStr = prev != null && prev !== '' ? String(prev) : '' const stillThere = prevStr && groupsData.some((g) => String(g.id) === prevStr) if (stillThere) return prevStr const ownGroup = groupsData.find((g) => g.trainer_id === user?.id) if (ownGroup) return String(ownGroup.id) if (groupsData.length === 1) return String(groupsData[0].id) return '' }) } else { setSelectedGroupId('') } } catch (err) { console.error('Failed to load data:', err) toast.error('Fehler beim Laden: ' + err.message) } finally { setLoading(false) } }, [user?.id, loadPlanTemplates]) const loadUnits = useCallback(async () => { if (!selectedGroupId) return let start = startDate let end = endDate if (planView === 'calendar') { const r = getCalendarGridRange(calendarMonthStr) start = r.gridStart end = r.gridEnd } const gid = parseInt(selectedGroupId, 10) const groupRow = groups.find((g) => g.id === gid) const clubId = groupRow?.club_id try { const filters = { start_date: start, end_date: end } if (assignedToMeOnly) { filters.assigned_to_me = true } if (planScope === 'club' && clubId) { filters.club_id = clubId } else { filters.group_id = gid } const unitsData = await api.listTrainingUnits(filters) setUnits(unitsData) } catch (err) { console.error('Failed to load units:', err) } }, [ selectedGroupId, groups, startDate, endDate, planView, calendarMonthStr, planScope, assignedToMeOnly ]) useEffect(() => { loadData() }, [loadData, tenantClubDepKey]) useEffect(() => { if (selectedGroupId) { loadUnits() } }, [selectedGroupId, loadUnits]) const selectedGroupClubIdMemo = useMemo(() => { const g = groups.find((gr) => gr.id === parseInt(selectedGroupId, 10)) return g?.club_id != null ? Number(g.club_id) : null }, [groups, selectedGroupId]) const canClubOrgTraining = useMemo(() => { const r = (user?.role || '').toLowerCase() if (r === 'admin' || r === 'superadmin') return true if (selectedGroupClubIdMemo == null || !Number.isFinite(selectedGroupClubIdMemo)) return false const row = activeClubMemberships(user?.clubs).find((c) => Number(c.id) === selectedGroupClubIdMemo) return Array.isArray(row?.roles) && row.roles.includes('club_admin') }, [user?.role, user?.clubs, selectedGroupClubIdMemo]) const clubAdminClubIdSet = useMemo(() => { const ids = [] for (const c of activeClubMemberships(user?.clubs)) { if (Array.isArray(c.roles) && c.roles.includes('club_admin')) { const id = Number(c.id) if (Number.isFinite(id)) ids.push(id) } } return new Set(ids) }, [user?.clubs]) useEffect(() => { const gid = parseInt(formData.group_id || selectedGroupId || '0', 10) const gModal = Number.isFinite(gid) && gid >= 1 ? groups.find((x) => x.id === gid) : null const clubForModal = gModal?.club_id != null ? Number(gModal.club_id) : null let assignModalClubId = null if (assignModalOpen && assignDraft.unit?.group_id != null) { const ug = Number(assignDraft.unit.group_id) const gAssign = Number.isFinite(ug) ? groups.find((x) => x.id === ug) : null if (gAssign?.club_id != null) assignModalClubId = Number(gAssign.club_id) } const loadClubId = showModal && clubForModal != null && Number.isFinite(clubForModal) ? clubForModal : assignModalOpen && assignModalClubId != null && Number.isFinite(assignModalClubId) ? assignModalClubId : canClubOrgTraining && selectedGroupClubIdMemo != null && Number.isFinite(selectedGroupClubIdMemo) ? selectedGroupClubIdMemo : null if (loadClubId == null || !Number.isFinite(loadClubId)) { setClubDirectory([]) return undefined } let cancelled = false ;(async () => { try { const d = await api.clubMembersDirectory(loadClubId) if (!cancelled) setClubDirectory(Array.isArray(d) ? d : []) } catch (err) { if (!cancelled) { console.error('Mitgliederverzeichnis:', err) setClubDirectory([]) } } })() return () => { cancelled = true } }, [ showModal, assignModalOpen, assignDraft.unit, formData.group_id, selectedGroupId, groups, canClubOrgTraining, selectedGroupClubIdMemo, ]) useEffect(() => { if (!frameworkImportOpen) return let cancelled = false ;(async () => { try { const list = await api.listTrainingFrameworkPrograms() if (!cancelled) setFrameworkProgramsList(Array.isArray(list) ? list : []) } catch (e) { if (!cancelled) { console.error('Rahmenprogramme laden:', e) setFrameworkProgramsList([]) } } })() return () => { cancelled = true } }, [frameworkImportOpen]) const openFrameworkImportModal = useCallback(() => { setFwImportProgramId('') setFwImportDetail(null) setFwImportSelectedSlots(new Set()) setFwImportSlotDates({}) setFwImportStartDate(new Date().toISOString().split('T')[0]) setFwImportIntervalDays(7) setFrameworkImportOpen(true) }, []) const onFwImportProgramChange = async (idStr) => { setFwImportProgramId(idStr) if (!idStr) { setFwImportDetail(null) return } setFwImportLoading(true) try { const d = await api.getTrainingFrameworkProgram(parseInt(idStr, 10)) setFwImportDetail(d) setFwImportSelectedSlots(new Set()) setFwImportSlotDates({}) } catch (e) { toast.error(e.message || 'Rahmenprogramm laden fehlgeschlagen') setFwImportDetail(null) } finally { setFwImportLoading(false) } } const toggleFwImportSlot = (slot) => { if (!slot?.blueprint_training_unit_id) return const sid = slot.id setFwImportSelectedSlots((prev) => { const n = new Set(prev) if (n.has(sid)) n.delete(sid) else n.add(sid) return n }) } const applyFwImportDateSuggestions = () => { if (!fwImportDetail?.slots?.length) return const sorted = [...fwImportDetail.slots].sort( (a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) ) let offset = 0 const iv = Math.max(0, Number(fwImportIntervalDays) || 0) const next = {} for (const s of sorted) { if (!fwImportSelectedSlots.has(s.id)) continue if (!s.blueprint_training_unit_id) continue next[String(s.id)] = addDaysIsoDate(fwImportStartDate, offset) offset += iv } setFwImportSlotDates((prev) => ({ ...prev, ...next })) } const submitFrameworkImport = async () => { if (!selectedGroupId) { toast.error('Bitte zuerst eine Trainingsgruppe wählen.') return } const gid = parseInt(selectedGroupId, 10) if (!fwImportDetail?.slots?.length) return const sorted = [...fwImportDetail.slots].sort( (a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) ) const picks = sorted.filter( (s) => fwImportSelectedSlots.has(s.id) && s.blueprint_training_unit_id ) if (!picks.length) { toast.error('Bitte mindestens eine Session mit Ablauf (Blueprint) auswählen.') return } for (const s of picks) { const key = String(s.id) const date = fwImportSlotDates[key] || fwImportStartDate if (!date) { toast.error('Bitte für jede ausgewählte Session ein Datum setzen (oder Datumsvorschläge nutzen).') return } } setFwImportSubmitting(true) try { for (const s of picks) { const key = String(s.id) const date = fwImportSlotDates[key] || fwImportStartDate await api.createTrainingUnitFromFrameworkSlot({ group_id: gid, planned_date: date, framework_slot_id: s.id, }) } setFrameworkImportOpen(false) await loadUnits() } catch (e) { toast.error(e.message || 'Übernahme fehlgeschlagen') } finally { setFwImportSubmitting(false) } } const handleCreate = () => { if (!selectedGroupId) { toast.error('Bitte wähle zuerst eine Trainingsgruppe') return } const group = groups.find((g) => g.id === parseInt(selectedGroupId, 10)) setEditingUnit(null) setDraftPlanTemplateId('') setFormData({ group_id: selectedGroupId, planned_date: today, planned_time_start: group?.time_start?.slice(0, 5) || '', planned_time_end: group?.time_end?.slice(0, 5) || '', planned_focus: '', actual_date: '', actual_time_start: '', actual_time_end: '', attendance_count: '', status: 'planned', notes: '', trainer_notes: '', debrief_completed: false, sections: [defaultSection('Hauptteil')], ...sessionAssignDefaults() }) setSectionsEditMode('planning') setShowModal(true) } const handleCreateForDate = (isoDay) => { if (!selectedGroupId) { toast.error('Bitte wähle zuerst eine Trainingsgruppe') return } const group = groups.find((g) => g.id === parseInt(selectedGroupId, 10)) setEditingUnit(null) setDraftPlanTemplateId('') setFormData({ group_id: selectedGroupId, planned_date: isoDay, planned_time_start: group?.time_start?.slice(0, 5) || '', planned_time_end: group?.time_end?.slice(0, 5) || '', planned_focus: '', actual_date: '', actual_time_start: '', actual_time_end: '', attendance_count: '', status: 'planned', notes: '', trainer_notes: '', debrief_completed: false, sections: [defaultSection('Hauptteil')], ...sessionAssignDefaults() }) setSectionsEditMode('planning') setShowModal(true) } const applyTemplateFromSelect = async (templateId) => { setDraftPlanTemplateId(templateId) if (!templateId) return try { const tpl = await api.getTrainingPlanTemplate(parseInt(templateId, 10)) setFormData((fd) => ({ ...fd, sections: (tpl.sections || []).length ? formSectionsFromPlanTemplateRows(tpl.sections) : [defaultSection()], })) } catch (err) { toast.error('Vorlage laden: ' + err.message) } } const handleEdit = useCallback(async (unit) => { try { const fullUnit = await api.getTrainingUnit(unit.id) setEditingUnit(fullUnit) setDraftPlanTemplateId(fullUnit.plan_template_id ? String(fullUnit.plan_template_id) : '') let sections = normalizeUnitToForm(fullUnit) sections = await enrichSectionsWithVariants(sections) setFormData({ group_id: fullUnit.group_id, planned_date: fullUnit.planned_date || '', planned_time_start: fullUnit.planned_time_start?.slice(0, 5) || '', planned_time_end: fullUnit.planned_time_end?.slice(0, 5) || '', planned_focus: fullUnit.planned_focus || '', actual_date: fullUnit.actual_date || '', actual_time_start: fullUnit.actual_time_start?.slice(0, 5) || '', actual_time_end: fullUnit.actual_time_end?.slice(0, 5) || '', attendance_count: fullUnit.attendance_count ?? '', status: fullUnit.status || 'planned', notes: fullUnit.notes || '', trainer_notes: fullUnit.trainer_notes || '', debrief_completed: Boolean(fullUnit.debrief_completed_at), sections, lead_trainer_profile_id: fullUnit.lead_trainer_profile_id != null && fullUnit.lead_trainer_profile_id !== '' ? String(fullUnit.lead_trainer_profile_id) : '', session_assistants_inherit: fullUnit.assistant_trainer_profile_ids == null || fullUnit.assistant_trainer_profile_ids === undefined, session_assistant_profile_ids: (() => { const efLead = fullUnit.effective_lead_trainer_profile_id != null ? Number(fullUnit.effective_lead_trainer_profile_id) : null let xs = toNumList(fullUnit.assistant_trainer_profile_ids) if (efLead != null && Number.isFinite(efLead)) xs = xs.filter((id) => id !== efLead) return xs })(), }) setSectionsEditMode(fullUnit.status === 'completed' ? 'debrief' : 'planning') setShowModal(true) } catch (err) { toast.error('Fehler beim Laden: ' + err.message) throw err } }, []) useEffect(() => { if (!user?.id || loading) return const uid = searchParams.get('unit') if (!uid) { unitDeepLinkHandledRef.current = null return } if (unitDeepLinkHandledRef.current === uid) return const idNum = parseInt(uid, 10) if (!Number.isFinite(idNum)) return unitDeepLinkHandledRef.current = uid handleEdit({ id: idNum }) .then(() => { setSearchParams( (prev) => { const next = new URLSearchParams(prev) next.delete('unit') next.delete('debrief') return next }, { replace: true } ) }) .catch(() => { unitDeepLinkHandledRef.current = null setSearchParams( (prev) => { const next = new URLSearchParams(prev) next.delete('unit') next.delete('debrief') return next }, { replace: true } ) }) }, [user?.id, loading, searchParams, handleEdit, setSearchParams]) const handleSaveAsTemplate = async () => { const name = window.prompt('Name für die neue Trainingsvorlage (nur Abschnitts-Gliederung):') if (!name?.trim()) return try { await api.createTrainingPlanTemplate({ name: name.trim(), sections: templateSectionsPayloadFromFormSections(formData.sections), }) await loadPlanTemplates() toast.success('Vorlage gespeichert.') } catch (err) { toast.error('Speichern: ' + err.message) } } const handleDeletePlanTemplate = useCallback( async (tpl) => { if (!tpl?.id) return const label = (tpl.name || '').trim() || `Vorlage #${tpl.id}` if ( !window.confirm( `Trainingsvorlage „${label}“ wirklich löschen? Die Aktion kann nicht rückgängig gemacht werden.` ) ) { return } try { await api.deleteTrainingPlanTemplate(tpl.id) setDraftPlanTemplateId((prev) => (String(prev) === String(tpl.id) ? '' : prev)) await loadPlanTemplates() toast.success('Vorlage gelöscht.') } catch (err) { toast.error(err.message || 'Löschen fehlgeschlagen') } }, [loadPlanTemplates, toast] ) const openModuleApplyModal = useCallback(async (placement) => { setModuleApplyErr('') setModuleApplySearchQuery('') const placementLocked = placement != null && typeof placement.sectionIndex === 'number' && typeof placement.insertBeforeIndex === 'number' setModuleApplyPlacementLocked(placementLocked) const secs = planningFormRef.current?.sections ?? [] let secIx = 0 let before = 0 if (secs.length) { if (placement && typeof placement.sectionIndex === 'number') { secIx = Math.min(Math.max(0, placement.sectionIndex), secs.length - 1) const items = Array.isArray(secs[secIx]?.items) ? secs[secIx].items : [] const cap = items.length if (typeof placement.insertBeforeIndex === 'number' && Number.isFinite(placement.insertBeforeIndex)) { before = Math.min(Math.max(0, placement.insertBeforeIndex), cap) } else before = cap } else { const items = Array.isArray(secs[0]?.items) ? secs[0].items : [] before = items.length secIx = 0 } } setModuleApplySectionIx(secIx) setModuleApplyInsertSlot(`before:${before}`) setModuleApplyOpen(true) try { const list = await api.listTrainingModules() const arr = Array.isArray(list) ? list : [] setModuleApplyList(arr) setModuleApplyModuleId(arr.length ? String(arr[0].id) : '') } catch (e) { setModuleApplyErr(e.message || 'Module konnten nicht geladen werden') setModuleApplyList([]) } }, []) const onModuleApplySectionIndexChange = useCallback((newIx) => { setModuleApplySectionIx(newIx) const secsNow = planningFormRef.current?.sections ?? [] const len = Array.isArray(secsNow[newIx]?.items) ? secsNow[newIx].items.length : 0 setModuleApplyInsertSlot(`before:${len}`) }, []) const handleApplyTrainingModuleConfirm = useCallback(async () => { const mid = parseInt(moduleApplyModuleId, 10) if (!Number.isFinite(mid)) { toast.error('Bitte ein Trainingsmodul wählen.') return } let secIx = parseInt(String(moduleApplySectionIx), 10) if (!Number.isFinite(secIx)) secIx = 0 const baseSections = planningFormRef.current?.sections ?? formData.sections ?? [] if (!baseSections.length) { toast.error('Keine Abschnitte im Formular.') return } if (secIx < 0 || secIx >= baseSections.length) secIx = 0 const secItems = Array.isArray(baseSections[secIx]?.items) ? baseSections[secIx].items : [] const itemCap = secItems.length let insertBefore = itemCap if (typeof moduleApplyInsertSlot === 'string' && moduleApplyInsertSlot.startsWith('before:')) { const zi = parseInt(moduleApplyInsertSlot.slice('before:'.length), 10) if (Number.isFinite(zi)) insertBefore = Math.min(Math.max(0, zi), itemCap) } setModuleApplyBusy(true) setModuleApplyErr('') try { const detail = await api.getTrainingModule(mid) let nextSections = await insertTrainingModuleIntoPlanningSections({ sections: baseSections, moduleDetail: detail, sectionIndex: secIx, insertBeforeItemIndex: insertBefore, }) nextSections = await enrichSectionsWithVariants(nextSections) setFormData((fd) => ({ ...fd, sections: nextSections })) setModuleApplyOpen(false) setModuleApplyPlacementLocked(false) } catch (e) { setModuleApplyErr(e.message || 'Einfügen fehlgeschlagen') } finally { setModuleApplyBusy(false) } }, [moduleApplyModuleId, moduleApplySectionIx, moduleApplyInsertSlot]) useEffect(() => { if (!moduleApplyOpen) { setModulePickPreview({ loading: false, moduleId: '', exercises: [], notes: 0, err: '', }) return undefined } const mid = parseInt(String(moduleApplyModuleId), 10) if (!Number.isFinite(mid) || mid < 1) { setModulePickPreview({ loading: false, moduleId: '', exercises: [], notes: 0, err: '', }) return undefined } let cancelled = false setModulePickPreview({ loading: true, moduleId: String(mid), exercises: [], notes: 0, err: '', }) ;(async () => { try { const detail = await api.getTrainingModule(mid) if (cancelled) return const itemsSorted = [...(detail.items ?? [])].sort( (a, b) => (a.order_index ?? 0) - (b.order_index ?? 0) ) const uniqueEx = new Set() let notes = 0 for (const row of itemsSorted) { if ((row.item_type || '') !== 'note') { const eid = row.exercise_id if (eid) uniqueEx.add(Number(eid)) continue } const b = String(row.note_body ?? '').trim() if (b === '---') continue notes += 1 } const titleById = new Map() await Promise.all( [...uniqueEx].map(async (eid) => { try { const ex = await api.getExercise(eid) titleById.set(eid, (ex?.title || '').trim() || `Übung #${eid}`) } catch { titleById.set(eid, `Übung #${eid}`) } }) ) if (cancelled) return const exTitlesInOrder = [] for (const row of itemsSorted) { if ((row.item_type || '') !== 'exercise') continue const eid = Number(row.exercise_id) if (!Number.isFinite(eid)) continue exTitlesInOrder.push(titleById.get(eid) || `Übung #${eid}`) } setModulePickPreview({ loading: false, moduleId: String(mid), exercises: exTitlesInOrder, notes, err: '', }) } catch (e) { if (!cancelled) { setModulePickPreview({ loading: false, moduleId: String(mid), exercises: [], notes: 0, err: e?.message || 'Vorschau fehlgeschlagen', }) } } })() return () => { cancelled = true } }, [moduleApplyOpen, moduleApplyModuleId]) const handleTakeLead = async (unit) => { if (!user?.id) return try { await api.updateTrainingUnit(unit.id, { lead_trainer_profile_id: user.id }) await loadUnits() } catch (err) { toast.error(err.message || 'Leitung konnte nicht übernommen werden') } } const openTrainerAssignModal = (unit) => { const effLead = unit.effective_lead_trainer_profile_id != null ? Number(unit.effective_lead_trainer_profile_id) : null let coIds = toNumList(unit.assistant_trainer_profile_ids) if (effLead != null && Number.isFinite(effLead)) { coIds = coIds.filter((id) => id !== effLead) } setAssignDraft({ unit, lead_trainer_profile_id: unit.lead_trainer_profile_id != null && unit.lead_trainer_profile_id !== '' ? String(unit.lead_trainer_profile_id) : '', session_assistants_inherit: unit.assistant_trainer_profile_ids == null || unit.assistant_trainer_profile_ids === undefined, session_assistant_profile_ids: coIds, }) setAssignModalOpen(true) } const saveTrainerAssignModal = async () => { if (!assignDraft.unit) return setAssignSaving(true) try { const payload = {} const leadStr = String(assignDraft.lead_trainer_profile_id || '').trim() if (leadStr) payload.lead_trainer_profile_id = parseInt(leadStr, 10) else payload.lead_trainer_profile_id = null if (assignDraft.session_assistants_inherit) { payload.assistant_trainer_profile_ids = null } else { payload.assistant_trainer_profile_ids = [...assignDraft.session_assistant_profile_ids].sort((a, b) => a - b) } await api.updateTrainingUnit(assignDraft.unit.id, payload) setAssignModalOpen(false) setAssignDraft({ unit: null, ...sessionAssignDefaults(), }) await loadUnits() } catch (err) { toast.error(err.message || 'Zuweisung konnte nicht gespeichert werden') } finally { setAssignSaving(false) } } const handleAssignLeadSelectChange = useCallback((v) => { setAssignDraft((prev) => { const exclude = [] const tr = String(v || '').trim() if (tr !== '') { const n = parseInt(tr, 10) if (Number.isFinite(n)) exclude.push(n) } else if (prev.unit?.effective_lead_trainer_profile_id != null) { const ef = Number(prev.unit.effective_lead_trainer_profile_id) if (Number.isFinite(ef)) exclude.push(ef) } const exSet = new Set(exclude) const co = exclude.length ? prev.session_assistant_profile_ids.filter((x) => !exSet.has(x)) : prev.session_assistant_profile_ids return { ...prev, lead_trainer_profile_id: v, session_assistant_profile_ids: co } }) }, []) const handleAssignAssistantsInheritChange = useCallback((checked) => { setAssignDraft((prev) => ({ ...prev, session_assistants_inherit: checked, })) }, []) const handleAssignCoTrainerToggle = useCallback((mid) => { setAssignDraft((prev) => { const was = prev.session_assistant_profile_ids.includes(mid) const nextIds = was ? prev.session_assistant_profile_ids.filter((x) => x !== mid) : [...prev.session_assistant_profile_ids, mid].sort((a, b) => a - b) return { ...prev, session_assistant_profile_ids: nextIds } }) }, []) const handleDelete = async (unit) => { if (!confirm(`Trainingseinheit vom ${unit.planned_date} wirklich löschen?`)) return try { await api.deleteTrainingUnit(unit.id) await loadUnits() } catch (err) { toast.error('Fehler beim Löschen: ' + err.message) } } const handleSubmit = async (e) => { e.preventDefault() if (!formData.group_id || !formData.planned_date) { toast.error('Gruppe und Datum sind Pflichtfelder') return } try { const planPart = buildPlanPayloadForSave(formData.sections) const payload = { planned_date: formData.planned_date, planned_time_start: formData.planned_time_start || null, planned_time_end: formData.planned_time_end || null, planned_focus: formData.planned_focus || null, actual_date: formData.actual_date || null, actual_time_start: formData.actual_time_start || null, actual_time_end: formData.actual_time_end || null, attendance_count: formData.attendance_count ? parseInt(formData.attendance_count, 10) : null, status: formData.status || 'planned', notes: formData.notes || null, trainer_notes: formData.trainer_notes || null, ...planPart, } if (editingUnit) { payload.debrief_completed = (formData.status || '') === 'completed' ? !!formData.debrief_completed : false } const leadStr = String(formData.lead_trainer_profile_id || '').trim() if (leadStr) { payload.lead_trainer_profile_id = parseInt(leadStr, 10) } else if (editingUnit) { payload.lead_trainer_profile_id = null } if (formData.session_assistants_inherit) { if (editingUnit) payload.assistant_trainer_profile_ids = null } else { payload.assistant_trainer_profile_ids = [...formData.session_assistant_profile_ids].sort( (a, b) => a - b ) } if (!editingUnit) { payload.group_id = parseInt(formData.group_id, 10) if (draftPlanTemplateId) { payload.plan_template_id = parseInt(draftPlanTemplateId, 10) } } if (editingUnit) { await api.updateTrainingUnit(editingUnit.id, payload) } else { await api.createTrainingUnit(payload) } setShowModal(false) await loadUnits() } catch (err) { toast.error('Fehler beim Speichern: ' + err.message) } } const updateFormField = (field, value) => { setFormData((prev) => { if (field !== 'lead_trainer_profile_id') { const patch = { ...prev, [field]: value } if (field === 'status' && value !== 'completed') { patch.debrief_completed = false } return patch } const ts = typeof value === 'string' ? value.trim() : String(value ?? '').trim() const strip = new Set() if (ts !== '') { const nid = parseInt(ts, 10) if (Number.isFinite(nid)) strip.add(nid) } else { const gidParsed = parseInt(prev.group_id || selectedGroupId || '0', 10) const gr = Number.isFinite(gidParsed) && gidParsed >= 1 ? groups.find((xg) => xg.id === gidParsed) : null if (gr?.trainer_id != null) { const ht = Number(gr.trainer_id) if (Number.isFinite(ht)) strip.add(ht) } } const assistants = prev.session_assistant_profile_ids.filter((id) => !strip.has(id)) return { ...prev, lead_trainer_profile_id: value, session_assistant_profile_ids: assistants } }) } const calendarGridDays = useMemo(() => { const r = getCalendarGridRange(calendarMonthStr) return enumerateIsoDays(r.gridStart, r.gridEnd) }, [calendarMonthStr]) const unitsByPlannedDate = useMemo(() => { const m = new Map() for (const u of units) { const raw = u.planned_date if (!raw) continue const key = String(raw).slice(0, 10) if (!m.has(key)) m.set(key, []) m.get(key).push(u) } return m }, [units]) const calendarMonthTitle = useMemo(() => { const p = calendarMonthStr.split('-').map(Number) const y = p[0] const mo = p[1] if (!y || !mo) return '' return new Date(y, mo - 1, 1).toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }) }, [calendarMonthStr]) const mayConfigureSessionAssignments = useCallback( (unit) => { if (!unit) return false const pid = Number(user?.id) if (!Number.isFinite(pid)) return false const r = (user?.role || '').toLowerCase() if (r === 'admin' || r === 'superadmin') return true const gClub = unit.group_club_id != null ? Number(unit.group_club_id) : null if (Number.isFinite(gClub) && clubAdminClubIdSet.has(gClub)) return true const gid = Number(unit.group_id) const g = groups.find((gr) => gr.id === gid) if (!g) return false const cb = unit.created_by != null ? Number(unit.created_by) : NaN if (Number.isFinite(cb) && cb === pid) return true const ht = g.trainer_id != null ? Number(g.trainer_id) : NaN if (Number.isFinite(ht) && ht === pid) return true return normalizeGroupCoTrainerIds(g.co_trainer_ids).includes(pid) }, [user?.id, user?.role, groups, clubAdminClubIdSet] ) if (loading) { return (

Laden...

) } const selectedGroup = groups.find((g) => g.id === parseInt(selectedGroupId, 10)) const gidTrainerForm = parseInt(formData.group_id || selectedGroupId || '0', 10) const groupForTrainerForm = Number.isFinite(gidTrainerForm) && gidTrainerForm >= 1 ? groups.find((gr) => gr.id === gidTrainerForm) : null let formTrainerAssignLeadExcludeId = null if (groupForTrainerForm?.trainer_id != null) formTrainerAssignLeadExcludeId = Number(groupForTrainerForm.trainer_id) const leadDraftTrim = String(formData.lead_trainer_profile_id || '').trim() if (leadDraftTrim !== '') { const nl = parseInt(leadDraftTrim, 10) if (Number.isFinite(nl)) formTrainerAssignLeadExcludeId = nl } if (editingUnit?.effective_lead_trainer_profile_id != null && leadDraftTrim === '') { const el = Number(editingUnit.effective_lead_trainer_profile_id) if (Number.isFinite(el)) formTrainerAssignLeadExcludeId = el } const clubDirectoryForCo = filterDirectoryExcludingLead(clubDirectory, formTrainerAssignLeadExcludeId) let assignExcludeLeadPid = null if (assignModalOpen && assignDraft.unit) { const dl = String(assignDraft.lead_trainer_profile_id || '').trim() if (dl !== '') { const n = parseInt(dl, 10) assignExcludeLeadPid = Number.isFinite(n) ? n : null } else if (assignDraft.unit.effective_lead_trainer_profile_id != null) { const n = Number(assignDraft.unit.effective_lead_trainer_profile_id) assignExcludeLeadPid = Number.isFinite(n) ? n : null } } const clubDirectoryForAssignCo = filterDirectoryExcludingLead(clubDirectory, assignExcludeLeadPid) return (

Trainingsplanung

Ansicht { if (id === 'calendar') { setPlanView('calendar') setCalendarMonthStr((prev) => { const fromList = (startDate || '').slice(0, 7) if (/^\d{4}-\d{2}$/.test(fromList)) return fromList return prev || new Date().toISOString().slice(0, 7) }) } else { setPlanView('list') } }} items={[ { id: 'list', label: 'Liste' }, { id: 'calendar', label: 'Kalender' }, ]} className="page-section-nav--inline planning-ansicht-nav" /> {planView === 'list' ? 'Zeitraum unten mit Von/Bis filtern.' : 'Monat unten wechseln; Termine erscheinen im Raster.'}

Wähle eine Trainingsgruppe und lege Trainingseinheiten für den Zeitraum an (Inhalt: Abschnitte und Übungen).

Mehrere Einheiten strukturieren auf einmal:{' '} Trainingsrahmenprogramme {' '} (Ziele, Sessions, Vorlagen‑Ablauf).

Wiederverwendbare Blöcke innerhalb einer Einheit:{' '} Trainingsmodule {' '} (übernahme als Kopie beim Bearbeiten einer Einheit).

{!loading && groups.length === 0 && (

Erst Verein & Gruppe anlegen

Ohne Trainingsgruppe kann hier nichts gebucht werden. Unter Vereine legst du einen Verein an (kurzer Name genügt), optional eine Sparte, dann eine Trainingsgruppe. Wochentage, feste Zeiten oder Eigenschaften sind optional und kannst du später ergänzen.

Zu Vereinen & Trainingsgruppen
)}
{planView === 'list' ? ( <>
setStartDate(e.target.value)} />
setEndDate(e.target.value)} />
) : (
{calendarMonthTitle}
)}
Einblenden

Neue Termine gelten immer für die gewählte Gruppe. „Ganzer Verein“ zeigt zusätzlich Termine anderer Gruppen desselben Vereins.

Mehr zu Ansicht & Trainerzuordnung

„Ganzer Verein“ bezieht sich auf denselben Verein wie die gewählte Gruppe. Neu angelegte Termine beziehen sich weiterhin auf die Gruppe, die du oben gewählt hast.

{selectedGroupId ? (

Über Trainer oder Trainer zuweisen bearbeitest du Leitung und Co je Einheit (berechtigt: Vereinsorganisation, Haupt-/Co‑Trainer der Gruppe sowie Erstellung der Einheit). Das Mitgliederverzeichnis listet nur eigene Vereinsmitglieder; die Leitung erscheint nicht unter Co‑Trainer.

) : (

Wähle zuerst eine Gruppe — dann erweitert sich die Hilfe zu Trainer und Berechtigungen.

)}
{selectedGroup && (

{selectedGroup.location || 'Kein Ort angegeben'} {selectedGroup.weekday && ` · ${selectedGroup.weekday}`} {selectedGroup.time_start && ` · ${selectedGroup.time_start.slice(0, 5)} - ${selectedGroup.time_end?.slice(0, 5)}`}

)}

Neue Trainingseinheit

Datum, Zeiten und Ablauf (Abschnitte & Übungen) — optional{' '} Trainingsvorlage oder Inhalte aus einem Rahmenprogramm im Dialog.

{!selectedGroupId && (

Wähle oben eine Trainingsgruppe, um fortzufahren.

)} {groups.length === 0 && (

Es gibt noch keine aktive Trainingsgruppe — unter{' '} Vereine anlegen oder aktivieren.

)}
{!selectedGroupId ? (

Wähle oben eine Trainingsgruppe — danach kannst du unter{' '} „Trainingseinheit planen…“ einen Termin anlegen.

) : planView === 'calendar' ? (
{units.length === 0 ? (

Im sichtbaren Monatsbereich liegt noch keine Einheit. Über + in einem Tag legst du einen neuen Termin mit Datum an.

) : null}
{WEEKDAYS_DE.map((w) => (
{w}
))} {calendarGridDays.map((dayIso) => { const inMonth = dayIso.slice(0, 7) === calendarMonthStr const dayNum = parseInt(dayIso.slice(8, 10), 10) const isTodayMarker = dayIso === today const dayUnits = unitsByPlannedDate.get(dayIso) || [] return (
{dayNum}
{dayUnits.slice(0, 3).map((unit) => (
{mayConfigureSessionAssignments(unit) ? ( ) : null}
))}
{dayUnits.length > 3 ? (

+{dayUnits.length - 3} weitere

) : null}
) })}
) : units.length === 0 ? (

Keine Trainingseinheiten in diesem Zeitraum. Unten unter „Neue Trainingseinheit“ einen Termin anlegen — optional mit Vorlage im Dialog.

) : (
{units.map((unit) => { const lineage = unit.origin_framework_slot_id ? frameworkLineageText(unit) : null const uid = user?.id != null ? Number(user.id) : null const effLead = unit.effective_lead_trainer_profile_id != null ? Number(unit.effective_lead_trainer_profile_id) : null const showTakeLead = unit.status === 'planned' && uid != null && effLead != null && effLead !== uid return (

{unit.planned_date} {unit.planned_time_start && ` · ${unit.planned_time_start.slice(0, 5)} - ${unit.planned_time_end?.slice(0, 5)}`}

{planScope === 'club' && (unit.group_name || '').trim() ? (

{unit.group_name}

) : null}

Leitung: {(unit.lead_trainer_name || '').trim() || '—'}

{(() => { const coRaw = unit.effective_assistant_trainer_profile_ids const co = Array.isArray(coRaw) ? coRaw.map(Number).filter((x) => Number.isFinite(x) && x >= 1) : [] if (!co.length) return null const src = unit.assistant_trainer_profile_ids != null ? 'Session-Zuweisung' : 'über Trainingsgruppe' return (

Co-Trainer ({src}): {co.length}

) })()} {unit.planned_focus && (

Fokus: {unit.planned_focus}

)} {lineage ? (

Aus Rahmen: {unit.origin_framework_program_id ? ( {lineage.fpTitle} ) : ( {lineage.fpTitle} )} · {lineage.slotBit}

) : null}
{unit.status === 'planned' && 'Geplant'} {unit.status === 'completed' && 'Durchgeführt'} {unit.status === 'cancelled' && 'Abgesagt'} {unit.attendance_count !== null && unit.attendance_count !== undefined && ( {unit.attendance_count} Teilnehmer )}
Plan & Ablauf Im Training (Coach) {mayConfigureSessionAssignments(unit) ? ( ) : null} {showTakeLead ? ( ) : null}
{unit.notes && (

{unit.notes}

)}
) })}
)} { if (!assignSaving) setAssignModalOpen(false) }} onCancel={() => setAssignModalOpen(false)} onSave={saveTrainerAssignModal} /> { setModuleApplyOpen(false) setModuleApplyPlacementLocked(false) }} /> setFwImportSlotDates((prev) => ({ ...prev, [slotId]: value })) } fwImportStartDate={fwImportStartDate} onFwImportStartDateChange={setFwImportStartDate} fwImportIntervalDays={fwImportIntervalDays} onFwImportIntervalDaysChange={setFwImportIntervalDays} fwImportSubmitting={fwImportSubmitting} onApplyDateSuggestions={applyFwImportDateSuggestions} onSubmit={submitFrameworkImport} onClose={() => setFrameworkImportOpen(false)} /> setShowModal(false)} draftPlanTemplateId={draftPlanTemplateId} onDraftTemplateSelect={applyTemplateFromSelect} planTemplates={planTemplates} onDeletePlanTemplate={handleDeletePlanTemplate} clubDirectory={clubDirectory} clubDirectoryForCo={clubDirectoryForCo} planningModalClubId={planningModalClubId} user={user} onMetaRefresh={refreshPlanningSectionMeta} sectionsEditMode={sectionsEditMode} setSectionsEditMode={setSectionsEditMode} onSaveAsTemplate={handleSaveAsTemplate} onRequestTrainingModulePick={(ctx) => { void openModuleApplyModal(ctx) }} onRequestExercisePick={({ sectionIndex, itemIndex, insertBeforeIndex }) => { setExercisePickerTarget({ sIdx: sectionIndex, iIdx: typeof itemIndex === 'number' ? itemIndex : undefined, insertBeforeIndex: typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex) ? insertBeforeIndex : undefined, }) setExercisePickerOpen(true) }} onPeekExercise={(id, variantId, peekExtras) => setPlanningPeekCtx({ exerciseId: id, variantId: variantId ?? null, peekExtras: peekExtras ?? null, }) } /> { setExercisePickerOpen(false) setExercisePickerTarget(null) }} onSelectExercises={async (picked) => { if (!exercisePickerTarget || !picked?.length) return const rows = [] for (const ex of picked) { const row = await hydrateExercisePlanningRow(ex) if (row) rows.push(row) } if (!rows.length) return const { sIdx, iIdx, insertBeforeIndex } = exercisePickerTarget setFormData((prev) => ({ ...prev, sections: prev.sections.map((s, si) => { if (si !== sIdx) return s const items = [...(s.items || [])] if (typeof iIdx === 'number') { const cur = items[iIdx] if (!cur || cur.item_type !== 'exercise') return s const [first, ...tail] = rows items[iIdx] = { ...cur, exercise_id: first.exercise_id, exercise_variant_id: first.exercise_variant_id, exercise_title: first.exercise_title, variants: first.variants, } if (tail.length) items.splice(iIdx + 1, 0, ...tail) return { ...s, items } } const rawAt = typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex) ? insertBeforeIndex : items.length const at = Math.max(0, Math.min(rawAt, items.length)) items.splice(at, 0, ...rows) return { ...s, items } }), })) setExercisePickerOpen(false) setExercisePickerTarget(null) }} /> setPlanningPeekCtx(null)} />
) } export default TrainingPlanningPageRoot