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
2043 lines
77 KiB
JavaScript
2043 lines
77 KiB
JavaScript
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, Vorlagen‑Ablauf).
|
||
</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 & 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-/Co‑Trainer der Gruppe sowie Erstellung
|
||
der Einheit). Das Mitgliederverzeichnis listet nur eigene Vereinsmitglieder; die Leitung erscheint
|
||
nicht unter Co‑Trainer.
|
||
</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 & Ü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 & 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
|