shinkan-jinkendo/frontend/src/pages/TrainingPlanningPage.jsx
Lars 56ea36ea25
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
feat: enhance TrainingPlanningPage with new utility functions and state management improvements
- 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.
2026-05-06 07:42:39 +02:00

2437 lines
96 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import ExercisePickerModal from '../components/ExercisePickerModal'
import ExercisePeekModal from '../components/ExercisePeekModal'
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
import {
defaultSection,
normalizeUnitToForm,
enrichSectionsWithVariants,
buildSectionsPayload,
hydrateExercisePlanningRow,
} from '../utils/trainingUnitSectionsForm'
function addDaysIsoDate(isoDay, daysDelta) {
const d = new Date(`${isoDay}T12:00:00`)
d.setDate(d.getDate() + daysDelta)
return d.toISOString().slice(0, 10)
}
function pad2(n) {
return String(n).padStart(2, '0')
}
function toIsoLocal(d) {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
}
/** Montag = erster Wochentag (ISO-Woche UI) */
function mondayIndex(d) {
return (d.getDay() + 6) % 7
}
/** Kalendarische Monatsansicht: erster und letzter Tag des sichtbaren Rasters (MoSo) */
function getCalendarGridRange(ym) {
const parts = (ym || '').split('-').map(Number)
const y = parts[0]
const m = parts[1]
if (!y || !m || m < 1 || m > 12) {
const t = new Date()
return { gridStart: toIsoLocal(t), gridEnd: toIsoLocal(t) }
}
const first = new Date(y, m - 1, 1)
const last = new Date(y, m, 0)
const gridStart = new Date(first)
gridStart.setDate(first.getDate() - mondayIndex(first))
const lastMon = mondayIndex(last)
const gridEnd = new Date(last)
gridEnd.setDate(last.getDate() + (6 - lastMon))
return { gridStart: toIsoLocal(gridStart), gridEnd: toIsoLocal(gridEnd) }
}
function shiftCalendarMonth(ym, delta) {
const parts = (ym || '').split('-').map(Number)
const y = parts[0] || new Date().getFullYear()
const m = parts[1] || 1
const d = new Date(y, m - 1 + delta, 1)
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`
}
function enumerateIsoDays(fromIso, toIso) {
const out = []
const cur = new Date(`${fromIso}T12:00:00`)
const end = new Date(`${toIso}T12:00:00`)
while (cur <= end) {
out.push(toIsoLocal(cur))
cur.setDate(cur.getDate() + 1)
}
return out
}
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
function toNumList(arr) {
if (!Array.isArray(arr)) return []
const out = []
for (const x of arr) {
const n = Number(x)
if (Number.isFinite(n) && n >= 1) out.push(n)
}
return out
}
const sessionAssignDefaults = () => ({
lead_trainer_profile_id: '',
session_assistants_inherit: true,
session_assistant_profile_ids: [],
})
/** Co_trainer_ids aus TrainingGroups (Liste/JSON) → Zahlenliste */
function normalizeGroupCoTrainerIds(raw) {
if (raw == null) return []
const arr = Array.isArray(raw) ? raw : []
const out = []
for (const x of arr) {
const n = Number(x)
if (Number.isFinite(n) && n >= 1) out.push(n)
}
return out
}
/** Mitgliederverzeichnis-Einträge ohne effektiven Leitungsträger als 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([])
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)
const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('')
const [quickTemplateId, setQuickTemplateId] = useState('')
const [exercisePickerOpen, setExercisePickerOpen] = useState(false)
const [exercisePickerTarget, setExercisePickerTarget] = useState(null)
const [planningPeekCtx, setPlanningPeekCtx] = useState(null)
const today = new Date().toISOString().split('T')[0]
const thirtyDaysLater = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
const [frameworkImportOpen, setFrameworkImportOpen] = useState(false)
const [frameworkProgramsList, setFrameworkProgramsList] = useState([])
const [fwImportProgramId, setFwImportProgramId] = useState('')
const [fwImportDetail, setFwImportDetail] = useState(null)
const [fwImportLoading, setFwImportLoading] = useState(false)
const [fwImportSelectedSlots, setFwImportSelectedSlots] = useState(() => new Set())
const [fwImportSlotDates, setFwImportSlotDates] = useState({})
const [fwImportStartDate, setFwImportStartDate] = useState(today)
const [fwImportIntervalDays, setFwImportIntervalDays] = useState(7)
const [fwImportSubmitting, setFwImportSubmitting] = useState(false)
const [startDate, setStartDate] = useState(today)
const [endDate, setEndDate] = useState(thirtyDaysLater)
const [planView, setPlanView] = useState('list')
const [calendarMonthStr, setCalendarMonthStr] = useState(() => today.slice(0, 7))
const [planScope, setPlanScope] = useState('group')
const [assignedToMeOnly, setAssignedToMeOnly] = useState(false)
const [clubDirectory, setClubDirectory] = useState([])
const [assignModalOpen, setAssignModalOpen] = useState(false)
const [assignDraft, setAssignDraft] = useState({
unit: null,
lead_trainer_profile_id: '',
session_assistants_inherit: true,
session_assistant_profile_ids: [],
})
const [assignSaving, setAssignSaving] = useState(false)
const [formData, setFormData] = useState({
group_id: '',
planned_date: '',
planned_time_start: '',
planned_time_end: '',
planned_focus: '',
actual_date: '',
actual_time_start: '',
actual_time_end: '',
attendance_count: '',
status: 'planned',
notes: '',
trainer_notes: '',
sections: [defaultSection()],
...sessionAssignDefaults()
})
const loadPlanTemplates = useCallback(async () => {
try {
const tpl = await api.listTrainingPlanTemplates()
setPlanTemplates(tpl)
} catch (e) {
console.error('Vorlagen laden:', e)
}
}, [])
const loadData = async () => {
try {
const groupsData = await api.listTrainingGroups({ status: 'active' })
setGroups(groupsData)
await loadPlanTemplates()
if (groupsData.length > 0) {
const ownGroup = groupsData.find((g) => g.trainer_id === user?.id)
if (ownGroup) {
setSelectedGroupId(ownGroup.id)
} else if (groupsData.length === 1) {
setSelectedGroupId(groupsData[0].id)
}
}
} catch (err) {
console.error('Failed to load data:', err)
alert('Fehler beim Laden: ' + err.message)
} finally {
setLoading(false)
}
}
const loadUnits = useCallback(async () => {
if (!selectedGroupId) return
let start = startDate
let end = endDate
if (planView === 'calendar') {
const r = getCalendarGridRange(calendarMonthStr)
start = r.gridStart
end = r.gridEnd
}
const gid = parseInt(selectedGroupId, 10)
const groupRow = groups.find((g) => g.id === gid)
const clubId = groupRow?.club_id
try {
const filters = {
start_date: start,
end_date: end
}
if (assignedToMeOnly) {
filters.assigned_to_me = true
}
if (planScope === 'club' && clubId) {
filters.club_id = clubId
} else {
filters.group_id = gid
}
const unitsData = await api.listTrainingUnits(filters)
setUnits(unitsData)
} catch (err) {
console.error('Failed to load units:', err)
}
}, [
selectedGroupId,
groups,
startDate,
endDate,
planView,
calendarMonthStr,
planScope,
assignedToMeOnly
])
useEffect(() => {
loadData()
}, [])
useEffect(() => {
if (selectedGroupId) {
loadUnits()
}
}, [selectedGroupId, loadUnits])
const selectedGroupClubIdMemo = useMemo(() => {
const g = groups.find((gr) => gr.id === parseInt(selectedGroupId, 10))
return g?.club_id != null ? Number(g.club_id) : null
}, [groups, selectedGroupId])
const canClubOrgTraining = useMemo(() => {
const r = (user?.role || '').toLowerCase()
if (r === 'admin' || r === 'superadmin') return true
if (selectedGroupClubIdMemo == null || !Number.isFinite(selectedGroupClubIdMemo)) return false
const row = (user?.clubs || []).find((c) => Number(c.id) === selectedGroupClubIdMemo)
return Array.isArray(row?.roles) && row.roles.includes('club_admin')
}, [user?.role, user?.clubs, selectedGroupClubIdMemo])
const clubAdminClubIdSet = useMemo(() => {
const ids = []
for (const c of user?.clubs || []) {
if (Array.isArray(c.roles) && c.roles.includes('club_admin')) {
const id = Number(c.id)
if (Number.isFinite(id)) ids.push(id)
}
}
return new Set(ids)
}, [user?.clubs])
useEffect(() => {
const gid = parseInt(formData.group_id || selectedGroupId || '0', 10)
const gModal = Number.isFinite(gid) && gid >= 1 ? groups.find((x) => x.id === gid) : null
const clubForModal = gModal?.club_id != null ? Number(gModal.club_id) : null
let assignModalClubId = null
if (assignModalOpen && assignDraft.unit?.group_id != null) {
const ug = Number(assignDraft.unit.group_id)
const gAssign = Number.isFinite(ug) ? groups.find((x) => x.id === ug) : null
if (gAssign?.club_id != null) assignModalClubId = Number(gAssign.club_id)
}
const loadClubId =
showModal && clubForModal != null && Number.isFinite(clubForModal)
? clubForModal
: assignModalOpen && assignModalClubId != null && Number.isFinite(assignModalClubId)
? assignModalClubId
: canClubOrgTraining && selectedGroupClubIdMemo != null && Number.isFinite(selectedGroupClubIdMemo)
? selectedGroupClubIdMemo
: null
if (loadClubId == null || !Number.isFinite(loadClubId)) {
setClubDirectory([])
return undefined
}
let cancelled = false
;(async () => {
try {
const d = await api.clubMembersDirectory(loadClubId)
if (!cancelled) setClubDirectory(Array.isArray(d) ? d : [])
} catch (err) {
if (!cancelled) {
console.error('Mitgliederverzeichnis:', err)
setClubDirectory([])
}
}
})()
return () => {
cancelled = true
}
}, [
showModal,
assignModalOpen,
assignDraft.unit,
formData.group_id,
selectedGroupId,
groups,
canClubOrgTraining,
selectedGroupClubIdMemo,
])
useEffect(() => {
if (!frameworkImportOpen) return
let cancelled = false
;(async () => {
try {
const list = await api.listTrainingFrameworkPrograms()
if (!cancelled) setFrameworkProgramsList(Array.isArray(list) ? list : [])
} catch (e) {
if (!cancelled) {
console.error('Rahmenprogramme laden:', e)
setFrameworkProgramsList([])
}
}
})()
return () => {
cancelled = true
}
}, [frameworkImportOpen])
const openFrameworkImportModal = useCallback(() => {
setFwImportProgramId('')
setFwImportDetail(null)
setFwImportSelectedSlots(new Set())
setFwImportSlotDates({})
setFwImportStartDate(new Date().toISOString().split('T')[0])
setFwImportIntervalDays(7)
setFrameworkImportOpen(true)
}, [])
const onFwImportProgramChange = async (idStr) => {
setFwImportProgramId(idStr)
if (!idStr) {
setFwImportDetail(null)
return
}
setFwImportLoading(true)
try {
const d = await api.getTrainingFrameworkProgram(parseInt(idStr, 10))
setFwImportDetail(d)
setFwImportSelectedSlots(new Set())
setFwImportSlotDates({})
} catch (e) {
alert(e.message || 'Rahmenprogramm laden fehlgeschlagen')
setFwImportDetail(null)
} finally {
setFwImportLoading(false)
}
}
const toggleFwImportSlot = (slot) => {
if (!slot?.blueprint_training_unit_id) return
const sid = slot.id
setFwImportSelectedSlots((prev) => {
const n = new Set(prev)
if (n.has(sid)) n.delete(sid)
else n.add(sid)
return n
})
}
const applyFwImportDateSuggestions = () => {
if (!fwImportDetail?.slots?.length) return
const sorted = [...fwImportDetail.slots].sort(
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)
)
let offset = 0
const iv = Math.max(0, Number(fwImportIntervalDays) || 0)
const next = {}
for (const s of sorted) {
if (!fwImportSelectedSlots.has(s.id)) continue
if (!s.blueprint_training_unit_id) continue
next[String(s.id)] = addDaysIsoDate(fwImportStartDate, offset)
offset += iv
}
setFwImportSlotDates((prev) => ({ ...prev, ...next }))
}
const submitFrameworkImport = async () => {
if (!selectedGroupId) {
alert('Bitte zuerst eine Trainingsgruppe wählen.')
return
}
const gid = parseInt(selectedGroupId, 10)
if (!fwImportDetail?.slots?.length) return
const sorted = [...fwImportDetail.slots].sort(
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)
)
const picks = sorted.filter(
(s) => fwImportSelectedSlots.has(s.id) && s.blueprint_training_unit_id
)
if (!picks.length) {
alert('Bitte mindestens eine Session mit Ablauf (Blueprint) auswählen.')
return
}
for (const s of picks) {
const key = String(s.id)
const date = fwImportSlotDates[key] || fwImportStartDate
if (!date) {
alert('Bitte für jede ausgewählte Session ein Datum setzen (oder Datumsvorschläge nutzen).')
return
}
}
setFwImportSubmitting(true)
try {
for (const s of picks) {
const key = String(s.id)
const date = fwImportSlotDates[key] || fwImportStartDate
await api.createTrainingUnitFromFrameworkSlot({
group_id: gid,
planned_date: date,
framework_slot_id: s.id,
})
}
setFrameworkImportOpen(false)
await loadUnits()
} catch (e) {
alert(e.message || 'Übernahme fehlgeschlagen')
} finally {
setFwImportSubmitting(false)
}
}
const frameworkLineageText = (unit) => {
const fpTitle = (unit.origin_framework_program_title || '').trim() || 'Rahmenprogramm'
const st = (unit.origin_framework_slot_title || '').trim()
const idx = unit.origin_framework_slot_sort_order
const slotBit =
st || (typeof idx === 'number' ? `Session ${idx + 1}` : 'Session')
return { fpTitle, slotBit, fpId: unit.origin_framework_program_id }
}
const handleQuickCreate = async () => {
if (!selectedGroupId) {
alert('Bitte wähle zuerst eine Trainingsgruppe')
return
}
const date = prompt('Datum für neue Trainingseinheit (YYYY-MM-DD):', today)
if (!date) return
try {
const body = {
group_id: parseInt(selectedGroupId, 10),
planned_date: date
}
if (quickTemplateId) {
body.plan_template_id = parseInt(quickTemplateId, 10)
}
await api.quickCreateTrainingUnit(body)
await loadUnits()
} catch (err) {
alert('Fehler beim Erstellen: ' + err.message)
}
}
const handleCreate = () => {
if (!selectedGroupId) {
alert('Bitte wähle zuerst eine Trainingsgruppe')
return
}
const group = groups.find((g) => g.id === parseInt(selectedGroupId, 10))
setEditingUnit(null)
setDraftPlanTemplateId('')
setFormData({
group_id: selectedGroupId,
planned_date: today,
planned_time_start: group?.time_start?.slice(0, 5) || '',
planned_time_end: group?.time_end?.slice(0, 5) || '',
planned_focus: '',
actual_date: '',
actual_time_start: '',
actual_time_end: '',
attendance_count: '',
status: 'planned',
notes: '',
trainer_notes: '',
sections: [defaultSection('Hauptteil')],
...sessionAssignDefaults()
})
setShowModal(true)
}
const handleCreateForDate = (isoDay) => {
if (!selectedGroupId) {
alert('Bitte wähle zuerst eine Trainingsgruppe')
return
}
const group = groups.find((g) => g.id === parseInt(selectedGroupId, 10))
setEditingUnit(null)
setDraftPlanTemplateId('')
setFormData({
group_id: selectedGroupId,
planned_date: isoDay,
planned_time_start: group?.time_start?.slice(0, 5) || '',
planned_time_end: group?.time_end?.slice(0, 5) || '',
planned_focus: '',
actual_date: '',
actual_time_start: '',
actual_time_end: '',
attendance_count: '',
status: 'planned',
notes: '',
trainer_notes: '',
sections: [defaultSection('Hauptteil')],
...sessionAssignDefaults()
})
setShowModal(true)
}
const applyTemplateFromSelect = async (templateId) => {
setDraftPlanTemplateId(templateId)
if (!templateId) return
try {
const tpl = await api.getTrainingPlanTemplate(parseInt(templateId, 10))
setFormData((fd) => ({
...fd,
sections: (tpl.sections || []).length
? tpl.sections.map((s) => ({
title: s.title,
guidance_notes: s.guidance_text || '',
items: []
}))
: [defaultSection()]
}))
} catch (err) {
alert('Vorlage laden: ' + err.message)
}
}
const handleEdit = 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 || '',
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
})(),
})
setShowModal(true)
} catch (err) {
alert('Fehler beim Laden: ' + err.message)
}
}
const handleSaveAsTemplate = async () => {
const name = window.prompt('Name für die neue Trainingsvorlage (nur Abschnitts-Gliederung):')
if (!name?.trim()) return
try {
await api.createTrainingPlanTemplate({
name: name.trim(),
sections: formData.sections.map((s) => ({
title: s.title || 'Abschnitt',
guidance_text: s.guidance_notes?.trim() ? s.guidance_notes.trim() : null
}))
})
await loadPlanTemplates()
alert('Vorlage gespeichert.')
} catch (err) {
alert('Speichern: ' + err.message)
}
}
const handleTakeLead = async (unit) => {
if (!user?.id) return
try {
await api.updateTrainingUnit(unit.id, { lead_trainer_profile_id: user.id })
await loadUnits()
} catch (err) {
alert(err.message || 'Leitung konnte nicht übernommen werden')
}
}
const openTrainerAssignModal = (unit) => {
const effLead =
unit.effective_lead_trainer_profile_id != null
? Number(unit.effective_lead_trainer_profile_id)
: null
let coIds = toNumList(unit.assistant_trainer_profile_ids)
if (effLead != null && Number.isFinite(effLead)) {
coIds = coIds.filter((id) => id !== effLead)
}
setAssignDraft({
unit,
lead_trainer_profile_id:
unit.lead_trainer_profile_id != null && unit.lead_trainer_profile_id !== ''
? String(unit.lead_trainer_profile_id)
: '',
session_assistants_inherit:
unit.assistant_trainer_profile_ids == null || unit.assistant_trainer_profile_ids === undefined,
session_assistant_profile_ids: coIds,
})
setAssignModalOpen(true)
}
const saveTrainerAssignModal = async () => {
if (!assignDraft.unit) return
setAssignSaving(true)
try {
const payload = {}
const leadStr = String(assignDraft.lead_trainer_profile_id || '').trim()
if (leadStr) payload.lead_trainer_profile_id = parseInt(leadStr, 10)
else payload.lead_trainer_profile_id = null
if (assignDraft.session_assistants_inherit) {
payload.assistant_trainer_profile_ids = null
} else {
payload.assistant_trainer_profile_ids = [...assignDraft.session_assistant_profile_ids].sort((a, b) => a - b)
}
await api.updateTrainingUnit(assignDraft.unit.id, payload)
setAssignModalOpen(false)
setAssignDraft({
unit: null,
...sessionAssignDefaults(),
})
await loadUnits()
} catch (err) {
alert(err.message || 'Zuweisung konnte nicht gespeichert werden')
} finally {
setAssignSaving(false)
}
}
const handleDelete = async (unit) => {
if (!confirm(`Trainingseinheit vom ${unit.planned_date} wirklich löschen?`)) return
try {
await api.deleteTrainingUnit(unit.id)
await loadUnits()
} catch (err) {
alert('Fehler beim Löschen: ' + err.message)
}
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!formData.group_id || !formData.planned_date) {
alert('Gruppe und Datum sind Pflichtfelder')
return
}
try {
const sectionsPayload = buildSectionsPayload(formData.sections)
const payload = {
planned_date: formData.planned_date,
planned_time_start: formData.planned_time_start || null,
planned_time_end: formData.planned_time_end || null,
planned_focus: formData.planned_focus || null,
actual_date: formData.actual_date || null,
actual_time_start: formData.actual_time_start || null,
actual_time_end: formData.actual_time_end || null,
attendance_count: formData.attendance_count ? parseInt(formData.attendance_count, 10) : null,
status: formData.status || 'planned',
notes: formData.notes || null,
trainer_notes: formData.trainer_notes || null,
sections: sectionsPayload
}
const leadStr = String(formData.lead_trainer_profile_id || '').trim()
if (leadStr) {
payload.lead_trainer_profile_id = parseInt(leadStr, 10)
} else if (editingUnit) {
payload.lead_trainer_profile_id = null
}
if (formData.session_assistants_inherit) {
if (editingUnit) payload.assistant_trainer_profile_ids = null
} else {
payload.assistant_trainer_profile_ids = [...formData.session_assistant_profile_ids].sort(
(a, b) => a - b
)
}
if (!editingUnit) {
payload.group_id = parseInt(formData.group_id, 10)
if (draftPlanTemplateId) {
payload.plan_template_id = parseInt(draftPlanTemplateId, 10)
}
}
if (editingUnit) {
await api.updateTrainingUnit(editingUnit.id, payload)
} else {
await api.createTrainingUnit(payload)
}
setShowModal(false)
await loadUnits()
} catch (err) {
alert('Fehler beim Speichern: ' + err.message)
}
}
const updateFormField = (field, value) => {
setFormData((prev) => {
if (field !== 'lead_trainer_profile_id') 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(() => {
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>
<div
role="group"
aria-label="Darstellung Liste oder Kalender"
style={{
display: 'inline-flex',
borderRadius: '10px',
border: '1.5px solid var(--border2)',
overflow: 'hidden',
background: 'var(--surface2)',
boxSizing: 'border-box',
}}
>
<button
type="button"
aria-pressed={planView === 'list'}
onClick={() => setPlanView('list')}
style={{
border: 'none',
padding: '10px 20px',
fontWeight: 600,
fontSize: '0.92rem',
cursor: 'pointer',
background: planView === 'list' ? 'var(--accent-dark)' : 'transparent',
color: planView === 'list' ? '#fff' : 'var(--text1)',
whiteSpace: 'nowrap',
}}
>
Liste
</button>
<button
type="button"
aria-pressed={planView === 'calendar'}
onClick={() => {
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)
})
}}
style={{
border: 'none',
borderLeft: '1.5px solid var(--border2)',
padding: '10px 20px',
fontWeight: 600,
fontSize: '0.92rem',
cursor: 'pointer',
background: planView === 'calendar' ? 'var(--accent-dark)' : 'transparent',
color: planView === 'calendar' ? '#fff' : 'var(--text1)',
whiteSpace: 'nowrap',
}}
>
Kalender
</button>
</div>
<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, lege dann Termine mit Inhalt (Abschnitte und Übungen) an ein Plan entsteht aus einer oder mehreren{' '}
<strong>Trainingseinheiten</strong> im gewählten Zeitraum.
</p>
<div className="card" style={{ marginBottom: '1.25rem', padding: '12px 14px' }}>
<p style={{ margin: 0, 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, Slots, Übungen als Vorlage).
</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>
<div
role="group"
aria-label="Gruppe oder ganzer Verein"
style={{
display: 'inline-flex',
borderRadius: '10px',
border: '1.5px solid var(--border2)',
overflow: 'hidden',
background: 'var(--surface2)',
}}
>
<button
type="button"
aria-pressed={planScope === 'group'}
disabled={!selectedGroupId}
onClick={() => setPlanScope('group')}
style={{
border: 'none',
padding: '8px 14px',
fontWeight: 600,
fontSize: '0.85rem',
cursor: selectedGroupId ? 'pointer' : 'not-allowed',
opacity: selectedGroupId ? 1 : 0.55,
background: planScope === 'group' ? 'var(--accent-dark)' : 'transparent',
color: planScope === 'group' ? '#fff' : 'var(--text1)',
whiteSpace: 'nowrap',
}}
>
Nur diese Gruppe
</button>
<button
type="button"
aria-pressed={planScope === 'club'}
disabled={!selectedGroupId}
onClick={() => setPlanScope('club')}
style={{
border: 'none',
borderLeft: '1.5px solid var(--border2)',
padding: '8px 14px',
fontWeight: 600,
fontSize: '0.85rem',
cursor: selectedGroupId ? 'pointer' : 'not-allowed',
opacity: selectedGroupId ? 1 : 0.55,
background: planScope === 'club' ? 'var(--accent-dark)' : 'transparent',
color: planScope === 'club' ? '#fff' : 'var(--text1)',
whiteSpace: 'nowrap',
}}
>
Ganzer Verein
</button>
</div>
<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>
<span style={{ fontSize: '0.78rem', color: 'var(--text3)', lineHeight: 1.4, flex: '1 1 240px' }}>
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)' }}>
Ü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>
</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
style={{
marginTop: '1.25rem',
paddingTop: '1rem',
borderTop: '1px solid var(--border, rgba(0,0,0,0.08))'
}}
>
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', marginBottom: '0.75rem' }}>
<strong>Plan anlegen:</strong> neue Trainingseinheit mit Datum, Zeit und Ablauf oder schnell nur mit Datum (Zeiten aus der Gruppe).
{!selectedGroupId && (
<span style={{ display: 'block', marginTop: '0.35rem' }}>
Wähle oben eine Trainingsgruppe, um die Schaltflächen zu aktivieren.
</span>
)}
{groups.length === 0 && (
<span style={{ display: 'block', marginTop: '0.35rem' }}>
Es gibt noch keine aktive Trainingsgruppe unter{' '}
<Link to="/clubs">
Vereine
</Link>{' '}
anlegen oder aktivieren.
</span>
)}
</p>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
alignItems: 'center'
}}
>
<button
type="button"
className="btn btn-primary"
disabled={!selectedGroupId}
title={!selectedGroupId ? 'Zuerst eine Trainingsgruppe wählen' : undefined}
onClick={handleCreate}
>
+ Neue Trainingseinheit planen
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<label className="form-label" style={{ marginBottom: 0 }}>
Schnell (+ optional Vorlage):
</label>
<select
className="form-input"
style={{ minWidth: '180px', marginBottom: 0 }}
value={quickTemplateId}
onChange={(e) => setQuickTemplateId(e.target.value)}
disabled={!selectedGroupId}
title={!selectedGroupId ? 'Zuerst Trainingsgruppe wählen' : undefined}
>
<option value="">Standard (leer)</option>
{planTemplates.map((t) => (
<option key={t.id} value={String(t.id)}>
{t.name}
{typeof t.sections_count === 'number' ? ` (${t.sections_count} Abschn.)` : ''}
</option>
))}
</select>
<button
type="button"
className="btn btn-secondary"
disabled={!selectedGroupId}
title={!selectedGroupId ? 'Zuerst eine Trainingsgruppe wählen' : undefined}
onClick={handleQuickCreate}
>
Schnell erstellen
</button>
</div>
<button
type="button"
className="btn btn-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 mit <strong>Neue Trainingseinheit planen</strong> starten.
</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. Nutze oben <strong>Neue Trainingseinheit planen</strong> oder{' '}
<strong>Schnell erstellen</strong>, um den ersten Termin anzulegen.
</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>
)}
{assignModalOpen && assignDraft.unit ? (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1020,
padding: '1rem',
overflowY: 'auto',
}}
role="presentation"
onClick={() => {
if (!assignSaving) setAssignModalOpen(false)
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="trainer-assign-modal-title"
onClick={(e) => e.stopPropagation()}
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: 'clamp(14px, 3vw, 1.75rem)',
maxWidth: 'min(460px, 100%)',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxSizing: 'border-box',
}}
>
<h2 id="trainer-assign-modal-title" style={{ marginBottom: '0.5rem', fontSize: '1.1rem' }}>
Trainer zuweisen (organisatorisch)
</h2>
<p style={{ fontSize: '0.86rem', color: 'var(--text2)', marginBottom: '1rem', lineHeight: 1.45 }}>
{(assignDraft.unit.planned_date || '').toString().slice(0, 10)}
{assignDraft.unit.planned_time_start
? ` · ${String(assignDraft.unit.planned_time_start).slice(0, 5)}`
: ''}
{(assignDraft.unit.group_name || '').trim()
? ` · ${(assignDraft.unit.group_name || '').trim()}`
: null}
</p>
<div className="form-row">
<label className="form-label">Leitung (diese Einheit)</label>
<select
className="form-input"
value={assignDraft.lead_trainer_profile_id}
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>
{clubDirectory.map((m) => {
const idStr = String(m.id)
return (
<option key={idStr} value={idStr}>
{(m.name || '').trim() || m.email || `Profil ${m.id}`}
</option>
)
})}
</select>
</div>
<div className="form-row" style={{ marginTop: '0.85rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={assignDraft.session_assistants_inherit}
disabled={assignSaving}
onChange={(e) =>
setAssignDraft((prev) => ({
...prev,
session_assistants_inherit: e.target.checked,
}))
}
/>
<span style={{ fontSize: '0.9rem', color: 'var(--text1)' }}>
Co-Trainer wie in der Trainingsgruppe
</span>
</label>
</div>
{!assignDraft.session_assistants_inherit ? (
<div style={{ marginTop: '10px', maxHeight: '180px', overflowY: 'auto' }}>
{clubDirectoryForAssignCo.map((m) => {
const mid = typeof m.id === 'number' ? m.id : parseInt(String(m.id), 10)
const labelText = `${(m.name || '').trim() || m.email || `Profil ${mid}`}`
const isOn = Number.isFinite(mid) && assignDraft.session_assistant_profile_ids.includes(mid)
return (
<label
key={`assign-co-${mid}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '0.875rem',
marginBottom: '6px',
cursor: assignSaving ? 'default' : 'pointer',
color: 'var(--text1)',
}}
>
<input
type="checkbox"
checked={isOn}
disabled={assignSaving}
onChange={() => {
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 }
})
}}
/>
<span>{labelText}</span>
</label>
)
})}
</div>
) : null}
{!clubDirectory.length ? (
<p style={{ marginTop: '10px', fontSize: '0.82rem', color: 'var(--text3)' }}>
Mitgliederverzeichnis konnte nicht geladen werden.
</p>
) : null}
<div
style={{
display: 'flex',
gap: '0.65rem',
flexWrap: 'wrap',
justifyContent: 'flex-end',
marginTop: '1.25rem',
}}
>
<button
type="button"
className="btn btn-secondary"
disabled={assignSaving}
onClick={() => setAssignModalOpen(false)}
>
Abbrechen
</button>
<button type="button" className="btn btn-primary" disabled={assignSaving} onClick={saveTrainerAssignModal}>
{assignSaving ? 'Speichern …' : 'Speichern'}
</button>
</div>
</div>
</div>
) : null}
{frameworkImportOpen && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1010,
padding: '1rem',
overflowY: 'auto',
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: 'clamp(14px, 3vw, 1.75rem)',
maxWidth: 'min(620px, 100%)',
width: '100%',
maxHeight: '90vh',
overflowY: 'auto',
boxSizing: 'border-box',
minWidth: 0,
}}
>
<h2 style={{ marginBottom: '0.65rem' }}>Sessions aus Rahmen übernehmen</h2>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', marginBottom: '1rem', lineHeight: 1.5 }}>
Wähle ein Trainingsrahmenprogramm und eine oder mehrere Sessions. Pro Session entsteht eine{' '}
<strong>eigene geplante Einheit</strong> in der aktuellen Gruppe (Kopie des Ablaufs). Die{' '}
<strong>Verknüpfung zum Rahmen-Slot</strong> wird gespeichert, damit die Herkunft sichtbar bleibt.
</p>
<div className="form-row">
<label className="form-label">Rahmenprogramm</label>
<select
className="form-input"
value={fwImportProgramId}
onChange={(e) => onFwImportProgramChange(e.target.value)}
disabled={fwImportLoading || fwImportSubmitting}
>
<option value="">Bitte wählen</option>
{frameworkProgramsList.map((fp) => (
<option key={fp.id} value={String(fp.id)}>
{(fp.title || '').trim() || `Rahmen #${fp.id}`}
</option>
))}
</select>
</div>
{fwImportLoading ? (
<p style={{ color: 'var(--text2)', marginTop: '1rem' }}>Laden der Sessions</p>
) : fwImportDetail?.slots?.length ? (
<>
<fieldset style={{ border: 'none', margin: '1rem 0', padding: 0 }}>
<legend className="form-label" style={{ padding: 0, marginBottom: '0.5rem' }}>
Sessions (mit Ablauf)
</legend>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{[...fwImportDetail.slots]
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
.map((slot) => {
const hasBp = !!slot.blueprint_training_unit_id
const checked = fwImportSelectedSlots.has(slot.id)
const label =
(slot.title || '').trim() ||
`Session ${(slot.sort_order ?? 0) + 1}`
return (
<li key={slot.id} style={{ marginBottom: '10px' }}>
<label
style={{
display: 'flex',
gap: '10px',
alignItems: 'flex-start',
cursor: hasBp ? 'pointer' : 'not-allowed',
opacity: hasBp ? 1 : 0.55,
}}
>
<input
type="checkbox"
checked={checked}
disabled={!hasBp || fwImportSubmitting}
onChange={() => toggleFwImportSlot(slot)}
style={{ marginTop: '0.2rem', flexShrink: 0 }}
/>
<span style={{ flex: 1, minWidth: 0 }}>
<strong>{label}</strong>
{!hasBp ? (
<span style={{ display: 'block', fontSize: '0.82rem', color: 'var(--danger)' }}>
Ohne Session-Ablauf Übernahme nicht möglich.
</span>
) : null}
{hasBp && checked ? (
<span style={{ display: 'block', marginTop: '6px' }}>
<span className="form-label" style={{ fontSize: '0.78rem' }}>
Termin (Datum)
</span>
<input
type="date"
className="form-input"
style={{ maxWidth: '200px', marginTop: '4px' }}
value={fwImportSlotDates[String(slot.id)] || ''}
onChange={(e) =>
setFwImportSlotDates((prev) => ({
...prev,
[String(slot.id)]: e.target.value,
}))
}
disabled={fwImportSubmitting}
/>
</span>
) : null}
</span>
</label>
</li>
)
})}
</ul>
</fieldset>
<div
className="responsive-grid-3"
style={{
marginBottom: '0.75rem',
padding: '12px',
background: 'var(--surface2)',
borderRadius: '8px',
}}
>
<div className="form-row">
<label className="form-label">Startdatum (Vorschlag)</label>
<input
type="date"
className="form-input"
value={fwImportStartDate}
onChange={(e) => setFwImportStartDate(e.target.value)}
disabled={fwImportSubmitting}
/>
</div>
<div className="form-row">
<label className="form-label">Abstand (Tage)</label>
<input
type="number"
min={0}
className="form-input"
value={fwImportIntervalDays}
onChange={(e) => setFwImportIntervalDays(parseInt(e.target.value, 10) || 0)}
disabled={fwImportSubmitting}
/>
</div>
<div className="form-row" style={{ alignSelf: 'end' }}>
<button
type="button"
className="btn btn-secondary"
style={{ width: '100%' }}
disabled={fwImportSubmitting}
onClick={applyFwImportDateSuggestions}
>
Datumsvorschläge setzen
</button>
</div>
</div>
</>
) : fwImportProgramId ? (
<p style={{ color: 'var(--text2)', marginTop: '0.75rem' }}>Keine Sessions in diesem Programm.</p>
) : null}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginTop: '1.25rem' }}>
<button
type="button"
className="btn btn-primary"
disabled={fwImportSubmitting || !fwImportDetail}
onClick={submitFrameworkImport}
>
{fwImportSubmitting ? 'Übernehmen…' : 'In Planung übernehmen'}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={fwImportSubmitting}
onClick={() => setFrameworkImportOpen(false)}
>
Abbrechen
</button>
</div>
</div>
</div>
)}
{showModal && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '1rem',
overflowY: 'auto'
}}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: 'clamp(12px, 3vw, 2rem)',
maxWidth: 'min(1100px, 100%)',
width: '100%',
maxHeight: '92vh',
overflowY: 'auto',
margin: 'max(0px, env(safe-area-inset-top, 0px)) auto',
boxSizing: 'border-box',
minWidth: 0
}}
>
<h2 style={{ marginBottom: '1rem' }}>
{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
</h2>
{editingUnit?.origin_framework_slot_id ? (() => {
const L = frameworkLineageText(editingUnit)
return (
<div
className="card"
style={{
marginBottom: '1.1rem',
padding: '12px 14px',
background: 'var(--surface2)',
fontSize: '0.9rem',
lineHeight: 1.5,
}}
>
<strong style={{ color: 'var(--text1)' }}>Herkunft:</strong>{' '}
{editingUnit.origin_framework_program_id ? (
<Link
to={`/planning/framework-programs/${editingUnit.origin_framework_program_id}`}
style={{ color: 'var(--accent-dark)' }}
>
{L.fpTitle}
</Link>
) : (
L.fpTitle
)}
<span style={{ color: 'var(--text2)' }}> · {L.slotBit}</span>
<p style={{ margin: '0.5rem 0 0', fontSize: '0.82rem', color: 'var(--text2)' }}>
Inhalt stammt aus dem Session-Blueprint des Rahmenprogramms. Änderungen gelten nur für diese
geplante Einheit; die Zuordnung zum Rahmen bleibt zur Nachverfolgung erhalten.
</p>
</div>
)
})() : null}
{!editingUnit && (
<div className="form-row" style={{ marginBottom: '1.25rem' }}>
<label className="form-label">Gliederungsvorlage (optional)</label>
<select
className="form-input"
value={draftPlanTemplateId}
onChange={(e) => applyTemplateFromSelect(e.target.value)}
>
<option value="">Keine Vorlage</option>
{planTemplates.map((t) => (
<option key={t.id} value={String(t.id)}>
{t.name}
</option>
))}
</select>
<p style={{ fontSize: '0.8rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
Lädt die Abschnitte und Hinweise aus der Vorlage; Übungen fügst du hier ein.
</p>
</div>
)}
<form onSubmit={handleSubmit}>
<h3 style={{ marginBottom: '1rem' }}>Planung</h3>
<div className="responsive-grid-3" style={{ marginBottom: '1rem' }}>
<div className="form-row">
<label className="form-label">Datum *</label>
<input
type="date"
className="form-input"
value={formData.planned_date}
onChange={(e) => updateFormField('planned_date', e.target.value)}
required
/>
</div>
<div className="form-row">
<label className="form-label">Von</label>
<input
type="time"
className="form-input"
value={formData.planned_time_start}
onChange={(e) => updateFormField('planned_time_start', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Bis</label>
<input
type="time"
className="form-input"
value={formData.planned_time_end}
onChange={(e) => updateFormField('planned_time_end', e.target.value)}
/>
</div>
</div>
<div className="form-row">
<label className="form-label">Trainingsfokus</label>
<input
type="text"
className="form-input"
value={formData.planned_focus}
onChange={(e) => updateFormField('planned_focus', e.target.value)}
placeholder="z.B. Grundlagen, Kinder altersgerecht"
/>
</div>
<div
className="card"
style={{
marginTop: '1.25rem',
marginBottom: '0.25rem',
padding: '12px 14px',
background: 'var(--surface2)',
}}
>
<h3 style={{ margin: '0 0 10px', fontSize: '1rem' }}>Trainerzuordnung (diese Einheit)</h3>
<div className="form-row">
<label className="form-label">Leitung</label>
<select
className="form-input"
value={formData.lead_trainer_profile_id}
onChange={(e) => updateFormField('lead_trainer_profile_id', e.target.value)}
disabled={!editingUnit && !formData.group_id}
>
<option value="">Standard (Haupttrainer der Gruppe)</option>
{clubDirectory.map((m) => {
const idStr = String(m.id)
return (
<option key={idStr} value={idStr}>
{(m.name || '').trim() || m.email || `Profil ${m.id}`}
</option>
)
})}
</select>
<p style={{ fontSize: '0.8rem', color: 'var(--text2)', marginTop: '0.35rem', lineHeight: 1.45 }}>
Für Vertretungen genügt in der Regel die Vereinsmitgliedschaft; Zuweisen dürfen u.a.
Haupt-/CoTrainer dieser Gruppe, der/die Ersteller:in der Einheit oder Vereinsadmins.
</p>
</div>
<div className="form-row" style={{ marginTop: '0.75rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={formData.session_assistants_inherit}
onChange={(e) =>
updateFormField('session_assistants_inherit', e.target.checked)
}
/>
<span style={{ fontSize: '0.9rem', color: 'var(--text1)' }}>
Co-Trainer wie in der Trainingsgruppe (Standard)
</span>
</label>
</div>
{!formData.session_assistants_inherit ? (
<div style={{ marginTop: '10px', maxHeight: '200px', overflowY: 'auto' }}>
{clubDirectoryForCo.map((m) => {
const mid = typeof m.id === 'number' ? m.id : parseInt(String(m.id), 10)
const labelText = `${(m.name || '').trim() || m.email || `Profil ${mid}`}`
const isOn = Number.isFinite(mid) && formData.session_assistant_profile_ids.includes(mid)
return (
<label
key={`co-${mid}`}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '0.875rem',
marginBottom: '6px',
cursor: 'pointer',
color: 'var(--text1)',
}}
>
<input
type="checkbox"
checked={isOn}
onChange={() => {
setFormData((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 }
})
}}
/>
<span>{labelText}</span>
</label>
)
})}
</div>
) : null}
{!clubDirectory.length && showModal ? (
<p style={{ margin: '10px 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.4 }}>
Keine Einträge im Vereins-Mitgliederverzeichnis oder noch nicht geladen (nur für Vereinsinterne).
</p>
) : null}
</div>
<div style={{ marginTop: '2rem' }}>
<TrainingUnitSectionsEditor
heading="Abschnitte & Übungen"
headingAccessory={
<button type="button" className="btn btn-secondary" onClick={handleSaveAsTemplate}>
Vorlage aus Aufbau speichern
</button>
}
sections={formData.sections}
wideExerciseGrid
onSectionsChange={(updater) =>
setFormData((prev) => ({
...prev,
sections: updater(prev.sections),
}))
}
onRequestExercisePick={({ sectionIndex, itemIndex }) => {
setExercisePickerTarget({
sIdx: sectionIndex,
iIdx: typeof itemIndex === 'number' ? itemIndex : undefined,
})
setExercisePickerOpen(true)
}}
onPeekExercise={(id, variantId) =>
setPlanningPeekCtx({ exerciseId: id, variantId: variantId ?? null })
}
showExecutionExtras={!!editingUnit}
/>
</div>
<div style={{ marginBottom: '1.75rem' }} />
{editingUnit && (
<>
<h3 style={{ marginTop: '0.5rem', marginBottom: '1rem' }}>Durchführung</h3>
<div className="responsive-grid-4" style={{ marginBottom: '1rem' }}>
<div className="form-row">
<label className="form-label">Tatsächliches Datum</label>
<input
type="date"
className="form-input"
value={formData.actual_date}
onChange={(e) => updateFormField('actual_date', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Von</label>
<input
type="time"
className="form-input"
value={formData.actual_time_start}
onChange={(e) => updateFormField('actual_time_start', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Bis</label>
<input
type="time"
className="form-input"
value={formData.actual_time_end}
onChange={(e) => updateFormField('actual_time_end', e.target.value)}
/>
</div>
<div className="form-row">
<label className="form-label">Teilnehmer</label>
<input
type="number"
className="form-input"
value={formData.attendance_count}
onChange={(e) => updateFormField('attendance_count', e.target.value)}
/>
</div>
</div>
<div className="form-row">
<label className="form-label">Status</label>
<select
className="form-input"
value={formData.status}
onChange={(e) => updateFormField('status', e.target.value)}
>
<option value="planned">Geplant</option>
<option value="completed">Durchgeführt</option>
<option value="cancelled">Abgesagt</option>
</select>
</div>
</>
)}
<h3 style={{ marginTop: '2rem', marginBottom: '1rem' }}>Notizen</h3>
<div className="form-row">
<label className="form-label">Öffentliche Notizen</label>
<textarea
className="form-input"
rows={3}
value={formData.notes}
onChange={(e) => updateFormField('notes', e.target.value)}
placeholder="Für Teilnehmer"
/>
</div>
<div className="form-row">
<label className="form-label">Trainernotizen</label>
<textarea
className="form-input"
rows={3}
value={formData.trainer_notes}
onChange={(e) => updateFormField('trainer_notes', e.target.value)}
/>
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.5rem' }}>
<button type="submit" className="btn btn-primary" style={{ flex: 1 }}>
{editingUnit ? 'Speichern' : 'Erstellen'}
</button>
<button type="button" className="btn btn-secondary" onClick={() => setShowModal(false)}>
Abbrechen
</button>
</div>
</form>
</div>
</div>
)}
<ExercisePickerModal
open={exercisePickerOpen}
multiSelect
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 } = 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 }
}
return { ...s, items: [...items, ...rows] }
}),
}))
setExercisePickerOpen(false)
setExercisePickerTarget(null)
}}
/>
<ExercisePeekModal
open={planningPeekCtx != null}
exerciseId={planningPeekCtx?.exerciseId}
variantId={planningPeekCtx?.variantId ?? undefined}
onClose={() => setPlanningPeekCtx(null)}
/>
</div>
)
}
export default TrainingPlanningPage