All checks were successful
Deploy Development / deploy (push) Successful in 33s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 45s
- Added functionality to promote private exercises used in training units to club visibility, allowing better access for trainers and members. - Introduced helper functions to retrieve distinct exercise IDs and group club IDs for scheduled units. - Updated the create, update, and quick create training unit methods to include exercise promotion logic, enhancing exercise management within clubs.
2499 lines
99 KiB
JavaScript
2499 lines
99 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 ExercisePickerModal from '../components/ExercisePickerModal'
|
||
import ExercisePeekModal from '../components/ExercisePeekModal'
|
||
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
|
||
import TrainingPlanExerciseVisibilityPanel from '../components/TrainingPlanExerciseVisibilityPanel'
|
||
import PageSectionNav from '../components/PageSectionNav'
|
||
import {
|
||
defaultSection,
|
||
normalizeUnitToForm,
|
||
enrichSectionsWithVariants,
|
||
buildSectionsPayload,
|
||
hydrateExercisePlanningRow,
|
||
} from '../utils/trainingUnitSectionsForm'
|
||
|
||
function addDaysIsoDate(isoDay, daysDelta) {
|
||
const d = new Date(`${isoDay}T12:00:00`)
|
||
d.setDate(d.getDate() + daysDelta)
|
||
return d.toISOString().slice(0, 10)
|
||
}
|
||
|
||
function pad2(n) {
|
||
return String(n).padStart(2, '0')
|
||
}
|
||
|
||
function toIsoLocal(d) {
|
||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
|
||
}
|
||
|
||
/** Montag = erster Wochentag (ISO-Woche UI) */
|
||
function mondayIndex(d) {
|
||
return (d.getDay() + 6) % 7
|
||
}
|
||
|
||
/** Kalendarische Monatsansicht: erster und letzter Tag des sichtbaren Rasters (Mo–So) */
|
||
function getCalendarGridRange(ym) {
|
||
const parts = (ym || '').split('-').map(Number)
|
||
const y = parts[0]
|
||
const m = parts[1]
|
||
if (!y || !m || m < 1 || m > 12) {
|
||
const t = new Date()
|
||
return { gridStart: toIsoLocal(t), gridEnd: toIsoLocal(t) }
|
||
}
|
||
const first = new Date(y, m - 1, 1)
|
||
const last = new Date(y, m, 0)
|
||
const gridStart = new Date(first)
|
||
gridStart.setDate(first.getDate() - mondayIndex(first))
|
||
const lastMon = mondayIndex(last)
|
||
const gridEnd = new Date(last)
|
||
gridEnd.setDate(last.getDate() + (6 - lastMon))
|
||
return { gridStart: toIsoLocal(gridStart), gridEnd: toIsoLocal(gridEnd) }
|
||
}
|
||
|
||
function shiftCalendarMonth(ym, delta) {
|
||
const parts = (ym || '').split('-').map(Number)
|
||
const y = parts[0] || new Date().getFullYear()
|
||
const m = parts[1] || 1
|
||
const d = new Date(y, m - 1 + delta, 1)
|
||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`
|
||
}
|
||
|
||
function enumerateIsoDays(fromIso, toIso) {
|
||
const out = []
|
||
const cur = new Date(`${fromIso}T12:00:00`)
|
||
const end = new Date(`${toIso}T12:00:00`)
|
||
while (cur <= end) {
|
||
out.push(toIsoLocal(cur))
|
||
cur.setDate(cur.getDate() + 1)
|
||
}
|
||
return out
|
||
}
|
||
|
||
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||
|
||
function toNumList(arr) {
|
||
if (!Array.isArray(arr)) return []
|
||
const out = []
|
||
for (const x of arr) {
|
||
const n = Number(x)
|
||
if (Number.isFinite(n) && n >= 1) out.push(n)
|
||
}
|
||
return out
|
||
}
|
||
|
||
const sessionAssignDefaults = () => ({
|
||
lead_trainer_profile_id: '',
|
||
session_assistants_inherit: true,
|
||
session_assistant_profile_ids: [],
|
||
})
|
||
|
||
/** Co_trainer_ids aus TrainingGroups (Liste/JSON) → Zahlenliste */
|
||
function normalizeGroupCoTrainerIds(raw) {
|
||
if (raw == null) return []
|
||
const arr = Array.isArray(raw) ? raw : []
|
||
const out = []
|
||
for (const x of arr) {
|
||
const n = Number(x)
|
||
if (Number.isFinite(n) && n >= 1) out.push(n)
|
||
}
|
||
return out
|
||
}
|
||
|
||
/** Mitgliederverzeichnis-Einträge ohne effektiven Leitungsträger als Co‑Option */
|
||
function filterDirectoryExcludingLead(directory, excludeLeadPid) {
|
||
const ex =
|
||
excludeLeadPid != null && excludeLeadPid !== '' && Number.isFinite(Number(excludeLeadPid))
|
||
? Number(excludeLeadPid)
|
||
: null
|
||
if (ex == null) return directory
|
||
return directory.filter((m) => Number(m.id) !== ex)
|
||
}
|
||
function TrainingPlanningPage() {
|
||
const { user } = useAuth()
|
||
const [searchParams, setSearchParams] = useSearchParams()
|
||
const unitDeepLinkHandledRef = useRef(null)
|
||
const [groups, setGroups] = useState([])
|
||
const [selectedGroupId, setSelectedGroupId] = useState('')
|
||
const [units, setUnits] = useState([])
|
||
const [planTemplates, setPlanTemplates] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [showModal, setShowModal] = useState(false)
|
||
const [editingUnit, setEditingUnit] = useState(null)
|
||
/** Abschnitts-Editor bei Bearbeitung: Planung vs. Nachbereitung (Ist & Abweichungen) */
|
||
const [sectionsEditMode, setSectionsEditMode] = useState('planning')
|
||
const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('')
|
||
const [exercisePickerOpen, setExercisePickerOpen] = useState(false)
|
||
const [exercisePickerTarget, setExercisePickerTarget] = useState(null)
|
||
const [planningPeekCtx, setPlanningPeekCtx] = useState(null)
|
||
|
||
const today = new Date().toISOString().split('T')[0]
|
||
const thirtyDaysLater = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||
|
||
const [frameworkImportOpen, setFrameworkImportOpen] = useState(false)
|
||
const [frameworkProgramsList, setFrameworkProgramsList] = useState([])
|
||
const [fwImportProgramId, setFwImportProgramId] = useState('')
|
||
const [fwImportDetail, setFwImportDetail] = useState(null)
|
||
const [fwImportLoading, setFwImportLoading] = useState(false)
|
||
const [fwImportSelectedSlots, setFwImportSelectedSlots] = useState(() => new Set())
|
||
const [fwImportSlotDates, setFwImportSlotDates] = useState({})
|
||
const [fwImportStartDate, setFwImportStartDate] = useState(today)
|
||
const [fwImportIntervalDays, setFwImportIntervalDays] = useState(7)
|
||
const [fwImportSubmitting, setFwImportSubmitting] = useState(false)
|
||
|
||
const [startDate, setStartDate] = useState(today)
|
||
const [endDate, setEndDate] = useState(thirtyDaysLater)
|
||
const [planView, setPlanView] = useState('list')
|
||
const [calendarMonthStr, setCalendarMonthStr] = useState(() => today.slice(0, 7))
|
||
const [planScope, setPlanScope] = useState('group')
|
||
const [assignedToMeOnly, setAssignedToMeOnly] = useState(false)
|
||
const [clubDirectory, setClubDirectory] = useState([])
|
||
const [assignModalOpen, setAssignModalOpen] = useState(false)
|
||
const [assignDraft, setAssignDraft] = useState({
|
||
unit: null,
|
||
lead_trainer_profile_id: '',
|
||
session_assistants_inherit: true,
|
||
session_assistant_profile_ids: [],
|
||
})
|
||
const [assignSaving, setAssignSaving] = useState(false)
|
||
|
||
const [formData, setFormData] = useState({
|
||
group_id: '',
|
||
planned_date: '',
|
||
planned_time_start: '',
|
||
planned_time_end: '',
|
||
planned_focus: '',
|
||
actual_date: '',
|
||
actual_time_start: '',
|
||
actual_time_end: '',
|
||
attendance_count: '',
|
||
status: 'planned',
|
||
notes: '',
|
||
trainer_notes: '',
|
||
debrief_completed: false,
|
||
sections: [defaultSection()],
|
||
...sessionAssignDefaults()
|
||
})
|
||
const planningFormRef = useRef(formData)
|
||
planningFormRef.current = formData
|
||
|
||
const planningModalClubId = useMemo(() => {
|
||
const gid = Number(formData.group_id)
|
||
if (!Number.isFinite(gid) || gid < 1) return null
|
||
const g = groups.find((x) => Number(x.id) === gid)
|
||
if (!g || g.club_id == null || g.club_id === '') return null
|
||
const c = Number(g.club_id)
|
||
return Number.isFinite(c) ? c : null
|
||
}, [groups, formData.group_id])
|
||
|
||
const refreshPlanningSectionMeta = useCallback(async () => {
|
||
const next = await enrichSectionsWithVariants(planningFormRef.current.sections)
|
||
setFormData((prev) => ({ ...prev, sections: next }))
|
||
}, [])
|
||
|
||
const loadPlanTemplates = useCallback(async () => {
|
||
try {
|
||
const tpl = await api.listTrainingPlanTemplates()
|
||
setPlanTemplates(tpl)
|
||
} catch (e) {
|
||
console.error('Vorlagen laden:', e)
|
||
}
|
||
}, [])
|
||
|
||
const loadData = async () => {
|
||
try {
|
||
const groupsData = await api.listTrainingGroups({ status: 'active' })
|
||
setGroups(groupsData)
|
||
await loadPlanTemplates()
|
||
|
||
if (groupsData.length > 0) {
|
||
const ownGroup = groupsData.find((g) => g.trainer_id === user?.id)
|
||
if (ownGroup) {
|
||
setSelectedGroupId(ownGroup.id)
|
||
} else if (groupsData.length === 1) {
|
||
setSelectedGroupId(groupsData[0].id)
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load data:', err)
|
||
alert('Fehler beim Laden: ' + err.message)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const loadUnits = useCallback(async () => {
|
||
if (!selectedGroupId) return
|
||
let start = startDate
|
||
let end = endDate
|
||
if (planView === 'calendar') {
|
||
const r = getCalendarGridRange(calendarMonthStr)
|
||
start = r.gridStart
|
||
end = r.gridEnd
|
||
}
|
||
const gid = parseInt(selectedGroupId, 10)
|
||
const groupRow = groups.find((g) => g.id === gid)
|
||
const clubId = groupRow?.club_id
|
||
try {
|
||
const filters = {
|
||
start_date: start,
|
||
end_date: end
|
||
}
|
||
if (assignedToMeOnly) {
|
||
filters.assigned_to_me = true
|
||
}
|
||
if (planScope === 'club' && clubId) {
|
||
filters.club_id = clubId
|
||
} else {
|
||
filters.group_id = gid
|
||
}
|
||
const unitsData = await api.listTrainingUnits(filters)
|
||
setUnits(unitsData)
|
||
} catch (err) {
|
||
console.error('Failed to load units:', err)
|
||
}
|
||
}, [
|
||
selectedGroupId,
|
||
groups,
|
||
startDate,
|
||
endDate,
|
||
planView,
|
||
calendarMonthStr,
|
||
planScope,
|
||
assignedToMeOnly
|
||
])
|
||
|
||
useEffect(() => {
|
||
loadData()
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (selectedGroupId) {
|
||
loadUnits()
|
||
}
|
||
}, [selectedGroupId, loadUnits])
|
||
|
||
const selectedGroupClubIdMemo = useMemo(() => {
|
||
const g = groups.find((gr) => gr.id === parseInt(selectedGroupId, 10))
|
||
return g?.club_id != null ? Number(g.club_id) : null
|
||
}, [groups, selectedGroupId])
|
||
|
||
const canClubOrgTraining = useMemo(() => {
|
||
const r = (user?.role || '').toLowerCase()
|
||
if (r === 'admin' || r === 'superadmin') return true
|
||
if (selectedGroupClubIdMemo == null || !Number.isFinite(selectedGroupClubIdMemo)) return false
|
||
const row = (user?.clubs || []).find((c) => Number(c.id) === selectedGroupClubIdMemo)
|
||
return Array.isArray(row?.roles) && row.roles.includes('club_admin')
|
||
}, [user?.role, user?.clubs, selectedGroupClubIdMemo])
|
||
|
||
const clubAdminClubIdSet = useMemo(() => {
|
||
const ids = []
|
||
for (const c of user?.clubs || []) {
|
||
if (Array.isArray(c.roles) && c.roles.includes('club_admin')) {
|
||
const id = Number(c.id)
|
||
if (Number.isFinite(id)) ids.push(id)
|
||
}
|
||
}
|
||
return new Set(ids)
|
||
}, [user?.clubs])
|
||
|
||
useEffect(() => {
|
||
const gid = parseInt(formData.group_id || selectedGroupId || '0', 10)
|
||
const gModal = Number.isFinite(gid) && gid >= 1 ? groups.find((x) => x.id === gid) : null
|
||
const clubForModal = gModal?.club_id != null ? Number(gModal.club_id) : null
|
||
|
||
let assignModalClubId = null
|
||
if (assignModalOpen && assignDraft.unit?.group_id != null) {
|
||
const ug = Number(assignDraft.unit.group_id)
|
||
const gAssign = Number.isFinite(ug) ? groups.find((x) => x.id === ug) : null
|
||
if (gAssign?.club_id != null) assignModalClubId = Number(gAssign.club_id)
|
||
}
|
||
|
||
const loadClubId =
|
||
showModal && clubForModal != null && Number.isFinite(clubForModal)
|
||
? clubForModal
|
||
: assignModalOpen && assignModalClubId != null && Number.isFinite(assignModalClubId)
|
||
? assignModalClubId
|
||
: canClubOrgTraining && selectedGroupClubIdMemo != null && Number.isFinite(selectedGroupClubIdMemo)
|
||
? selectedGroupClubIdMemo
|
||
: null
|
||
|
||
if (loadClubId == null || !Number.isFinite(loadClubId)) {
|
||
setClubDirectory([])
|
||
return undefined
|
||
}
|
||
let cancelled = false
|
||
;(async () => {
|
||
try {
|
||
const d = await api.clubMembersDirectory(loadClubId)
|
||
if (!cancelled) setClubDirectory(Array.isArray(d) ? d : [])
|
||
} catch (err) {
|
||
if (!cancelled) {
|
||
console.error('Mitgliederverzeichnis:', err)
|
||
setClubDirectory([])
|
||
}
|
||
}
|
||
})()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [
|
||
showModal,
|
||
assignModalOpen,
|
||
assignDraft.unit,
|
||
formData.group_id,
|
||
selectedGroupId,
|
||
groups,
|
||
canClubOrgTraining,
|
||
selectedGroupClubIdMemo,
|
||
])
|
||
|
||
useEffect(() => {
|
||
if (!frameworkImportOpen) return
|
||
let cancelled = false
|
||
;(async () => {
|
||
try {
|
||
const list = await api.listTrainingFrameworkPrograms()
|
||
if (!cancelled) setFrameworkProgramsList(Array.isArray(list) ? list : [])
|
||
} catch (e) {
|
||
if (!cancelled) {
|
||
console.error('Rahmenprogramme laden:', e)
|
||
setFrameworkProgramsList([])
|
||
}
|
||
}
|
||
})()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [frameworkImportOpen])
|
||
|
||
const openFrameworkImportModal = useCallback(() => {
|
||
setFwImportProgramId('')
|
||
setFwImportDetail(null)
|
||
setFwImportSelectedSlots(new Set())
|
||
setFwImportSlotDates({})
|
||
setFwImportStartDate(new Date().toISOString().split('T')[0])
|
||
setFwImportIntervalDays(7)
|
||
setFrameworkImportOpen(true)
|
||
}, [])
|
||
|
||
const onFwImportProgramChange = async (idStr) => {
|
||
setFwImportProgramId(idStr)
|
||
if (!idStr) {
|
||
setFwImportDetail(null)
|
||
return
|
||
}
|
||
setFwImportLoading(true)
|
||
try {
|
||
const d = await api.getTrainingFrameworkProgram(parseInt(idStr, 10))
|
||
setFwImportDetail(d)
|
||
setFwImportSelectedSlots(new Set())
|
||
setFwImportSlotDates({})
|
||
} catch (e) {
|
||
alert(e.message || 'Rahmenprogramm laden fehlgeschlagen')
|
||
setFwImportDetail(null)
|
||
} finally {
|
||
setFwImportLoading(false)
|
||
}
|
||
}
|
||
|
||
const toggleFwImportSlot = (slot) => {
|
||
if (!slot?.blueprint_training_unit_id) return
|
||
const sid = slot.id
|
||
setFwImportSelectedSlots((prev) => {
|
||
const n = new Set(prev)
|
||
if (n.has(sid)) n.delete(sid)
|
||
else n.add(sid)
|
||
return n
|
||
})
|
||
}
|
||
|
||
const applyFwImportDateSuggestions = () => {
|
||
if (!fwImportDetail?.slots?.length) return
|
||
const sorted = [...fwImportDetail.slots].sort(
|
||
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)
|
||
)
|
||
let offset = 0
|
||
const iv = Math.max(0, Number(fwImportIntervalDays) || 0)
|
||
const next = {}
|
||
for (const s of sorted) {
|
||
if (!fwImportSelectedSlots.has(s.id)) continue
|
||
if (!s.blueprint_training_unit_id) continue
|
||
next[String(s.id)] = addDaysIsoDate(fwImportStartDate, offset)
|
||
offset += iv
|
||
}
|
||
setFwImportSlotDates((prev) => ({ ...prev, ...next }))
|
||
}
|
||
|
||
const submitFrameworkImport = async () => {
|
||
if (!selectedGroupId) {
|
||
alert('Bitte zuerst eine Trainingsgruppe wählen.')
|
||
return
|
||
}
|
||
const gid = parseInt(selectedGroupId, 10)
|
||
if (!fwImportDetail?.slots?.length) return
|
||
const sorted = [...fwImportDetail.slots].sort(
|
||
(a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)
|
||
)
|
||
const picks = sorted.filter(
|
||
(s) => fwImportSelectedSlots.has(s.id) && s.blueprint_training_unit_id
|
||
)
|
||
if (!picks.length) {
|
||
alert('Bitte mindestens eine Session mit Ablauf (Blueprint) auswählen.')
|
||
return
|
||
}
|
||
for (const s of picks) {
|
||
const key = String(s.id)
|
||
const date = fwImportSlotDates[key] || fwImportStartDate
|
||
if (!date) {
|
||
alert('Bitte für jede ausgewählte Session ein Datum setzen (oder Datumsvorschläge nutzen).')
|
||
return
|
||
}
|
||
}
|
||
setFwImportSubmitting(true)
|
||
try {
|
||
for (const s of picks) {
|
||
const key = String(s.id)
|
||
const date = fwImportSlotDates[key] || fwImportStartDate
|
||
await api.createTrainingUnitFromFrameworkSlot({
|
||
group_id: gid,
|
||
planned_date: date,
|
||
framework_slot_id: s.id,
|
||
})
|
||
}
|
||
setFrameworkImportOpen(false)
|
||
await loadUnits()
|
||
} catch (e) {
|
||
alert(e.message || 'Übernahme fehlgeschlagen')
|
||
} finally {
|
||
setFwImportSubmitting(false)
|
||
}
|
||
}
|
||
|
||
const frameworkLineageText = (unit) => {
|
||
const fpTitle = (unit.origin_framework_program_title || '').trim() || 'Rahmenprogramm'
|
||
const st = (unit.origin_framework_slot_title || '').trim()
|
||
const idx = unit.origin_framework_slot_sort_order
|
||
const slotBit =
|
||
st || (typeof idx === 'number' ? `Session ${idx + 1}` : 'Session')
|
||
return { fpTitle, slotBit, fpId: unit.origin_framework_program_id }
|
||
}
|
||
|
||
const handleCreate = () => {
|
||
if (!selectedGroupId) {
|
||
alert('Bitte wähle zuerst eine Trainingsgruppe')
|
||
return
|
||
}
|
||
const group = groups.find((g) => g.id === parseInt(selectedGroupId, 10))
|
||
setEditingUnit(null)
|
||
setDraftPlanTemplateId('')
|
||
setFormData({
|
||
group_id: selectedGroupId,
|
||
planned_date: today,
|
||
planned_time_start: group?.time_start?.slice(0, 5) || '',
|
||
planned_time_end: group?.time_end?.slice(0, 5) || '',
|
||
planned_focus: '',
|
||
actual_date: '',
|
||
actual_time_start: '',
|
||
actual_time_end: '',
|
||
attendance_count: '',
|
||
status: 'planned',
|
||
notes: '',
|
||
trainer_notes: '',
|
||
debrief_completed: false,
|
||
sections: [defaultSection('Hauptteil')],
|
||
...sessionAssignDefaults()
|
||
})
|
||
setSectionsEditMode('planning')
|
||
setShowModal(true)
|
||
}
|
||
|
||
const handleCreateForDate = (isoDay) => {
|
||
if (!selectedGroupId) {
|
||
alert('Bitte wähle zuerst eine Trainingsgruppe')
|
||
return
|
||
}
|
||
const group = groups.find((g) => g.id === parseInt(selectedGroupId, 10))
|
||
setEditingUnit(null)
|
||
setDraftPlanTemplateId('')
|
||
setFormData({
|
||
group_id: selectedGroupId,
|
||
planned_date: isoDay,
|
||
planned_time_start: group?.time_start?.slice(0, 5) || '',
|
||
planned_time_end: group?.time_end?.slice(0, 5) || '',
|
||
planned_focus: '',
|
||
actual_date: '',
|
||
actual_time_start: '',
|
||
actual_time_end: '',
|
||
attendance_count: '',
|
||
status: 'planned',
|
||
notes: '',
|
||
trainer_notes: '',
|
||
debrief_completed: false,
|
||
sections: [defaultSection('Hauptteil')],
|
||
...sessionAssignDefaults()
|
||
})
|
||
setSectionsEditMode('planning')
|
||
setShowModal(true)
|
||
}
|
||
|
||
const applyTemplateFromSelect = async (templateId) => {
|
||
setDraftPlanTemplateId(templateId)
|
||
if (!templateId) return
|
||
try {
|
||
const tpl = await api.getTrainingPlanTemplate(parseInt(templateId, 10))
|
||
setFormData((fd) => ({
|
||
...fd,
|
||
sections: (tpl.sections || []).length
|
||
? tpl.sections.map((s) => ({
|
||
title: s.title,
|
||
guidance_notes: s.guidance_text || '',
|
||
items: []
|
||
}))
|
||
: [defaultSection()]
|
||
}))
|
||
} catch (err) {
|
||
alert('Vorlage laden: ' + err.message)
|
||
}
|
||
}
|
||
|
||
const handleEdit = useCallback(async (unit) => {
|
||
try {
|
||
const fullUnit = await api.getTrainingUnit(unit.id)
|
||
setEditingUnit(fullUnit)
|
||
setDraftPlanTemplateId(fullUnit.plan_template_id ? String(fullUnit.plan_template_id) : '')
|
||
let sections = normalizeUnitToForm(fullUnit)
|
||
sections = await enrichSectionsWithVariants(sections)
|
||
setFormData({
|
||
group_id: fullUnit.group_id,
|
||
planned_date: fullUnit.planned_date || '',
|
||
planned_time_start: fullUnit.planned_time_start?.slice(0, 5) || '',
|
||
planned_time_end: fullUnit.planned_time_end?.slice(0, 5) || '',
|
||
planned_focus: fullUnit.planned_focus || '',
|
||
actual_date: fullUnit.actual_date || '',
|
||
actual_time_start: fullUnit.actual_time_start?.slice(0, 5) || '',
|
||
actual_time_end: fullUnit.actual_time_end?.slice(0, 5) || '',
|
||
attendance_count: fullUnit.attendance_count ?? '',
|
||
status: fullUnit.status || 'planned',
|
||
notes: fullUnit.notes || '',
|
||
trainer_notes: fullUnit.trainer_notes || '',
|
||
debrief_completed: Boolean(fullUnit.debrief_completed_at),
|
||
sections,
|
||
lead_trainer_profile_id:
|
||
fullUnit.lead_trainer_profile_id != null && fullUnit.lead_trainer_profile_id !== ''
|
||
? String(fullUnit.lead_trainer_profile_id)
|
||
: '',
|
||
session_assistants_inherit:
|
||
fullUnit.assistant_trainer_profile_ids == null ||
|
||
fullUnit.assistant_trainer_profile_ids === undefined,
|
||
session_assistant_profile_ids: (() => {
|
||
const efLead =
|
||
fullUnit.effective_lead_trainer_profile_id != null
|
||
? Number(fullUnit.effective_lead_trainer_profile_id)
|
||
: null
|
||
let xs = toNumList(fullUnit.assistant_trainer_profile_ids)
|
||
if (efLead != null && Number.isFinite(efLead)) xs = xs.filter((id) => id !== efLead)
|
||
return xs
|
||
})(),
|
||
})
|
||
setSectionsEditMode(fullUnit.status === 'completed' ? 'debrief' : 'planning')
|
||
setShowModal(true)
|
||
} catch (err) {
|
||
alert('Fehler beim Laden: ' + err.message)
|
||
throw err
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!user?.id || loading) return
|
||
const uid = searchParams.get('unit')
|
||
if (!uid) {
|
||
unitDeepLinkHandledRef.current = null
|
||
return
|
||
}
|
||
if (unitDeepLinkHandledRef.current === uid) return
|
||
const idNum = parseInt(uid, 10)
|
||
if (!Number.isFinite(idNum)) return
|
||
unitDeepLinkHandledRef.current = uid
|
||
handleEdit({ id: idNum })
|
||
.then(() => {
|
||
setSearchParams(
|
||
(prev) => {
|
||
const next = new URLSearchParams(prev)
|
||
next.delete('unit')
|
||
next.delete('debrief')
|
||
return next
|
||
},
|
||
{ replace: true }
|
||
)
|
||
})
|
||
.catch(() => {
|
||
unitDeepLinkHandledRef.current = null
|
||
setSearchParams(
|
||
(prev) => {
|
||
const next = new URLSearchParams(prev)
|
||
next.delete('unit')
|
||
next.delete('debrief')
|
||
return next
|
||
},
|
||
{ replace: true }
|
||
)
|
||
})
|
||
}, [user?.id, loading, searchParams, handleEdit, setSearchParams])
|
||
|
||
const handleSaveAsTemplate = async () => {
|
||
const name = window.prompt('Name für die neue Trainingsvorlage (nur Abschnitts-Gliederung):')
|
||
if (!name?.trim()) return
|
||
try {
|
||
await api.createTrainingPlanTemplate({
|
||
name: name.trim(),
|
||
sections: formData.sections.map((s) => ({
|
||
title: s.title || 'Abschnitt',
|
||
guidance_text: s.guidance_notes?.trim() ? s.guidance_notes.trim() : null
|
||
}))
|
||
})
|
||
await loadPlanTemplates()
|
||
alert('Vorlage gespeichert.')
|
||
} catch (err) {
|
||
alert('Speichern: ' + err.message)
|
||
}
|
||
}
|
||
|
||
const handleTakeLead = async (unit) => {
|
||
if (!user?.id) return
|
||
try {
|
||
await api.updateTrainingUnit(unit.id, { lead_trainer_profile_id: user.id })
|
||
await loadUnits()
|
||
} catch (err) {
|
||
alert(err.message || 'Leitung konnte nicht übernommen werden')
|
||
}
|
||
}
|
||
|
||
const openTrainerAssignModal = (unit) => {
|
||
const effLead =
|
||
unit.effective_lead_trainer_profile_id != null
|
||
? Number(unit.effective_lead_trainer_profile_id)
|
||
: null
|
||
let coIds = toNumList(unit.assistant_trainer_profile_ids)
|
||
if (effLead != null && Number.isFinite(effLead)) {
|
||
coIds = coIds.filter((id) => id !== effLead)
|
||
}
|
||
setAssignDraft({
|
||
unit,
|
||
lead_trainer_profile_id:
|
||
unit.lead_trainer_profile_id != null && unit.lead_trainer_profile_id !== ''
|
||
? String(unit.lead_trainer_profile_id)
|
||
: '',
|
||
session_assistants_inherit:
|
||
unit.assistant_trainer_profile_ids == null || unit.assistant_trainer_profile_ids === undefined,
|
||
session_assistant_profile_ids: coIds,
|
||
})
|
||
setAssignModalOpen(true)
|
||
}
|
||
|
||
const saveTrainerAssignModal = async () => {
|
||
if (!assignDraft.unit) return
|
||
setAssignSaving(true)
|
||
try {
|
||
const payload = {}
|
||
const leadStr = String(assignDraft.lead_trainer_profile_id || '').trim()
|
||
if (leadStr) payload.lead_trainer_profile_id = parseInt(leadStr, 10)
|
||
else payload.lead_trainer_profile_id = null
|
||
if (assignDraft.session_assistants_inherit) {
|
||
payload.assistant_trainer_profile_ids = null
|
||
} else {
|
||
payload.assistant_trainer_profile_ids = [...assignDraft.session_assistant_profile_ids].sort((a, b) => a - b)
|
||
}
|
||
await api.updateTrainingUnit(assignDraft.unit.id, payload)
|
||
setAssignModalOpen(false)
|
||
setAssignDraft({
|
||
unit: null,
|
||
...sessionAssignDefaults(),
|
||
})
|
||
await loadUnits()
|
||
} catch (err) {
|
||
alert(err.message || 'Zuweisung konnte nicht gespeichert werden')
|
||
} finally {
|
||
setAssignSaving(false)
|
||
}
|
||
}
|
||
|
||
const handleDelete = async (unit) => {
|
||
if (!confirm(`Trainingseinheit vom ${unit.planned_date} wirklich löschen?`)) return
|
||
try {
|
||
await api.deleteTrainingUnit(unit.id)
|
||
await loadUnits()
|
||
} catch (err) {
|
||
alert('Fehler beim Löschen: ' + err.message)
|
||
}
|
||
}
|
||
|
||
const handleSubmit = async (e) => {
|
||
e.preventDefault()
|
||
if (!formData.group_id || !formData.planned_date) {
|
||
alert('Gruppe und Datum sind Pflichtfelder')
|
||
return
|
||
}
|
||
try {
|
||
const sectionsPayload = buildSectionsPayload(formData.sections)
|
||
const payload = {
|
||
planned_date: formData.planned_date,
|
||
planned_time_start: formData.planned_time_start || null,
|
||
planned_time_end: formData.planned_time_end || null,
|
||
planned_focus: formData.planned_focus || null,
|
||
actual_date: formData.actual_date || null,
|
||
actual_time_start: formData.actual_time_start || null,
|
||
actual_time_end: formData.actual_time_end || null,
|
||
attendance_count: formData.attendance_count ? parseInt(formData.attendance_count, 10) : null,
|
||
status: formData.status || 'planned',
|
||
notes: formData.notes || null,
|
||
trainer_notes: formData.trainer_notes || null,
|
||
sections: sectionsPayload
|
||
}
|
||
if (editingUnit) {
|
||
payload.debrief_completed =
|
||
(formData.status || '') === 'completed' ? !!formData.debrief_completed : false
|
||
}
|
||
const leadStr = String(formData.lead_trainer_profile_id || '').trim()
|
||
if (leadStr) {
|
||
payload.lead_trainer_profile_id = parseInt(leadStr, 10)
|
||
} else if (editingUnit) {
|
||
payload.lead_trainer_profile_id = null
|
||
}
|
||
if (formData.session_assistants_inherit) {
|
||
if (editingUnit) payload.assistant_trainer_profile_ids = null
|
||
} else {
|
||
payload.assistant_trainer_profile_ids = [...formData.session_assistant_profile_ids].sort(
|
||
(a, b) => a - b
|
||
)
|
||
}
|
||
if (!editingUnit) {
|
||
payload.group_id = parseInt(formData.group_id, 10)
|
||
if (draftPlanTemplateId) {
|
||
payload.plan_template_id = parseInt(draftPlanTemplateId, 10)
|
||
}
|
||
}
|
||
|
||
if (editingUnit) {
|
||
await api.updateTrainingUnit(editingUnit.id, payload)
|
||
} else {
|
||
await api.createTrainingUnit(payload)
|
||
}
|
||
setShowModal(false)
|
||
await loadUnits()
|
||
} catch (err) {
|
||
alert('Fehler beim Speichern: ' + err.message)
|
||
}
|
||
}
|
||
|
||
const updateFormField = (field, value) => {
|
||
setFormData((prev) => {
|
||
if (field !== 'lead_trainer_profile_id') {
|
||
const patch = { ...prev, [field]: value }
|
||
if (field === 'status' && value !== 'completed') {
|
||
patch.debrief_completed = false
|
||
}
|
||
return patch
|
||
}
|
||
const ts = typeof value === 'string' ? value.trim() : String(value ?? '').trim()
|
||
const strip = new Set()
|
||
if (ts !== '') {
|
||
const nid = parseInt(ts, 10)
|
||
if (Number.isFinite(nid)) strip.add(nid)
|
||
} else {
|
||
const gidParsed = parseInt(prev.group_id || selectedGroupId || '0', 10)
|
||
const gr =
|
||
Number.isFinite(gidParsed) && gidParsed >= 1
|
||
? groups.find((xg) => xg.id === gidParsed)
|
||
: null
|
||
if (gr?.trainer_id != null) {
|
||
const ht = Number(gr.trainer_id)
|
||
if (Number.isFinite(ht)) strip.add(ht)
|
||
}
|
||
}
|
||
const assistants = prev.session_assistant_profile_ids.filter((id) => !strip.has(id))
|
||
return { ...prev, lead_trainer_profile_id: value, session_assistant_profile_ids: assistants }
|
||
})
|
||
}
|
||
|
||
const calendarGridDays = useMemo(() => {
|
||
const r = getCalendarGridRange(calendarMonthStr)
|
||
return enumerateIsoDays(r.gridStart, r.gridEnd)
|
||
}, [calendarMonthStr])
|
||
|
||
const unitsByPlannedDate = useMemo(() => {
|
||
const m = new Map()
|
||
for (const u of units) {
|
||
const raw = u.planned_date
|
||
if (!raw) continue
|
||
const key = String(raw).slice(0, 10)
|
||
if (!m.has(key)) m.set(key, [])
|
||
m.get(key).push(u)
|
||
}
|
||
return m
|
||
}, [units])
|
||
|
||
const calendarMonthTitle = useMemo(() => {
|
||
const p = calendarMonthStr.split('-').map(Number)
|
||
const y = p[0]
|
||
const mo = p[1]
|
||
if (!y || !mo) return ''
|
||
return new Date(y, mo - 1, 1).toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })
|
||
}, [calendarMonthStr])
|
||
|
||
const mayConfigureSessionAssignments = useCallback(
|
||
(unit) => {
|
||
if (!unit) return false
|
||
const pid = Number(user?.id)
|
||
if (!Number.isFinite(pid)) return false
|
||
const r = (user?.role || '').toLowerCase()
|
||
if (r === 'admin' || r === 'superadmin') return true
|
||
|
||
const gClub = unit.group_club_id != null ? Number(unit.group_club_id) : null
|
||
if (Number.isFinite(gClub) && clubAdminClubIdSet.has(gClub)) return true
|
||
|
||
const gid = Number(unit.group_id)
|
||
const g = groups.find((gr) => gr.id === gid)
|
||
if (!g) return false
|
||
|
||
const cb = unit.created_by != null ? Number(unit.created_by) : NaN
|
||
if (Number.isFinite(cb) && cb === pid) return true
|
||
|
||
const ht = g.trainer_id != null ? Number(g.trainer_id) : NaN
|
||
if (Number.isFinite(ht) && ht === pid) return true
|
||
|
||
return normalizeGroupCoTrainerIds(g.co_trainer_ids).includes(pid)
|
||
},
|
||
[user?.id, user?.role, groups, clubAdminClubIdSet]
|
||
)
|
||
|
||
if (loading) {
|
||
return (
|
||
<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, 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>
|
||
<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>
|
||
)}
|
||
|
||
{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="training-planning-template-panel" style={{ marginBottom: '1.35rem' }}>
|
||
<label className="form-label training-planning-template-panel__label" htmlFor="planning-draft-template">
|
||
Vorlage für den Ablauf
|
||
</label>
|
||
<select
|
||
id="planning-draft-template"
|
||
className="form-input training-planning-template-panel__select"
|
||
value={draftPlanTemplateId}
|
||
onChange={(e) => applyTemplateFromSelect(e.target.value)}
|
||
>
|
||
<option value="">Ohne Vorlage — leere Gliederung (ein Abschnitt)</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>
|
||
<p className="training-planning-template-panel__help">
|
||
Übernimmt nur die <strong>Sektionsstruktur</strong> aus der Bibliothek; Übungen trägst du unten bei
|
||
den Abschnitten ein. Gespeicherte Vorlagen kannst du unter Planung später erweitern.
|
||
</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-/Co‑Trainer 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>
|
||
|
||
<TrainingPlanExerciseVisibilityPanel
|
||
sections={formData.sections}
|
||
targetClubId={planningModalClubId}
|
||
user={user}
|
||
onMetaRefresh={refreshPlanningSectionMeta}
|
||
/>
|
||
|
||
<div style={{ marginTop: '2rem' }}>
|
||
{editingUnit ? (
|
||
<div style={{ marginBottom: '1rem' }}>
|
||
<div
|
||
role="radiogroup"
|
||
aria-label="Modus für Abschnitte und Übungen"
|
||
style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
alignItems: 'center',
|
||
gap: '10px',
|
||
}}
|
||
>
|
||
<span className="form-label" style={{ marginBottom: 0, fontSize: '0.82rem' }}>
|
||
Ablauf bearbeiten als
|
||
</span>
|
||
<div
|
||
style={{
|
||
display: 'inline-flex',
|
||
borderRadius: '10px',
|
||
border: '1.5px solid var(--border2)',
|
||
overflow: 'hidden',
|
||
background: 'var(--surface2)',
|
||
}}
|
||
>
|
||
{[
|
||
{ id: 'planning', label: 'Planung' },
|
||
{ id: 'debrief', label: 'Nachbereitung' },
|
||
].map((opt, i) => (
|
||
<button
|
||
key={opt.id}
|
||
type="button"
|
||
role="radio"
|
||
aria-checked={sectionsEditMode === opt.id}
|
||
onClick={() => setSectionsEditMode(opt.id)}
|
||
style={{
|
||
border: 'none',
|
||
padding: '8px 14px',
|
||
fontWeight: 600,
|
||
fontSize: '0.85rem',
|
||
cursor: 'pointer',
|
||
background: sectionsEditMode === opt.id ? 'var(--accent-dark)' : 'transparent',
|
||
color: sectionsEditMode === opt.id ? '#fff' : 'var(--text1)',
|
||
whiteSpace: 'nowrap',
|
||
...(i > 0 ? { borderLeft: '1.5px solid var(--border2)' } : {}),
|
||
}}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<p style={{ margin: '0.35rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
||
{sectionsEditMode === 'debrief'
|
||
? 'Ist‑Minuten rechts in derselben Spaltenbreite wie „Min“ (Plan); Abweichungen als Text über die volle Breite.'
|
||
: 'Ablauf, Übungen und geplante Minuten. Ist‑Werte und Abweichungen unter „Nachbereitung“.'}
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
<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={Boolean(editingUnit) && sectionsEditMode === 'debrief'}
|
||
/>
|
||
</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>
|
||
|
||
{formData.status === 'completed' ? (
|
||
<div className="form-row" style={{ marginTop: '0.75rem' }}>
|
||
<label
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'flex-start',
|
||
gap: '10px',
|
||
cursor: 'pointer',
|
||
lineHeight: 1.45,
|
||
}}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={!!formData.debrief_completed}
|
||
onChange={(e) => updateFormField('debrief_completed', e.target.checked)}
|
||
style={{ marginTop: '3px' }}
|
||
/>
|
||
<span>
|
||
<strong>Rückschau erledigt</strong>
|
||
<span className="muted" style={{ display: 'block', fontSize: '0.82rem', marginTop: '5px' }}>
|
||
Wenn angehakt, erscheint die Einheit nicht mehr unter „Offene Rückschau“ auf dem
|
||
Dashboard (Nachbereitung gilt als abgeschlossen).
|
||
</span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
) : null}
|
||
</>
|
||
)}
|
||
|
||
<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
|
||
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 } = 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
|