feat: enhance TrainingPlanningPage with new utility functions and state management improvements
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 23s

- Added utility functions to normalize co-trainer IDs and filter directory entries, improving data handling for training groups.
- Updated state management to remove reliance on the user profile for club admin checks, enhancing performance and clarity.
- Improved session assignment logic to ensure effective lead trainers are excluded from assistant trainer lists.
- Enhanced form field updates to manage session assistant IDs more effectively, streamlining the assignment process.
This commit is contained in:
Lars 2026-05-06 07:42:39 +02:00
parent 9dbd3cbd5f
commit 56ea36ea25

View File

@ -87,6 +87,28 @@ const sessionAssignDefaults = () => ({
session_assistants_inherit: true,
session_assistant_profile_ids: [],
})
/** Co_trainer_ids aus TrainingGroups (Liste/JSON) → Zahlenliste */
function normalizeGroupCoTrainerIds(raw) {
if (raw == null) return []
const arr = Array.isArray(raw) ? raw : []
const out = []
for (const x of arr) {
const n = Number(x)
if (Number.isFinite(n) && n >= 1) out.push(n)
}
return out
}
/** Mitgliederverzeichnis-Einträge ohne effektiven Leitungsträger als CoOption */
function filterDirectoryExcludingLead(directory, excludeLeadPid) {
const ex =
excludeLeadPid != null && excludeLeadPid !== '' && Number.isFinite(Number(excludeLeadPid))
? Number(excludeLeadPid)
: null
if (ex == null) return directory
return directory.filter((m) => Number(m.id) !== ex)
}
function TrainingPlanningPage() {
const { user } = useAuth()
const [groups, setGroups] = useState([])
@ -123,7 +145,6 @@ function TrainingPlanningPage() {
const [planScope, setPlanScope] = useState('group')
const [assignedToMeOnly, setAssignedToMeOnly] = useState(false)
const [clubDirectory, setClubDirectory] = useState([])
const [meProfile, setMeProfile] = useState(null)
const [assignModalOpen, setAssignModalOpen] = useState(false)
const [assignDraft, setAssignDraft] = useState({
unit: null,
@ -241,39 +262,41 @@ function TrainingPlanningPage() {
const r = (user?.role || '').toLowerCase()
if (r === 'admin' || r === 'superadmin') return true
if (selectedGroupClubIdMemo == null || !Number.isFinite(selectedGroupClubIdMemo)) return false
const row = (meProfile?.clubs || []).find((c) => Number(c.id) === selectedGroupClubIdMemo)
const row = (user?.clubs || []).find((c) => Number(c.id) === selectedGroupClubIdMemo)
return Array.isArray(row?.roles) && row.roles.includes('club_admin')
}, [user?.role, selectedGroupClubIdMemo, meProfile])
}, [user?.role, user?.clubs, selectedGroupClubIdMemo])
useEffect(() => {
if (!user?.id) {
setMeProfile(null)
return undefined
const clubAdminClubIdSet = useMemo(() => {
const ids = []
for (const c of user?.clubs || []) {
if (Array.isArray(c.roles) && c.roles.includes('club_admin')) {
const id = Number(c.id)
if (Number.isFinite(id)) ids.push(id)
}
}
let cancelled = false
api
.getCurrentProfile()
.then((p) => {
if (!cancelled) setMeProfile(p)
})
.catch(() => {
if (!cancelled) setMeProfile(null)
})
return () => {
cancelled = true
}
}, [user?.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
: planScope === 'club' && canClubOrgTraining && selectedGroupClubIdMemo != null
? selectedGroupClubIdMemo
: null
: assignModalOpen && assignModalClubId != null && Number.isFinite(assignModalClubId)
? assignModalClubId
: canClubOrgTraining && selectedGroupClubIdMemo != null && Number.isFinite(selectedGroupClubIdMemo)
? selectedGroupClubIdMemo
: null
if (loadClubId == null || !Number.isFinite(loadClubId)) {
setClubDirectory([])
@ -296,10 +319,11 @@ function TrainingPlanningPage() {
}
}, [
showModal,
assignModalOpen,
assignDraft.unit,
formData.group_id,
selectedGroupId,
groups,
planScope,
canClubOrgTraining,
selectedGroupClubIdMemo,
])
@ -559,7 +583,15 @@ function TrainingPlanningPage() {
session_assistants_inherit:
fullUnit.assistant_trainer_profile_ids == null ||
fullUnit.assistant_trainer_profile_ids === undefined,
session_assistant_profile_ids: toNumList(fullUnit.assistant_trainer_profile_ids),
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
})(),
})
setShowModal(true)
} catch (err) {
@ -596,6 +628,14 @@ function TrainingPlanningPage() {
}
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:
@ -604,7 +644,7 @@ function TrainingPlanningPage() {
: '',
session_assistants_inherit:
unit.assistant_trainer_profile_ids == null || unit.assistant_trainer_profile_ids === undefined,
session_assistant_profile_ids: toNumList(unit.assistant_trainer_profile_ids),
session_assistant_profile_ids: coIds,
})
setAssignModalOpen(true)
}
@ -701,7 +741,27 @@ function TrainingPlanningPage() {
}
const updateFormField = (field, value) => {
setFormData((prev) => ({ ...prev, [field]: value }))
setFormData((prev) => {
if (field !== 'lead_trainer_profile_id') return { ...prev, [field]: value }
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(() => {
@ -729,6 +789,32 @@ function TrainingPlanningPage() {
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' }}>
@ -739,7 +825,39 @@ function TrainingPlanningPage() {
}
const selectedGroup = groups.find((g) => g.id === parseInt(selectedGroupId, 10))
const showOrgTrainerAssignControls = planScope === 'club' && canClubOrgTraining
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">
@ -1038,11 +1156,12 @@ function TrainingPlanningPage() {
Nur meine Zuordnung (Leitung / Co)
</label>
<span style={{ fontSize: '0.78rem', color: 'var(--text3)', lineHeight: 1.4, flex: '1 1 240px' }}>
Ganzer Verein nutzt den Verein der gewählten Gruppe; neue Termine unten gelten weiter für die gewählte Gruppe.
{planScope === 'club' && canClubOrgTraining ? (
Ganzer Verein bezieht sich auf denselben Verein wie die gewählte Gruppe: Dort siehst du Termine mehrerer Gruppen; neu angelegte Termine gelten weiter für die gesondert gewählte Gruppe.
{selectedGroupId ? (
<span style={{ display: 'block', marginTop: '6px', color: 'var(--text2)' }}>
Vereinsorganisation: Über <strong>Trainer zuweisen</strong> kannst du pro Termin die Leitung und Co-Trainer
anpassen (auch in der Kalenderansicht).
Über <strong>Trainer</strong> oder <strong>Trainer zuweisen</strong>: Leitung und Co je Einheit bearbeitbar (berechtigt: Vereinsorganisation, Haupt-/CoTrainer der Gruppe sowie Erstellung der Einheit).
Das Mitgliederverzeichnis listet nur <strong>eigene Vereinsmitglieder</strong>; die Leitung erscheint nicht unter CoTrainer.
Gasttrainer aus anderen Vereinen (Zugriff nur auf eine Session, nicht auf den Verein insgesamt) sind für später vorgesehen.
</span>
) : null}
</span>
@ -1307,7 +1426,7 @@ function TrainingPlanningPage() {
</span>
) : null}
</button>
{showOrgTrainerAssignControls ? (
{mayConfigureSessionAssignments(unit) ? (
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
@ -1504,7 +1623,7 @@ function TrainingPlanningPage() {
<button className="btn btn-secondary" onClick={() => handleEdit(unit)}>
Bearbeiten
</button>
{showOrgTrainerAssignControls ? (
{mayConfigureSessionAssignments(unit) ? (
<button
type="button"
className="btn btn-secondary"
@ -1594,9 +1713,25 @@ function TrainingPlanningPage() {
<select
className="form-input"
value={assignDraft.lead_trainer_profile_id}
onChange={(e) =>
setAssignDraft((prev) => ({ ...prev, lead_trainer_profile_id: e.target.value }))
}
onChange={(e) => {
const v = e.target.value
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 }
})
}}
disabled={assignSaving}
>
<option value="">Standard (Haupttrainer der Gruppe)</option>
@ -1630,7 +1765,7 @@ function TrainingPlanningPage() {
</div>
{!assignDraft.session_assistants_inherit ? (
<div style={{ marginTop: '10px', maxHeight: '180px', overflowY: 'auto' }}>
{clubDirectory.map((m) => {
{clubDirectoryForAssignCo.map((m) => {
const mid = typeof m.id === 'number' ? m.id : parseInt(String(m.id), 10)
const labelText = `${(m.name || '').trim() || m.email || `Profil ${mid}`}`
const isOn = Number.isFinite(mid) && assignDraft.session_assistant_profile_ids.includes(mid)
@ -2075,7 +2210,7 @@ function TrainingPlanningPage() {
</div>
{!formData.session_assistants_inherit ? (
<div style={{ marginTop: '10px', maxHeight: '200px', overflowY: 'auto' }}>
{clubDirectory.map((m) => {
{clubDirectoryForCo.map((m) => {
const mid = typeof m.id === 'number' ? m.id : parseInt(String(m.id), 10)
const labelText = `${(m.name || '').trim() || m.email || `Profil ${mid}`}`
const isOn = Number.isFinite(mid) && formData.session_assistant_profile_ids.includes(mid)