Backend (already committed): - Migration 007: focus_areas, training_styles, training_characters, skill_categories tables - routers/catalogs.py: 20 CRUD endpoints for all catalogs - routers/exercises.py: Updated to support new FK fields - Trainer focus area assignment for role-based filtering Frontend (new): - AdminCatalogsPage: Comprehensive admin UI with 5 tabs - Focus Areas (with color + icon) - Training Styles (hierarchical with parent_style_id) - Training Characters - Skill Categories (hierarchical) - Trainer Assignments (trainer → focus area mapping) - ExercisesPage: Updated to use catalog dropdowns - Focus area dropdown now loads from API - Added missing Training Style dropdown - Training character dropdown now loads from API - Uses IDs instead of hard-coded text values - App.jsx: Added /admin/catalogs route - api.js: Added all catalog endpoints All form fields standardized: labels on top, full width, left-aligned Ready for testing via /admin/catalogs
609 lines
22 KiB
JavaScript
609 lines
22 KiB
JavaScript
import React, { useState, useEffect } from 'react'
|
|
import api from '../utils/api'
|
|
|
|
function ExercisesPage() {
|
|
const [exercises, setExercises] = useState([])
|
|
const [skills, setSkills] = useState([])
|
|
const [focusAreas, setFocusAreas] = useState([])
|
|
const [trainingStyles, setTrainingStyles] = useState([])
|
|
const [trainingCharacters, setTrainingCharacters] = useState([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [showModal, setShowModal] = useState(false)
|
|
const [editingExercise, setEditingExercise] = useState(null)
|
|
const [filters, setFilters] = useState({
|
|
focus_area: '',
|
|
visibility: '',
|
|
status: ''
|
|
})
|
|
|
|
// Form state
|
|
const [formData, setFormData] = useState({
|
|
title: '',
|
|
summary: '',
|
|
goal: '',
|
|
execution: '',
|
|
preparation: '',
|
|
trainer_notes: '',
|
|
equipment: [],
|
|
duration_min: '',
|
|
duration_max: '',
|
|
group_size_min: '',
|
|
group_size_max: '',
|
|
age_groups: [],
|
|
focus_area: '',
|
|
focus_area_id: null,
|
|
secondary_areas: [],
|
|
training_style_id: null,
|
|
training_character: '',
|
|
training_character_id: null,
|
|
visibility: 'private',
|
|
status: 'draft',
|
|
skills: []
|
|
})
|
|
|
|
useEffect(() => {
|
|
loadData()
|
|
}, [filters])
|
|
|
|
const loadData = async () => {
|
|
try {
|
|
const [exercisesData, skillsData, focusAreasData, stylesData, charactersData] = await Promise.all([
|
|
api.listExercises(filters),
|
|
api.listSkills(),
|
|
api.listFocusAreas(),
|
|
api.listTrainingStyles(),
|
|
api.listTrainingCharacters()
|
|
])
|
|
setExercises(exercisesData)
|
|
setSkills(skillsData)
|
|
setFocusAreas(focusAreasData)
|
|
setTrainingStyles(stylesData)
|
|
setTrainingCharacters(charactersData)
|
|
} catch (err) {
|
|
console.error('Failed to load data:', err)
|
|
alert('Fehler beim Laden: ' + err.message)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleCreate = () => {
|
|
setEditingExercise(null)
|
|
setFormData({
|
|
title: '',
|
|
summary: '',
|
|
goal: '',
|
|
execution: '',
|
|
preparation: '',
|
|
trainer_notes: '',
|
|
equipment: [],
|
|
duration_min: '',
|
|
duration_max: '',
|
|
group_size_min: '',
|
|
group_size_max: '',
|
|
age_groups: [],
|
|
focus_area: '',
|
|
focus_area_id: null,
|
|
secondary_areas: [],
|
|
training_style_id: null,
|
|
training_character: '',
|
|
training_character_id: null,
|
|
visibility: 'private',
|
|
status: 'draft',
|
|
skills: []
|
|
})
|
|
setShowModal(true)
|
|
}
|
|
|
|
const handleEdit = (exercise) => {
|
|
setEditingExercise(exercise)
|
|
setFormData({
|
|
title: exercise.title || '',
|
|
summary: exercise.summary || '',
|
|
goal: exercise.goal || '',
|
|
execution: exercise.execution || '',
|
|
preparation: exercise.preparation || '',
|
|
trainer_notes: exercise.trainer_notes || '',
|
|
equipment: exercise.equipment || [],
|
|
duration_min: exercise.duration_min || '',
|
|
duration_max: exercise.duration_max || '',
|
|
group_size_min: exercise.group_size_min || '',
|
|
group_size_max: exercise.group_size_max || '',
|
|
age_groups: exercise.age_groups || [],
|
|
focus_area: exercise.focus_area || '',
|
|
focus_area_id: exercise.focus_area_id || null,
|
|
secondary_areas: exercise.secondary_areas || [],
|
|
training_style_id: exercise.training_style_id || null,
|
|
training_character: exercise.training_character || '',
|
|
training_character_id: exercise.training_character_id || null,
|
|
visibility: exercise.visibility || 'private',
|
|
status: exercise.status || 'draft',
|
|
skills: exercise.skills?.map(s => ({
|
|
skill_id: s.skill_id,
|
|
is_primary: s.is_primary || false,
|
|
intensity: s.intensity || null,
|
|
development_contribution: s.development_contribution || null,
|
|
required_level: s.required_level || null,
|
|
target_level: s.target_level || null
|
|
})) || []
|
|
})
|
|
setShowModal(true)
|
|
}
|
|
|
|
const handleDelete = async (exercise) => {
|
|
if (!confirm(`Übung "${exercise.title}" wirklich löschen?`)) return
|
|
|
|
try {
|
|
await api.deleteExercise(exercise.id)
|
|
await loadData()
|
|
} catch (err) {
|
|
alert('Fehler beim Löschen: ' + err.message)
|
|
}
|
|
}
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault()
|
|
|
|
if (!formData.title || !formData.goal || !formData.execution) {
|
|
alert('Titel, Ziel und Durchführung sind Pflichtfelder')
|
|
return
|
|
}
|
|
|
|
try {
|
|
if (editingExercise) {
|
|
await api.updateExercise(editingExercise.id, formData)
|
|
} else {
|
|
await api.createExercise(formData)
|
|
}
|
|
setShowModal(false)
|
|
await loadData()
|
|
} catch (err) {
|
|
alert('Fehler beim Speichern: ' + err.message)
|
|
}
|
|
}
|
|
|
|
const updateFormField = (field, value) => {
|
|
setFormData(prev => ({ ...prev, [field]: value }))
|
|
}
|
|
|
|
const toggleSkill = (skillId) => {
|
|
const existing = formData.skills.find(s => s.skill_id === skillId)
|
|
if (existing) {
|
|
updateFormField('skills', formData.skills.filter(s => s.skill_id !== skillId))
|
|
} else {
|
|
updateFormField('skills', [...formData.skills, {
|
|
skill_id: skillId,
|
|
is_primary: false,
|
|
intensity: null,
|
|
development_contribution: null,
|
|
required_level: null,
|
|
target_level: null
|
|
}])
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
|
<div className="spinner"></div>
|
|
<p>Laden...</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div style={{ padding: '2rem' }}>
|
|
<div style={{ maxWidth: '1200px', margin: '0 auto' }}>
|
|
{/* Header */}
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
|
<h1>Übungen</h1>
|
|
<button className="btn btn-primary" onClick={handleCreate}>
|
|
+ Neue Übung
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<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">Fokusbereich</label>
|
|
<select
|
|
className="form-input"
|
|
value={filters.focus_area}
|
|
onChange={(e) => setFilters({ ...filters, focus_area: e.target.value })}
|
|
>
|
|
<option value="">Alle</option>
|
|
{focusAreas.map(fa => (
|
|
<option key={fa.id} value={fa.id}>
|
|
{fa.icon} {fa.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="form-label">Sichtbarkeit</label>
|
|
<select
|
|
className="form-input"
|
|
value={filters.visibility}
|
|
onChange={(e) => setFilters({ ...filters, visibility: e.target.value })}
|
|
>
|
|
<option value="">Alle</option>
|
|
<option value="private">Privat</option>
|
|
<option value="club">Verein</option>
|
|
<option value="official">Offiziell</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="form-label">Status</label>
|
|
<select
|
|
className="form-input"
|
|
value={filters.status}
|
|
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
|
>
|
|
<option value="">Alle</option>
|
|
<option value="draft">Entwurf</option>
|
|
<option value="in_review">In Prüfung</option>
|
|
<option value="approved">Freigegeben</option>
|
|
<option value="archived">Archiviert</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Exercises Grid */}
|
|
{exercises.length === 0 ? (
|
|
<div className="card">
|
|
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
|
Keine Übungen gefunden. Lege jetzt deine erste Übung an!
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
|
gap: '1rem'
|
|
}}>
|
|
{exercises.map(exercise => (
|
|
<div key={exercise.id} className="card">
|
|
<div style={{ marginBottom: '1rem' }}>
|
|
<h3 style={{ marginBottom: '0.5rem' }}>{exercise.title}</h3>
|
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
<span style={{
|
|
fontSize: '0.75rem',
|
|
padding: '0.25rem 0.5rem',
|
|
borderRadius: '4px',
|
|
background: 'var(--surface2)',
|
|
color: 'var(--text2)'
|
|
}}>
|
|
{exercise.focus_area || 'Ohne Fokus'}
|
|
</span>
|
|
<span style={{
|
|
fontSize: '0.75rem',
|
|
padding: '0.25rem 0.5rem',
|
|
borderRadius: '4px',
|
|
background: exercise.visibility === 'official' ? 'var(--accent)' : 'var(--surface2)',
|
|
color: exercise.visibility === 'official' ? 'white' : 'var(--text2)'
|
|
}}>
|
|
{exercise.visibility}
|
|
</span>
|
|
<span style={{
|
|
fontSize: '0.75rem',
|
|
padding: '0.25rem 0.5rem',
|
|
borderRadius: '4px',
|
|
background: exercise.status === 'approved' ? '#2ea44f' : 'var(--surface2)',
|
|
color: exercise.status === 'approved' ? 'white' : 'var(--text2)'
|
|
}}>
|
|
{exercise.status}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{exercise.summary && (
|
|
<p style={{ color: 'var(--text2)', fontSize: '0.875rem', marginBottom: '1rem' }}>
|
|
{exercise.summary}
|
|
</p>
|
|
)}
|
|
|
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
|
|
<button
|
|
className="btn btn-secondary"
|
|
style={{ flex: 1 }}
|
|
onClick={() => handleEdit(exercise)}
|
|
>
|
|
Bearbeiten
|
|
</button>
|
|
<button
|
|
className="btn"
|
|
style={{
|
|
background: 'var(--danger)',
|
|
color: 'white',
|
|
border: 'none'
|
|
}}
|
|
onClick={() => handleDelete(exercise)}
|
|
>
|
|
Löschen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Create/Edit Modal */}
|
|
{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'
|
|
}}>
|
|
<div style={{
|
|
background: 'var(--surface)',
|
|
borderRadius: '12px',
|
|
padding: '2rem',
|
|
maxWidth: '600px',
|
|
width: '100%',
|
|
maxHeight: '90vh',
|
|
overflowY: 'auto'
|
|
}}>
|
|
<h2 style={{ marginBottom: '1.5rem' }}>
|
|
{editingExercise ? 'Übung bearbeiten' : 'Neue Übung'}
|
|
</h2>
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="form-row">
|
|
<label className="form-label">Titel *</label>
|
|
<input
|
|
type="text"
|
|
className="form-input"
|
|
value={formData.title}
|
|
onChange={(e) => updateFormField('title', e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Kurzbeschreibung</label>
|
|
<textarea
|
|
className="form-input"
|
|
rows={2}
|
|
value={formData.summary}
|
|
onChange={(e) => updateFormField('summary', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Ziel der Übung *</label>
|
|
<textarea
|
|
className="form-input"
|
|
rows={3}
|
|
value={formData.goal}
|
|
onChange={(e) => updateFormField('goal', e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Durchführung *</label>
|
|
<textarea
|
|
className="form-input"
|
|
rows={4}
|
|
value={formData.execution}
|
|
onChange={(e) => updateFormField('execution', e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Vorbereitung / Aufbau</label>
|
|
<textarea
|
|
className="form-input"
|
|
rows={3}
|
|
value={formData.preparation}
|
|
onChange={(e) => updateFormField('preparation', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Hinweise für Trainer</label>
|
|
<textarea
|
|
className="form-input"
|
|
rows={2}
|
|
value={formData.trainer_notes}
|
|
onChange={(e) => updateFormField('trainer_notes', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
|
<div className="form-row">
|
|
<label className="form-label">Dauer Min (Minuten)</label>
|
|
<input
|
|
type="number"
|
|
className="form-input"
|
|
value={formData.duration_min}
|
|
onChange={(e) => updateFormField('duration_min', e.target.value ? parseInt(e.target.value) : '')}
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Dauer Max (Minuten)</label>
|
|
<input
|
|
type="number"
|
|
className="form-input"
|
|
value={formData.duration_max}
|
|
onChange={(e) => updateFormField('duration_max', e.target.value ? parseInt(e.target.value) : '')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
|
<div className="form-row">
|
|
<label className="form-label">Gruppengröße Min</label>
|
|
<input
|
|
type="number"
|
|
className="form-input"
|
|
value={formData.group_size_min}
|
|
onChange={(e) => updateFormField('group_size_min', e.target.value ? parseInt(e.target.value) : '')}
|
|
/>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Gruppengröße Max</label>
|
|
<input
|
|
type="number"
|
|
className="form-input"
|
|
value={formData.group_size_max}
|
|
onChange={(e) => updateFormField('group_size_max', e.target.value ? parseInt(e.target.value) : '')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Fokusbereich</label>
|
|
<select
|
|
className="form-input"
|
|
value={formData.focus_area_id || ''}
|
|
onChange={(e) => updateFormField('focus_area_id', e.target.value ? parseInt(e.target.value) : null)}
|
|
>
|
|
<option value="">Bitte wählen</option>
|
|
{focusAreas.map(fa => (
|
|
<option key={fa.id} value={fa.id}>
|
|
{fa.icon} {fa.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Trainingsstil</label>
|
|
<select
|
|
className="form-input"
|
|
value={formData.training_style_id || ''}
|
|
onChange={(e) => updateFormField('training_style_id', e.target.value ? parseInt(e.target.value) : null)}
|
|
>
|
|
<option value="">Bitte wählen</option>
|
|
{trainingStyles.map(ts => (
|
|
<option key={ts.id} value={ts.id}>
|
|
{ts.name}
|
|
{ts.parent_style_name ? ` (${ts.parent_style_name})` : ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="form-row">
|
|
<label className="form-label">Trainingscharakter</label>
|
|
<select
|
|
className="form-input"
|
|
value={formData.training_character_id || ''}
|
|
onChange={(e) => updateFormField('training_character_id', e.target.value ? parseInt(e.target.value) : null)}
|
|
>
|
|
<option value="">Bitte wählen</option>
|
|
{trainingCharacters.map(tc => (
|
|
<option key={tc.id} value={tc.id}>
|
|
{tc.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
|
<div className="form-row">
|
|
<label className="form-label">Sichtbarkeit</label>
|
|
<select
|
|
className="form-input"
|
|
value={formData.visibility}
|
|
onChange={(e) => updateFormField('visibility', e.target.value)}
|
|
>
|
|
<option value="private">Privat</option>
|
|
<option value="club">Verein</option>
|
|
<option value="official">Offiziell</option>
|
|
</select>
|
|
</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="draft">Entwurf</option>
|
|
<option value="in_review">In Prüfung</option>
|
|
<option value="approved">Freigegeben</option>
|
|
<option value="archived">Archiviert</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Skills Selection */}
|
|
<div className="form-row">
|
|
<label className="form-label">Fähigkeiten</label>
|
|
<div style={{
|
|
maxHeight: '200px',
|
|
overflowY: 'auto',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: '8px',
|
|
padding: '0.5rem'
|
|
}}>
|
|
{skills.map(skill => (
|
|
<label
|
|
key={skill.id}
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
padding: '0.5rem',
|
|
cursor: 'pointer',
|
|
borderRadius: '4px',
|
|
transition: 'background 0.2s'
|
|
}}
|
|
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--surface2)'}
|
|
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.skills.some(s => s.skill_id === skill.id)}
|
|
onChange={() => toggleSkill(skill.id)}
|
|
style={{ marginRight: '0.5rem' }}
|
|
/>
|
|
<span style={{ fontSize: '0.875rem' }}>
|
|
{skill.name} <span style={{ color: 'var(--text2)' }}>({skill.category})</span>
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Buttons */}
|
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1.5rem' }}>
|
|
<button type="submit" className="btn btn-primary" style={{ flex: 1 }}>
|
|
{editingExercise ? 'Speichern' : 'Erstellen'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
onClick={() => setShowModal(false)}
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default ExercisesPage
|