- Introduced new pages for managing training framework programs, including listing and editing functionalities. - Updated routing in App.jsx to accommodate new training framework program pages. - Enhanced API with CRUD operations for training framework programs. - Incremented application version to 0.5.1 and updated relevant page versions. - Added informational link in TrainingPlanningPage to guide users to training framework programs.
1416 lines
54 KiB
JavaScript
1416 lines
54 KiB
JavaScript
import React, { useState, useEffect, useCallback } 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'
|
|
|
|
function defaultSection(title = 'Hauptteil') {
|
|
return { title, guidance_notes: '', items: [] }
|
|
}
|
|
|
|
function exerciseRow() {
|
|
return {
|
|
item_type: 'exercise',
|
|
exercise_id: '',
|
|
exercise_variant_id: '',
|
|
exercise_title: '',
|
|
variants: [],
|
|
planned_duration_min: '',
|
|
actual_duration_min: '',
|
|
notes: '',
|
|
modifications: ''
|
|
}
|
|
}
|
|
|
|
function noteRow() {
|
|
return { item_type: 'note', note_body: '' }
|
|
}
|
|
|
|
function normalizeUnitToForm(fullUnit) {
|
|
if (fullUnit.sections && fullUnit.sections.length) {
|
|
return fullUnit.sections.map((sec) => ({
|
|
title: sec.title,
|
|
guidance_notes: sec.guidance_notes || '',
|
|
items: (sec.items || []).map((it) => {
|
|
if (it.item_type === 'note') {
|
|
return { item_type: 'note', note_body: it.note_body || '' }
|
|
}
|
|
return {
|
|
item_type: 'exercise',
|
|
exercise_id: it.exercise_id,
|
|
exercise_variant_id: it.exercise_variant_id ?? '',
|
|
exercise_title: it.exercise_title || '',
|
|
variants: [],
|
|
planned_duration_min:
|
|
it.planned_duration_min !== null && it.planned_duration_min !== undefined
|
|
? String(it.planned_duration_min)
|
|
: '',
|
|
actual_duration_min:
|
|
it.actual_duration_min !== null && it.actual_duration_min !== undefined
|
|
? String(it.actual_duration_min)
|
|
: '',
|
|
notes: it.notes ?? '',
|
|
modifications: it.modifications ?? ''
|
|
}
|
|
})
|
|
}))
|
|
}
|
|
if (fullUnit.exercises && fullUnit.exercises.length) {
|
|
return [
|
|
{
|
|
title: 'Übungen',
|
|
guidance_notes: '',
|
|
items: fullUnit.exercises.map((ex) => ({
|
|
item_type: 'exercise',
|
|
exercise_id: ex.exercise_id,
|
|
exercise_variant_id: ex.exercise_variant_id ?? '',
|
|
exercise_title: ex.exercise_title || '',
|
|
variants: [],
|
|
planned_duration_min:
|
|
ex.planned_duration_min !== null && ex.planned_duration_min !== undefined
|
|
? String(ex.planned_duration_min)
|
|
: '',
|
|
actual_duration_min:
|
|
ex.actual_duration_min !== null && ex.actual_duration_min !== undefined
|
|
? String(ex.actual_duration_min)
|
|
: '',
|
|
notes: ex.notes ?? '',
|
|
modifications: ex.modifications ?? ''
|
|
}))
|
|
}
|
|
]
|
|
}
|
|
return [defaultSection()]
|
|
}
|
|
|
|
/** Lädt Varianten/Titel nach, wenn Einheit vom Server ohne variants[] im Client-State ist. */
|
|
async function enrichSectionsWithVariants(sections) {
|
|
if (!sections?.length) return sections
|
|
const ids = []
|
|
for (const sec of sections) {
|
|
for (const it of sec.items || []) {
|
|
if (it.item_type === 'note') continue
|
|
if (it.exercise_id) ids.push(it.exercise_id)
|
|
}
|
|
}
|
|
const unique = [...new Set(ids)]
|
|
const cache = new Map()
|
|
await Promise.all(
|
|
unique.map(async (id) => {
|
|
try {
|
|
const ex = await api.getExercise(id)
|
|
cache.set(id, {
|
|
title: ex.title || '',
|
|
variants: Array.isArray(ex.variants) ? ex.variants : [],
|
|
})
|
|
} catch {
|
|
cache.set(id, { title: '', variants: [] })
|
|
}
|
|
})
|
|
)
|
|
return sections.map((sec) => ({
|
|
...sec,
|
|
items: (sec.items || []).map((it) => {
|
|
if (it.item_type === 'note') return it
|
|
if (!it.exercise_id) return it
|
|
const c = cache.get(it.exercise_id)
|
|
if (!c) return it
|
|
return {
|
|
...it,
|
|
exercise_title: it.exercise_title || c.title,
|
|
variants:
|
|
Array.isArray(it.variants) && it.variants.length > 0 ? it.variants : c.variants,
|
|
}
|
|
}),
|
|
}))
|
|
}
|
|
|
|
function parseMin(v) {
|
|
if (v === '' || v === null || v === undefined) return null
|
|
const n = parseInt(String(v), 10)
|
|
return Number.isFinite(n) ? n : null
|
|
}
|
|
|
|
function buildSectionsPayload(sections) {
|
|
return sections.map((sec, si) => ({
|
|
order_index: si,
|
|
title: (sec.title || '').trim() || 'Abschnitt',
|
|
guidance_notes: sec.guidance_notes?.trim() ? sec.guidance_notes.trim() : null,
|
|
items: (sec.items || [])
|
|
.map((it, ii) => {
|
|
if (it.item_type === 'note') {
|
|
return {
|
|
item_type: 'note',
|
|
order_index: ii,
|
|
note_body: it.note_body ?? ''
|
|
}
|
|
}
|
|
if (it.exercise_id === '' || it.exercise_id == null || Number.isNaN(Number(it.exercise_id))) {
|
|
return null
|
|
}
|
|
const vid = it.exercise_variant_id
|
|
return {
|
|
item_type: 'exercise',
|
|
order_index: ii,
|
|
exercise_id: parseInt(it.exercise_id, 10),
|
|
exercise_variant_id:
|
|
vid !== '' && vid != null && !Number.isNaN(Number(vid)) ? parseInt(vid, 10) : null,
|
|
planned_duration_min: parseMin(it.planned_duration_min),
|
|
actual_duration_min: parseMin(it.actual_duration_min),
|
|
notes: it.notes?.trim() ? it.notes.trim() : null,
|
|
modifications: it.modifications?.trim() ? it.modifications.trim() : null
|
|
}
|
|
})
|
|
.filter(Boolean)
|
|
}))
|
|
}
|
|
|
|
function sectionPlannedMinutes(sec) {
|
|
return (sec.items || []).reduce((sum, it) => {
|
|
if (it.item_type !== 'exercise') return sum
|
|
const m = parseMin(it.planned_duration_min)
|
|
return sum + (m || 0)
|
|
}, 0)
|
|
}
|
|
|
|
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 [planningPeekExerciseId, setPlanningPeekExerciseId] = 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 [startDate, setStartDate] = useState(today)
|
|
const [endDate, setEndDate] = useState(thirtyDaysLater)
|
|
|
|
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()]
|
|
})
|
|
|
|
useEffect(() => {
|
|
loadData()
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (selectedGroupId) {
|
|
loadUnits()
|
|
}
|
|
}, [selectedGroupId, startDate, endDate])
|
|
|
|
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 = async () => {
|
|
if (!selectedGroupId) return
|
|
try {
|
|
const unitsData = await api.listTrainingUnits({
|
|
group_id: selectedGroupId,
|
|
start_date: startDate,
|
|
end_date: endDate
|
|
})
|
|
setUnits(unitsData)
|
|
} catch (err) {
|
|
console.error('Failed to load units:', err)
|
|
}
|
|
}
|
|
|
|
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')]
|
|
})
|
|
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,
|
|
})
|
|
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 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.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) => ({ ...prev, [field]: value }))
|
|
}
|
|
|
|
const updateSectionField = (sIdx, field, value) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
sections: prev.sections.map((s, i) => (i === sIdx ? { ...s, [field]: value } : s))
|
|
}))
|
|
}
|
|
|
|
const addSection = () => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
sections: [...prev.sections, defaultSection(`Abschnitt ${prev.sections.length + 1}`)]
|
|
}))
|
|
}
|
|
|
|
const removeSection = (sIdx) => {
|
|
setFormData((prev) => {
|
|
const next = prev.sections.filter((_, i) => i !== sIdx)
|
|
return { ...prev, sections: next.length ? next : [defaultSection()] }
|
|
})
|
|
}
|
|
|
|
const moveSection = (sIdx, dir) => {
|
|
setFormData((prev) => {
|
|
const ta = sIdx + dir
|
|
if (ta < 0 || ta >= prev.sections.length) return prev
|
|
const copy = [...prev.sections]
|
|
;[copy[sIdx], copy[ta]] = [copy[ta], copy[sIdx]]
|
|
return { ...prev, sections: copy }
|
|
})
|
|
}
|
|
|
|
const addItem = (sIdx, kind) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
sections: prev.sections.map((s, i) => {
|
|
if (i !== sIdx) return s
|
|
const row = kind === 'note' ? noteRow() : exerciseRow()
|
|
return { ...s, items: [...s.items, row] }
|
|
})
|
|
}))
|
|
}
|
|
|
|
const updateItem = (sIdx, iIdx, field, value) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
sections: prev.sections.map((s, si) => {
|
|
if (si !== sIdx) return s
|
|
return {
|
|
...s,
|
|
items: s.items.map((it, ii) => {
|
|
if (ii !== iIdx) return it
|
|
const next = { ...it, [field]: value }
|
|
if (field === 'exercise_id') {
|
|
next.exercise_variant_id = ''
|
|
next.exercise_title = ''
|
|
next.variants = []
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
})
|
|
}))
|
|
}
|
|
|
|
const removeItem = (sIdx, iIdx) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
sections: prev.sections.map((s, i) =>
|
|
i !== sIdx ? s : { ...s, items: s.items.filter((_, j) => j !== iIdx) }
|
|
)
|
|
}))
|
|
}
|
|
|
|
const moveItem = (sIdx, iIdx, dir) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
sections: prev.sections.map((s, si) => {
|
|
if (si !== sIdx) return s
|
|
const items = [...s.items]
|
|
const ta = iIdx + dir
|
|
if (ta < 0 || ta >= items.length) return s
|
|
;[items[iIdx], items[ta]] = [items[ta], items[iIdx]]
|
|
return { ...s, items }
|
|
})
|
|
}))
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
|
<div className="spinner"></div>
|
|
<p>Laden...</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const selectedGroup = groups.find((g) => g.id === parseInt(selectedGroupId, 10))
|
|
|
|
return (
|
|
<div style={{ padding: '2rem' }}>
|
|
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
|
<h1 style={{ marginBottom: '0.35rem' }}>Trainingsplanung</h1>
|
|
<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>
|
|
|
|
<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>
|
|
|
|
{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>
|
|
</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>
|
|
) : 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) => (
|
|
<div key={unit.id} className="card">
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'start',
|
|
marginBottom: '1rem'
|
|
}}
|
|
>
|
|
<div>
|
|
<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>
|
|
{unit.planned_focus && (
|
|
<p
|
|
style={{
|
|
color: 'var(--text2)',
|
|
fontSize: '0.875rem',
|
|
marginBottom: '0.5rem'
|
|
}}
|
|
>
|
|
Fokus: {unit.planned_focus}
|
|
</p>
|
|
)}
|
|
<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' }}>
|
|
<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>
|
|
<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>
|
|
)}
|
|
|
|
{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: '2rem',
|
|
maxWidth: '960px',
|
|
width: '100%',
|
|
maxHeight: '92vh',
|
|
overflowY: 'auto',
|
|
margin: '1rem'
|
|
}}
|
|
>
|
|
<h2 style={{ marginBottom: '1rem' }}>
|
|
{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
|
|
</h2>
|
|
|
|
{!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
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: '1fr 1fr 1fr',
|
|
gap: '1rem',
|
|
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
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginTop: '2rem',
|
|
marginBottom: '0.75rem',
|
|
flexWrap: 'wrap',
|
|
gap: '0.5rem'
|
|
}}
|
|
>
|
|
<h3 style={{ margin: 0 }}>Abschnitte & Übungen</h3>
|
|
<button type="button" className="btn btn-secondary" onClick={handleSaveAsTemplate}>
|
|
Vorlage aus Aufbau speichern
|
|
</button>
|
|
</div>
|
|
|
|
{formData.sections.map((sec, sIdx) => {
|
|
const planMin = sectionPlannedMinutes(sec)
|
|
return (
|
|
<div
|
|
key={`sec-${sIdx}`}
|
|
style={{
|
|
marginBottom: '1.25rem',
|
|
padding: '1rem',
|
|
background: 'var(--surface2)',
|
|
borderRadius: '10px',
|
|
border: '1px solid var(--border, rgba(0,0,0,0.08))'
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
|
<input
|
|
className="form-input"
|
|
style={{ flex: '2 1 220px', marginBottom: 0 }}
|
|
value={sec.title}
|
|
onChange={(e) => updateSectionField(sIdx, 'title', e.target.value)}
|
|
placeholder="Abschnittstitel (z. B. Aufwärmen)"
|
|
/>
|
|
<div style={{ display: 'flex', gap: '4px', alignSelf: 'center' }}>
|
|
<button
|
|
type="button"
|
|
aria-label="Abschnitt hoch"
|
|
onClick={() => moveSection(sIdx, -1)}
|
|
disabled={sIdx === 0}
|
|
style={{ padding: '4px 10px', opacity: sIdx === 0 ? 0.35 : 1 }}
|
|
>
|
|
▲
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="Abschnitt runter"
|
|
onClick={() => moveSection(sIdx, 1)}
|
|
disabled={sIdx === formData.sections.length - 1}
|
|
style={{
|
|
padding: '4px 10px',
|
|
opacity: sIdx === formData.sections.length - 1 ? 0.35 : 1
|
|
}}
|
|
>
|
|
▼
|
|
</button>
|
|
</div>
|
|
<button type="button" className="btn" onClick={() => removeSection(sIdx)}>
|
|
Abschnitt entfernen
|
|
</button>
|
|
</div>
|
|
<textarea
|
|
className="form-input"
|
|
rows={2}
|
|
value={sec.guidance_notes}
|
|
onChange={(e) => updateSectionField(sIdx, 'guidance_notes', e.target.value)}
|
|
placeholder="Hinweise zum Abschnitt (Aufbau, Zielrichtung der Gruppe, Material …)"
|
|
/>
|
|
{planMin > 0 && (
|
|
<p style={{ fontSize: '0.8rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
|
Geplant in diesem Abschnitt: ca. {planMin} Min. (Übungen)
|
|
</p>
|
|
)}
|
|
|
|
{(sec.items || []).map((it, iIdx) =>
|
|
it.item_type === 'note' ? (
|
|
<div key={`note-${sIdx}-${iIdx}`} style={{ marginTop: '0.75rem' }}>
|
|
<div style={{ fontSize: '0.78rem', color: 'var(--text2)', marginBottom: '4px' }}>
|
|
Zwischen-Anmerkung
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '6px', alignItems: 'start' }}>
|
|
<div style={{ display: 'flex', flexDirection: 'column', paddingTop: '4px' }}>
|
|
<button
|
|
type="button"
|
|
onClick={() => moveItem(sIdx, iIdx, -1)}
|
|
disabled={iIdx === 0}
|
|
style={{ padding: '2px', opacity: iIdx === 0 ? 0.3 : 1 }}
|
|
>
|
|
▲
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => moveItem(sIdx, iIdx, 1)}
|
|
disabled={iIdx === sec.items.length - 1}
|
|
style={{
|
|
padding: '2px',
|
|
opacity: iIdx === sec.items.length - 1 ? 0.3 : 1
|
|
}}
|
|
>
|
|
▼
|
|
</button>
|
|
</div>
|
|
<textarea
|
|
className="form-input"
|
|
rows={2}
|
|
style={{ flex: 1 }}
|
|
value={it.note_body}
|
|
onChange={(e) => updateItem(sIdx, iIdx, 'note_body', e.target.value)}
|
|
placeholder="Hinweise (Gruppen teilen, Hallenführung, Auf- und Abbau …)"
|
|
/>
|
|
<button
|
|
type="button"
|
|
style={{ background: 'var(--danger)', color: 'white', border: 'none' }}
|
|
onClick={() => removeItem(sIdx, iIdx)}
|
|
>
|
|
✗
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div
|
|
key={`ex-${sIdx}-${iIdx}`}
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: '32px minmax(0,1fr) 88px auto',
|
|
gap: '6px',
|
|
alignItems: 'start',
|
|
marginTop: '0.75rem',
|
|
paddingTop: '0.5rem',
|
|
borderTop: '1px solid rgba(0,0,0,0.06)'
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', flexDirection: 'column', paddingTop: '6px' }}>
|
|
<button
|
|
type="button"
|
|
onClick={() => moveItem(sIdx, iIdx, -1)}
|
|
disabled={iIdx === 0}
|
|
style={{ padding: '2px', opacity: iIdx === 0 ? 0.3 : 1 }}
|
|
>
|
|
▲
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => moveItem(sIdx, iIdx, 1)}
|
|
disabled={iIdx === sec.items.length - 1}
|
|
style={{
|
|
padding: '2px',
|
|
opacity: iIdx === sec.items.length - 1 ? 0.3 : 1
|
|
}}
|
|
>
|
|
▼
|
|
</button>
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', minWidth: 0 }}>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
gap: '8px',
|
|
flexWrap: 'wrap',
|
|
alignItems: 'center'
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
style={{ margin: 0, whiteSpace: 'nowrap' }}
|
|
onClick={() => {
|
|
setExercisePickerTarget({ sIdx, iIdx })
|
|
setExercisePickerOpen(true)
|
|
}}
|
|
>
|
|
Übung suchen…
|
|
</button>
|
|
{it.exercise_id ? (
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
style={{ margin: 0, whiteSpace: 'nowrap', fontSize: '0.8rem', padding: '6px 10px' }}
|
|
onClick={() => setPlanningPeekExerciseId(it.exercise_id)}
|
|
>
|
|
Katalog kurz zeigen
|
|
</button>
|
|
) : null}
|
|
{(it.exercise_title || it.exercise_id) && (
|
|
<span
|
|
style={{
|
|
fontSize: '0.875rem',
|
|
flex: 1,
|
|
minWidth: 0,
|
|
wordBreak: 'break-word'
|
|
}}
|
|
title={
|
|
it.exercise_title ||
|
|
(it.exercise_id ? `Übung #${it.exercise_id}` : '')
|
|
}
|
|
>
|
|
{it.exercise_title ||
|
|
(it.exercise_id ? `Übung #${it.exercise_id}` : '')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{(() => {
|
|
const variantOpts = Array.isArray(it.variants) ? it.variants : []
|
|
return (
|
|
<select
|
|
className="form-input"
|
|
value={
|
|
it.exercise_variant_id === '' || it.exercise_variant_id == null
|
|
? ''
|
|
: String(it.exercise_variant_id)
|
|
}
|
|
onChange={(e) => {
|
|
const raw = e.target.value
|
|
updateItem(
|
|
sIdx,
|
|
iIdx,
|
|
'exercise_variant_id',
|
|
raw === '' ? '' : parseInt(raw, 10)
|
|
)
|
|
}}
|
|
disabled={!it.exercise_id || variantOpts.length === 0}
|
|
style={{ margin: 0, fontSize: '0.875rem' }}
|
|
>
|
|
<option value="">
|
|
{variantOpts.length === 0
|
|
? 'Keine Varianten'
|
|
: 'Stammübung'}
|
|
</option>
|
|
{variantOpts.map((v) => (
|
|
<option key={v.id} value={v.id}>
|
|
{v.variant_name || `Variante #${v.id}`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)
|
|
})()}
|
|
<textarea
|
|
className="form-input"
|
|
rows={editingUnit ? 2 : 1}
|
|
value={it.notes || ''}
|
|
onChange={(e) => updateItem(sIdx, iIdx, 'notes', e.target.value)}
|
|
placeholder="Kurze Anmerkung zur Übung"
|
|
style={{ fontSize: '0.875rem' }}
|
|
/>
|
|
</div>
|
|
<input
|
|
type="number"
|
|
className="form-input"
|
|
min={1}
|
|
value={it.planned_duration_min}
|
|
onChange={(e) =>
|
|
updateItem(sIdx, iIdx, 'planned_duration_min', e.target.value)
|
|
}
|
|
placeholder="min"
|
|
title="Geplante Dauer (Minuten)"
|
|
style={{ margin: 0 }}
|
|
/>
|
|
<button
|
|
type="button"
|
|
style={{
|
|
padding: '0.5rem',
|
|
background: 'var(--danger)',
|
|
color: 'white',
|
|
border: 'none'
|
|
}}
|
|
onClick={() => removeItem(sIdx, iIdx)}
|
|
>
|
|
✗
|
|
</button>
|
|
{editingUnit && (
|
|
<label
|
|
className="form-label"
|
|
style={{
|
|
gridColumn: '2 / -1',
|
|
marginTop: 4,
|
|
display: 'block',
|
|
fontSize: '0.8rem'
|
|
}}
|
|
>
|
|
Ist-Dauer / Anpassungen
|
|
<input
|
|
type="number"
|
|
className="form-input"
|
|
min={1}
|
|
value={it.actual_duration_min}
|
|
onChange={(e) =>
|
|
updateItem(sIdx, iIdx, 'actual_duration_min', e.target.value)
|
|
}
|
|
placeholder="IST min"
|
|
style={{ maxWidth: '120px' }}
|
|
/>
|
|
<textarea
|
|
className="form-input"
|
|
rows={2}
|
|
value={it.modifications || ''}
|
|
onChange={(e) =>
|
|
updateItem(sIdx, iIdx, 'modifications', e.target.value)
|
|
}
|
|
placeholder="Abweichungen beim Durchführen"
|
|
style={{ marginTop: '6px', fontSize: '0.875rem' }}
|
|
/>
|
|
</label>
|
|
)}
|
|
</div>
|
|
)
|
|
)}
|
|
|
|
<div style={{ marginTop: '0.85rem', display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
|
<button type="button" className="btn btn-secondary" onClick={() => addItem(sIdx, 'exercise')}>
|
|
+ Übung
|
|
</button>
|
|
<button type="button" className="btn btn-secondary" onClick={() => addItem(sIdx, 'note')}>
|
|
+ Anmerkung
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={addSection} style={{ marginBottom: '1.75rem' }}>
|
|
+ Abschnitt hinzufügen
|
|
</button>
|
|
|
|
{editingUnit && (
|
|
<>
|
|
<h3 style={{ marginTop: '0.5rem', marginBottom: '1rem' }}>Durchführung</h3>
|
|
|
|
<div
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: '1fr 1fr 1fr 1fr',
|
|
gap: '1rem',
|
|
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}
|
|
onClose={() => {
|
|
setExercisePickerOpen(false)
|
|
setExercisePickerTarget(null)
|
|
}}
|
|
onSelectExercise={(ex) => {
|
|
if (!exercisePickerTarget) return
|
|
const { sIdx, iIdx } = exercisePickerTarget
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
sections: prev.sections.map((s, si) =>
|
|
si !== sIdx
|
|
? s
|
|
: {
|
|
...s,
|
|
items: s.items.map((row, ii) =>
|
|
ii !== iIdx
|
|
? row
|
|
: row.item_type !== 'exercise'
|
|
? row
|
|
: {
|
|
...row,
|
|
exercise_id: ex.id,
|
|
exercise_variant_id: '',
|
|
exercise_title: ex.title || '',
|
|
variants: Array.isArray(ex.variants) ? ex.variants : [],
|
|
}
|
|
)
|
|
}
|
|
)
|
|
}))
|
|
setExercisePickerOpen(false)
|
|
setExercisePickerTarget(null)
|
|
}}
|
|
/>
|
|
<ExercisePeekModal
|
|
open={planningPeekExerciseId != null}
|
|
exerciseId={planningPeekExerciseId}
|
|
onClose={() => setPlanningPeekExerciseId(null)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default TrainingPlanningPage
|