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 ExercisePickerModal from '../components/ExercisePickerModal' import ExercisePeekModal from '../components/ExercisePeekModal' import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor' import TrainingPlanExerciseVisibilityPanel from '../components/TrainingPlanExerciseVisibilityPanel' import PageSectionNav from '../components/PageSectionNav' import { defaultSection, normalizeUnitToForm, enrichSectionsWithVariants, buildSectionsPayload, hydrateExercisePlanningRow, } from '../utils/trainingUnitSectionsForm' function addDaysIsoDate(isoDay, daysDelta) { const d = new Date(`${isoDay}T12:00:00`) d.setDate(d.getDate() + daysDelta) return d.toISOString().slice(0, 10) } function pad2(n) { return String(n).padStart(2, '0') } function toIsoLocal(d) { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` } /** Montag = erster Wochentag (ISO-Woche UI) */ function mondayIndex(d) { return (d.getDay() + 6) % 7 } /** Kalendarische Monatsansicht: erster und letzter Tag des sichtbaren Rasters (Mo–So) */ function getCalendarGridRange(ym) { const parts = (ym || '').split('-').map(Number) const y = parts[0] const m = parts[1] if (!y || !m || m < 1 || m > 12) { const t = new Date() return { gridStart: toIsoLocal(t), gridEnd: toIsoLocal(t) } } const first = new Date(y, m - 1, 1) const last = new Date(y, m, 0) const gridStart = new Date(first) gridStart.setDate(first.getDate() - mondayIndex(first)) const lastMon = mondayIndex(last) const gridEnd = new Date(last) gridEnd.setDate(last.getDate() + (6 - lastMon)) return { gridStart: toIsoLocal(gridStart), gridEnd: toIsoLocal(gridEnd) } } function shiftCalendarMonth(ym, delta) { const parts = (ym || '').split('-').map(Number) const y = parts[0] || new Date().getFullYear() const m = parts[1] || 1 const d = new Date(y, m - 1 + delta, 1) return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}` } function enumerateIsoDays(fromIso, toIso) { const out = [] const cur = new Date(`${fromIso}T12:00:00`) const end = new Date(`${toIso}T12:00:00`) while (cur <= end) { out.push(toIsoLocal(cur)) cur.setDate(cur.getDate() + 1) } return out } const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] function toNumList(arr) { if (!Array.isArray(arr)) return [] const out = [] for (const x of arr) { const n = Number(x) if (Number.isFinite(n) && n >= 1) out.push(n) } return out } const sessionAssignDefaults = () => ({ lead_trainer_profile_id: '', session_assistants_inherit: true, session_assistant_profile_ids: [], }) /** Co_trainer_ids aus TrainingGroups (Liste/JSON) → Zahlenliste */ function normalizeGroupCoTrainerIds(raw) { if (raw == null) return [] const arr = Array.isArray(raw) ? raw : [] const out = [] for (const x of arr) { const n = Number(x) if (Number.isFinite(n) && n >= 1) out.push(n) } return out } /** Mitgliederverzeichnis-Einträge ohne effektiven Leitungsträger als Co‑Option */ function filterDirectoryExcludingLead(directory, excludeLeadPid) { const ex = excludeLeadPid != null && excludeLeadPid !== '' && Number.isFinite(Number(excludeLeadPid)) ? Number(excludeLeadPid) : null if (ex == null) return directory return directory.filter((m) => Number(m.id) !== ex) } function TrainingPlanningPage() { const { user } = useAuth() 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 [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 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 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 = async () => { try { const groupsData = await api.listTrainingGroups({ status: 'active' }) setGroups(groupsData) await loadPlanTemplates() if (groupsData.length > 0) { const ownGroup = groupsData.find((g) => g.trainer_id === user?.id) if (ownGroup) { setSelectedGroupId(ownGroup.id) } else if (groupsData.length === 1) { setSelectedGroupId(groupsData[0].id) } } } catch (err) { console.error('Failed to load data:', err) alert('Fehler beim Laden: ' + err.message) } finally { setLoading(false) } } 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() }, []) 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 = (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 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) { alert(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) { alert('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) { alert('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) { alert('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) { alert(e.message || 'Übernahme fehlgeschlagen') } finally { setFwImportSubmitting(false) } } const frameworkLineageText = (unit) => { const fpTitle = (unit.origin_framework_program_title || '').trim() || 'Rahmenprogramm' const st = (unit.origin_framework_slot_title || '').trim() const idx = unit.origin_framework_slot_sort_order const slotBit = st || (typeof idx === 'number' ? `Session ${idx + 1}` : 'Session') return { fpTitle, slotBit, fpId: unit.origin_framework_program_id } } const handleCreate = () => { if (!selectedGroupId) { alert('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) { alert('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 ? tpl.sections.map((s) => ({ title: s.title, guidance_notes: s.guidance_text || '', items: [] })) : [defaultSection()] })) } catch (err) { alert('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) { alert('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: formData.sections.map((s) => ({ title: s.title || 'Abschnitt', guidance_text: s.guidance_notes?.trim() ? s.guidance_notes.trim() : null })) }) await loadPlanTemplates() alert('Vorlage gespeichert.') } catch (err) { alert('Speichern: ' + err.message) } } const handleTakeLead = async (unit) => { if (!user?.id) return try { await api.updateTrainingUnit(unit.id, { lead_trainer_profile_id: user.id }) await loadUnits() } catch (err) { alert(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) { alert(err.message || 'Zuweisung konnte nicht gespeichert werden') } finally { setAssignSaving(false) } } 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) { alert('Fehler beim Löschen: ' + err.message) } } const handleSubmit = async (e) => { e.preventDefault() if (!formData.group_id || !formData.planned_date) { alert('Gruppe und Datum sind Pflichtfelder') return } try { const sectionsPayload = buildSectionsPayload(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, sections: sectionsPayload } 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) { alert('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, Slots, Übungen als Vorlage).

{!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}

)}
) })}
)} {assignModalOpen && assignDraft.unit ? (
{ if (!assignSaving) setAssignModalOpen(false) }} >
e.stopPropagation()} style={{ background: 'var(--surface)', borderRadius: '12px', padding: 'clamp(14px, 3vw, 1.75rem)', maxWidth: 'min(460px, 100%)', width: '100%', maxHeight: '90vh', overflowY: 'auto', boxSizing: 'border-box', }} >

Trainer zuweisen (organisatorisch)

{(assignDraft.unit.planned_date || '').toString().slice(0, 10)} {assignDraft.unit.planned_time_start ? ` · ${String(assignDraft.unit.planned_time_start).slice(0, 5)}` : ''} {(assignDraft.unit.group_name || '').trim() ? ` · ${(assignDraft.unit.group_name || '').trim()}` : null}

{!assignDraft.session_assistants_inherit ? (
{clubDirectoryForAssignCo.map((m) => { const mid = typeof m.id === 'number' ? m.id : parseInt(String(m.id), 10) const labelText = `${(m.name || '').trim() || m.email || `Profil ${mid}`}` const isOn = Number.isFinite(mid) && assignDraft.session_assistant_profile_ids.includes(mid) return ( ) })}
) : null} {!clubDirectory.length ? (

Mitgliederverzeichnis konnte nicht geladen werden.

) : null}
) : null} {frameworkImportOpen && (

Sessions aus Rahmen übernehmen

Wähle ein Trainingsrahmenprogramm und eine oder mehrere Sessions. Pro Session entsteht eine{' '} eigene geplante Einheit in der aktuellen Gruppe (Kopie des Ablaufs). Die{' '} Verknüpfung zum Rahmen-Slot wird gespeichert, damit die Herkunft sichtbar bleibt.

{fwImportLoading ? (

Laden der Sessions…

) : fwImportDetail?.slots?.length ? ( <>
Sessions (mit Ablauf)
    {[...fwImportDetail.slots] .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) .map((slot) => { const hasBp = !!slot.blueprint_training_unit_id const checked = fwImportSelectedSlots.has(slot.id) const label = (slot.title || '').trim() || `Session ${(slot.sort_order ?? 0) + 1}` return (
  • ) })}
setFwImportStartDate(e.target.value)} disabled={fwImportSubmitting} />
setFwImportIntervalDays(parseInt(e.target.value, 10) || 0)} disabled={fwImportSubmitting} />
) : fwImportProgramId ? (

Keine Sessions in diesem Programm.

) : null}
)} {showModal && (

{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}

{editingUnit?.origin_framework_slot_id ? (() => { const L = frameworkLineageText(editingUnit) return (
Herkunft:{' '} {editingUnit.origin_framework_program_id ? ( {L.fpTitle} ) : ( L.fpTitle )} · {L.slotBit}

Inhalt stammt aus dem Session-Blueprint des Rahmenprogramms. Änderungen gelten nur für diese geplante Einheit; die Zuordnung zum Rahmen bleibt zur Nachverfolgung erhalten.

) })() : null} {!editingUnit && (

Übernimmt nur die Sektionsstruktur aus der Bibliothek; Übungen trägst du unten bei den Abschnitten ein. Gespeicherte Vorlagen kannst du unter Planung später erweitern.

)}

Planung

updateFormField('planned_date', e.target.value)} required />
updateFormField('planned_time_start', e.target.value)} />
updateFormField('planned_time_end', e.target.value)} />
updateFormField('planned_focus', e.target.value)} placeholder="z.B. Grundlagen, Kinder altersgerecht" />

Trainerzuordnung (diese Einheit)

Für Vertretungen genügt in der Regel die Vereinsmitgliedschaft; Zuweisen dürfen u. a. Haupt-/Co‑Trainer dieser Gruppe, der/die Ersteller:in der Einheit oder Vereinsadmins.

{!formData.session_assistants_inherit ? (
{clubDirectoryForCo.map((m) => { const mid = typeof m.id === 'number' ? m.id : parseInt(String(m.id), 10) const labelText = `${(m.name || '').trim() || m.email || `Profil ${mid}`}` const isOn = Number.isFinite(mid) && formData.session_assistant_profile_ids.includes(mid) return ( ) })}
) : null} {!clubDirectory.length && showModal ? (

Keine Einträge im Vereins-Mitgliederverzeichnis oder noch nicht geladen (nur für Vereinsinterne).

) : null}
{editingUnit ? (
Ablauf bearbeiten als
{[ { id: 'planning', label: 'Planung' }, { id: 'debrief', label: 'Nachbereitung' }, ].map((opt, i) => ( ))}

{sectionsEditMode === 'debrief' ? 'Ist‑Minuten rechts in derselben Spaltenbreite wie „Min“ (Plan); Abweichungen als Text über die volle Breite.' : 'Ablauf, Übungen und geplante Minuten. Ist‑Werte und Abweichungen unter „Nachbereitung“.'}

) : null} Vorlage aus Aufbau speichern } sections={formData.sections} wideExerciseGrid onSectionsChange={(updater) => setFormData((prev) => ({ ...prev, sections: updater(prev.sections), })) } onRequestExercisePick={({ sectionIndex, itemIndex }) => { setExercisePickerTarget({ sIdx: sectionIndex, iIdx: typeof itemIndex === 'number' ? itemIndex : undefined, }) setExercisePickerOpen(true) }} onPeekExercise={(id, variantId) => setPlanningPeekCtx({ exerciseId: id, variantId: variantId ?? null }) } showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'} />
{editingUnit && ( <>

Durchführung

updateFormField('actual_date', e.target.value)} />
updateFormField('actual_time_start', e.target.value)} />
updateFormField('actual_time_end', e.target.value)} />
updateFormField('attendance_count', e.target.value)} />
{formData.status === 'completed' ? (
) : null} )}

Notizen