shinkan-jinkendo/frontend/src/components/planning/TrainingPlanningPageRoot.jsx
Lars e441f59bff
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m22s
Add delete functionality for training plan templates
2026-05-16 07:45:53 +02:00

2043 lines
77 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<div className="app-page" style={{ padding: '2rem 0', textAlign: 'center' }}>
<div className="spinner"></div>
<p>Laden...</p>
</div>
)
}
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 (
<div className="app-page">
<h1 style={{ marginBottom: '0.35rem' }}>Trainingsplanung</h1>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '12px',
marginBottom: '1rem',
}}
>
<span
className="form-label"
style={{
marginBottom: 0,
fontSize: '0.9rem',
color: 'var(--text2)',
}}
>
Ansicht
</span>
<PageSectionNav
semantics="toggle"
ariaLabel="Darstellung Liste oder Kalender"
value={planView}
onChange={(id) => {
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"
/>
<span style={{ fontSize: '0.8rem', color: 'var(--text3)', lineHeight: 1.35, maxWidth: '22rem' }}>
{planView === 'list'
? 'Zeitraum unten mit Von/Bis filtern.'
: 'Monat unten wechseln; Termine erscheinen im Raster.'}
</span>
</div>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', marginBottom: '1.25rem' }}>
Wähle eine Trainingsgruppe und lege <strong>Trainingseinheiten</strong> für den Zeitraum an (Inhalt: Abschnitte
und Übungen).
</p>
<div className="card" style={{ marginBottom: '1.25rem', padding: '12px 14px' }}>
<p style={{ margin: '0 0 0.5rem', fontSize: '0.92rem', color: 'var(--text2)' }}>
Mehrere Einheiten strukturieren auf einmal:{' '}
<Link to="/planning/framework-programs" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsrahmenprogramme
</Link>{' '}
(Ziele, Sessions, VorlagenAblauf).
</p>
<p style={{ margin: 0, fontSize: '0.92rem', color: 'var(--text2)' }}>
Wiederverwendbare Blöcke innerhalb einer Einheit:{' '}
<Link to="/planning/training-modules" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsmodule
</Link>{' '}
(übernahme als Kopie beim Bearbeiten einer Einheit).
</p>
</div>
{!loading && groups.length === 0 && (
<div
className="card"
style={{
marginBottom: '1.25rem',
borderLeft: '4px solid var(--accent)',
padding: '1rem 1.25rem'
}}
>
<h2 style={{ fontSize: '1.1rem', marginBottom: '0.5rem' }}>Erst Verein & Gruppe anlegen</h2>
<p style={{ color: 'var(--text2)', marginBottom: '0.85rem', lineHeight: 1.5 }}>
Ohne Trainingsgruppe kann hier nichts gebucht werden. Unter <strong>Vereine</strong> legst du einen Verein an
(kurzer Name genügt), optional eine Sparte, dann eine <strong>Trainingsgruppe</strong>. Wochentage, feste Zeiten oder
Eigenschaften sind optional und kannst du später ergänzen.
</p>
<Link to="/clubs" className="btn btn-primary" style={{ textDecoration: 'none' }}>
Zu Vereinen & Trainingsgruppen
</Link>
</div>
)}
<div className="card" style={{ marginBottom: '1.5rem' }}>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '1rem'
}}
>
<div>
<label className="form-label">Trainingsgruppe</label>
<select
className="form-input"
value={selectedGroupId}
onChange={(e) => setSelectedGroupId(e.target.value)}
>
<option value="">Bitte wählen</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
{g.name} ({g.club_name})
</option>
))}
</select>
</div>
{planView === 'list' ? (
<>
<div>
<label className="form-label">Von</label>
<input
type="date"
className="form-input"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
<div>
<label className="form-label">Bis</label>
<input
type="date"
className="form-input"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
</>
) : (
<div
style={{
gridColumn: '1 / -1',
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '0.65rem',
}}
>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
aria-label="Voriger Monat"
onClick={() => setCalendarMonthStr((prev) => shiftCalendarMonth(prev, -1))}
>
</button>
<span
style={{
fontWeight: 600,
fontSize: '1rem',
flex: '1 1 auto',
textAlign: 'center',
minWidth: '12rem',
}}
>
{calendarMonthTitle}
</span>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
aria-label="Nächster Monat"
onClick={() => setCalendarMonthStr((prev) => shiftCalendarMonth(prev, 1))}
>
</button>
<button
type="button"
className="btn btn-secondary"
style={{ whiteSpace: 'nowrap', padding: '0.35rem 0.65rem', fontSize: '0.85rem' }}
onClick={() => setCalendarMonthStr(new Date().toISOString().slice(0, 7))}
>
Aktueller Monat
</button>
</div>
)}
<div
style={{
gridColumn: '1 / -1',
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '12px',
}}
>
<span className="form-label" style={{ marginBottom: 0, alignSelf: 'center' }}>
Einblenden
</span>
<PageSectionNav
semantics="toggle"
ariaLabel="Gruppe oder ganzer Verein"
value={planScope}
onChange={setPlanScope}
items={[
{ id: 'group', label: 'Nur diese Gruppe', disabled: !selectedGroupId },
{ id: 'club', label: 'Ganzer Verein', disabled: !selectedGroupId },
]}
className="page-section-nav--inline"
/>
<label
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
fontSize: '0.88rem',
color: 'var(--text2)',
cursor: selectedGroupId ? 'pointer' : 'default',
opacity: selectedGroupId ? 1 : 0.65,
}}
>
<input
type="checkbox"
checked={assignedToMeOnly}
disabled={!selectedGroupId}
onChange={(e) => setAssignedToMeOnly(e.target.checked)}
/>
Nur meine Zuordnung (Leitung / Co)
</label>
<p
style={{
fontSize: '0.78rem',
color: 'var(--text3)',
lineHeight: 1.45,
flex: '1 1 220px',
margin: 0,
}}
>
Neue Termine gelten immer für die gewählte Gruppe. Ganzer Verein zeigt zusätzlich Termine
anderer Gruppen desselben Vereins.
</p>
<details className="planning-filter-help">
<summary className="planning-filter-help__summary">Mehr zu Ansicht &amp; Trainerzuordnung</summary>
<div className="planning-filter-help__body">
<p style={{ margin: '0 0 0.65rem' }}>
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.
</p>
{selectedGroupId ? (
<p style={{ margin: 0 }}>
Über <strong>Trainer</strong> oder <strong>Trainer zuweisen</strong> bearbeitest du Leitung und
Co je Einheit (berechtigt: Vereinsorganisation, Haupt-/CoTrainer der Gruppe sowie Erstellung
der Einheit). Das Mitgliederverzeichnis listet nur eigene Vereinsmitglieder; die Leitung erscheint
nicht unter CoTrainer.
</p>
) : (
<p style={{ margin: 0, color: 'var(--text3)' }}>
Wähle zuerst eine Gruppe dann erweitert sich die Hilfe zu Trainer und Berechtigungen.
</p>
)}
</div>
</details>
</div>
</div>
{selectedGroup && (
<div
style={{
marginTop: '1rem',
padding: '1rem',
background: 'var(--surface2)',
borderRadius: '8px'
}}
>
<p style={{ fontSize: '0.875rem', color: 'var(--text2)' }}>
{selectedGroup.location || 'Kein Ort angegeben'}
{selectedGroup.weekday && ` · ${selectedGroup.weekday}`}
{selectedGroup.time_start &&
` · ${selectedGroup.time_start.slice(0, 5)} - ${selectedGroup.time_end?.slice(0, 5)}`}
</p>
</div>
)}
<div className="training-planning-create training-planning-create--in-card">
<div className="training-planning-create__intro">
<h3 className="training-planning-create__title">Neue Trainingseinheit</h3>
<p className="training-planning-create__lede">
Datum, Zeiten und Ablauf (Abschnitte &amp; Übungen) optional{' '}
<strong>Trainingsvorlage</strong> oder Inhalte aus einem <strong>Rahmenprogramm</strong> im Dialog.
</p>
{!selectedGroupId && (
<p className="training-planning-create__hint training-planning-create__hint--warn">
Wähle oben eine Trainingsgruppe, um fortzufahren.
</p>
)}
{groups.length === 0 && (
<p className="training-planning-create__hint training-planning-create__hint--warn">
Es gibt noch keine aktive Trainingsgruppe unter{' '}
<Link to="/clubs">Vereine</Link> anlegen oder aktivieren.
</p>
)}
</div>
<div className="training-planning-create__actions">
<button
type="button"
className="btn btn-primary training-planning-create__cta"
disabled={!selectedGroupId}
title={!selectedGroupId ? 'Zuerst eine Trainingsgruppe wählen' : undefined}
onClick={handleCreate}
>
Trainingseinheit planen
</button>
<button
type="button"
className="btn btn-secondary training-planning-create__secondary"
disabled={!selectedGroupId}
title={!selectedGroupId ? 'Zuerst eine Trainingsgruppe wählen' : undefined}
onClick={openFrameworkImportModal}
>
Aus Rahmen übernehmen
</button>
</div>
</div>
</div>
{!selectedGroupId ? (
<div className="card">
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
Wähle oben eine Trainingsgruppe danach kannst du unter{' '}
<strong>Trainingseinheit planen</strong> einen Termin anlegen.
</p>
</div>
) : planView === 'calendar' ? (
<div className="card" style={{ padding: 'clamp(8px, 2vw, 14px)', overflowX: 'auto' }}>
{units.length === 0 ? (
<p style={{ color: 'var(--text2)', fontSize: '0.88rem', margin: '0 0 12px', lineHeight: 1.45 }}>
Im sichtbaren Monatsbereich liegt noch keine Einheit. Über <strong>+</strong> in einem Tag legst du einen
neuen Termin mit Datum an.
</p>
) : null}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(7, minmax(0, 1fr))',
gap: '4px',
minWidth: 'min(960px, 100%)',
}}
role="grid"
aria-label={`Kalender ${calendarMonthTitle}`}
>
{WEEKDAYS_DE.map((w) => (
<div
key={w}
style={{
fontWeight: 600,
fontSize: '0.72rem',
textAlign: 'center',
padding: '6px 2px',
color: 'var(--text3)',
}}
role="columnheader"
>
{w}
</div>
))}
{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 (
<div
key={dayIso}
role="gridcell"
style={{
minHeight: '96px',
border: '1px solid var(--border, rgba(0, 0, 0, 0.1))',
borderRadius: '6px',
padding: '5px',
background: inMonth ? 'var(--surface)' : 'var(--surface2)',
opacity: inMonth ? 1 : 0.58,
boxShadow: isTodayMarker ? 'inset 0 0 0 2px var(--accent-dark, #256)' : undefined,
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '4px',
marginBottom: '6px',
}}
>
<span style={{ fontWeight: 700, fontSize: '0.82rem', lineHeight: 1 }}>{dayNum}</span>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
title={`Neue Trainingseinheit am ${dayIso}`}
aria-label={`Neue Trainingseinheit am ${dayIso}`}
onClick={() => handleCreateForDate(dayIso)}
disabled={!selectedGroupId}
style={{ padding: '2px 6px', flexShrink: 0 }}
>
+
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{dayUnits.slice(0, 3).map((unit) => (
<div
key={unit.id}
style={{ display: 'flex', flexDirection: 'column', gap: '4px', width: '100%' }}
>
<button
type="button"
onClick={() => handleEdit(unit)}
title={[
planScope === 'club' && unit.group_name ? unit.group_name : '',
unit.planned_time_start?.slice(0, 5) || '',
unit.lead_trainer_name?.trim(),
unit.planned_focus?.trim(),
unit.status === 'completed'
? 'Durchgeführt'
: unit.status === 'cancelled'
? 'Abgesagt'
: 'Geplant',
]
.filter(Boolean)
.join(' · ')}
style={{
border: 'none',
cursor: 'pointer',
textAlign: 'left',
padding: '4px 5px',
borderRadius: '4px',
fontSize: '0.7rem',
lineHeight: 1.25,
width: '100%',
borderLeftWidth: '3px',
borderLeftStyle: 'solid',
borderLeftColor:
unit.status === 'completed'
? '#2ea44f'
: unit.status === 'cancelled'
? 'var(--danger)'
: 'var(--accent-dark)',
background: 'var(--surface2)',
color: 'var(--text1)',
}}
>
<span style={{ fontWeight: 600 }}>
{unit.planned_time_start
? `${unit.planned_time_start.slice(0, 5)}`
: 'Ganztags'}
</span>
{planScope === 'club' && (unit.group_name || '').trim() ? (
<span style={{ display: 'block', fontWeight: 500, color: 'var(--text1)' }}>
{(unit.group_name || '').trim().length > 22
? `${(unit.group_name || '').trim().slice(0, 22)}`
: unit.group_name}
</span>
) : null}
{unit.lead_trainer_name?.trim() ? (
<span
style={{
display: 'block',
color: 'var(--text3)',
fontWeight: 400,
fontSize: '0.62rem',
}}
>
{unit.lead_trainer_name.trim().split(/\s+/).slice(-1)[0] ||
unit.lead_trainer_name.trim()}
</span>
) : null}
{unit.planned_focus?.trim() ? (
<span style={{ display: 'block', color: 'var(--text2)', fontWeight: 400 }}>
{(unit.planned_focus || '').trim().length > 24
? `${(unit.planned_focus || '').trim().slice(0, 24)}`
: unit.planned_focus}
</span>
) : null}
</button>
{mayConfigureSessionAssignments(unit) ? (
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
style={{ fontSize: '0.62rem', padding: '2px 4px', alignSelf: 'stretch' }}
onClick={(ev) => {
ev.stopPropagation()
openTrainerAssignModal(unit)
}}
>
Trainer
</button>
) : null}
</div>
))}
</div>
{dayUnits.length > 3 ? (
<p style={{ fontSize: '0.65rem', color: 'var(--text3)', margin: '4px 0 0', lineHeight: 1.3 }}>
+{dayUnits.length - 3} weitere
</p>
) : null}
</div>
)
})}
</div>
</div>
) : units.length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
Keine Trainingseinheiten in diesem Zeitraum. Unten unter <strong>Neue Trainingseinheit</strong> einen
Termin anlegen optional mit Vorlage im Dialog.
</p>
</div>
) : (
<div style={{ display: 'grid', gap: '1rem' }}>
{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 (
<div key={unit.id} className="card">
<div
style={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '12px',
marginBottom: '1rem'
}}
>
<div style={{ minWidth: 0, flex: '1 1 200px' }}>
<h3 style={{ marginBottom: '0.25rem' }}>
{unit.planned_date}
{unit.planned_time_start &&
` · ${unit.planned_time_start.slice(0, 5)} - ${unit.planned_time_end?.slice(0, 5)}`}
</h3>
{planScope === 'club' && (unit.group_name || '').trim() ? (
<p
style={{
fontSize: '0.85rem',
color: 'var(--text3)',
margin: '0 0 0.35rem',
}}
>
{unit.group_name}
</p>
) : null}
<p style={{ fontSize: '0.82rem', color: 'var(--text2)', margin: '0 0 0.5rem' }}>
Leitung: {(unit.lead_trainer_name || '').trim() || '—'}
</p>
{(() => {
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 (
<p
style={{
fontSize: '0.78rem',
color: 'var(--text3)',
margin: '0 0 0.5rem',
}}
>
Co-Trainer ({src}): {co.length}
</p>
)
})()}
{unit.planned_focus && (
<p
style={{
color: 'var(--text2)',
fontSize: '0.875rem',
marginBottom: '0.5rem'
}}
>
Fokus: {unit.planned_focus}
</p>
)}
{lineage ? (
<p
style={{
color: 'var(--text2)',
fontSize: '0.82rem',
marginBottom: '0.5rem',
lineHeight: 1.45,
}}
>
<span style={{ fontWeight: 600, color: 'var(--text3)' }}>Aus Rahmen: </span>
{unit.origin_framework_program_id ? (
<Link
to={`/planning/framework-programs/${unit.origin_framework_program_id}`}
style={{ color: 'var(--accent-dark)' }}
>
{lineage.fpTitle}
</Link>
) : (
<span>{lineage.fpTitle}</span>
)}
<span style={{ color: 'var(--text2)' }}> · {lineage.slotBit}</span>
</p>
) : null}
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<span
style={{
fontSize: '0.75rem',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
background:
unit.status === 'completed'
? '#2ea44f'
: unit.status === 'cancelled'
? 'var(--danger)'
: 'var(--surface2)',
color:
unit.status === 'completed' || unit.status === 'cancelled'
? 'white'
: 'var(--text2)'
}}
>
{unit.status === 'planned' && 'Geplant'}
{unit.status === 'completed' && 'Durchgeführt'}
{unit.status === 'cancelled' && 'Abgesagt'}
</span>
{unit.attendance_count !== null && unit.attendance_count !== undefined && (
<span
style={{
fontSize: '0.75rem',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
background: 'var(--surface2)',
color: 'var(--text2)'
}}
>
{unit.attendance_count} Teilnehmer
</span>
)}
</div>
</div>
<div
style={{
display: 'flex',
gap: '0.5rem',
flexWrap: 'wrap',
justifyContent: 'flex-end',
flex: '1 1 180px',
minWidth: 0
}}
>
<Link
to={`/planning/run/${unit.id}`}
className="btn btn-secondary"
style={{ textDecoration: 'none', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
>
Plan &amp; Ablauf
</Link>
<Link
to={`/planning/run/${unit.id}/coach`}
className="btn btn-primary"
style={{ textDecoration: 'none', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
>
Im Training (Coach)
</Link>
<button className="btn btn-secondary" onClick={() => handleEdit(unit)}>
Bearbeiten
</button>
{mayConfigureSessionAssignments(unit) ? (
<button
type="button"
className="btn btn-secondary"
onClick={() => openTrainerAssignModal(unit)}
title="Nur organisatorisch: Leitung und Co für diese Einheit"
>
Trainer zuweisen
</button>
) : null}
{showTakeLead ? (
<button type="button" className="btn btn-secondary" onClick={() => handleTakeLead(unit)}>
Ich übernehme
</button>
) : null}
<button
className="btn"
style={{ background: 'var(--danger)', color: 'white', border: 'none' }}
onClick={() => handleDelete(unit)}
>
Löschen
</button>
</div>
</div>
{unit.notes && (
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginTop: '0.5rem' }}>
{unit.notes}
</p>
)}
</div>
)
})}
</div>
)}
<TrainingPlanningTrainerAssignModal
open={assignModalOpen && !!assignDraft.unit}
unit={assignDraft.unit}
leadTrainerProfileId={assignDraft.lead_trainer_profile_id}
onLeadChange={handleAssignLeadSelectChange}
sessionAssistantsInherit={assignDraft.session_assistants_inherit}
onSessionAssistantsInheritChange={handleAssignAssistantsInheritChange}
sessionAssistantProfileIds={assignDraft.session_assistant_profile_ids}
onCoTrainerToggle={handleAssignCoTrainerToggle}
clubDirectory={clubDirectory}
coTrainerOptions={clubDirectoryForAssignCo}
saving={assignSaving}
onBackdropRequestClose={() => {
if (!assignSaving) setAssignModalOpen(false)
}}
onCancel={() => setAssignModalOpen(false)}
onSave={saveTrainerAssignModal}
/>
<TrainingPlanningModuleApplyModal
open={moduleApplyOpen}
busy={moduleApplyBusy}
err={moduleApplyErr}
placementLocked={moduleApplyPlacementLocked}
placementSummary={modulePlacementSummary}
sections={formData.sections}
sectionIx={moduleApplySectionIx}
onSectionIndexChange={onModuleApplySectionIndexChange}
insertSlot={moduleApplyInsertSlot}
onInsertSlotChange={setModuleApplyInsertSlot}
targetItems={moduleApplyTargetItems}
searchQuery={moduleApplySearchQuery}
onSearchQueryChange={setModuleApplySearchQuery}
filteredList={moduleApplyFilteredList}
fullList={moduleApplyList}
selectedModuleId={moduleApplyModuleId}
onSelectModuleId={setModuleApplyModuleId}
modulePickPreview={modulePickPreview}
onConfirm={handleApplyTrainingModuleConfirm}
onCancel={() => {
setModuleApplyOpen(false)
setModuleApplyPlacementLocked(false)
}}
/>
<TrainingPlanningFrameworkImportModal
open={frameworkImportOpen}
frameworkProgramsList={frameworkProgramsList}
fwImportProgramId={fwImportProgramId}
onProgramChange={onFwImportProgramChange}
fwImportLoading={fwImportLoading}
fwImportDetail={fwImportDetail}
fwImportSelectedSlots={fwImportSelectedSlots}
onToggleSlot={toggleFwImportSlot}
fwImportSlotDates={fwImportSlotDates}
onSlotDateChange={(slotId, value) =>
setFwImportSlotDates((prev) => ({ ...prev, [slotId]: value }))
}
fwImportStartDate={fwImportStartDate}
onFwImportStartDateChange={setFwImportStartDate}
fwImportIntervalDays={fwImportIntervalDays}
onFwImportIntervalDaysChange={setFwImportIntervalDays}
fwImportSubmitting={fwImportSubmitting}
onApplyDateSuggestions={applyFwImportDateSuggestions}
onSubmit={submitFrameworkImport}
onClose={() => setFrameworkImportOpen(false)}
/>
<TrainingPlanningUnitFormModal
open={showModal}
editingUnit={editingUnit}
formData={formData}
updateFormField={updateFormField}
setFormData={setFormData}
onSubmit={handleSubmit}
onCancel={() => 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,
})
}
/>
<ExercisePickerModal
open={exercisePickerOpen}
multiSelect
enableQuickCreateDraft
onClose={() => {
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)
}}
/>
<ExercisePeekModal
key={planningPeekCtx != null ? String(planningPeekCtx.exerciseId) : 'plan-peek-closed'}
open={planningPeekCtx != null}
exerciseId={planningPeekCtx?.exerciseId}
variantId={planningPeekCtx?.variantId ?? undefined}
peekExtras={planningPeekCtx?.peekExtras ?? undefined}
onClose={() => setPlanningPeekCtx(null)}
/>
</div>
)
}
export default TrainingPlanningPageRoot