shinkan-jinkendo/frontend/src/pages/ExercisesPage.jsx
Lars c7cda03201
Some checks failed
Deploy Development / deploy (push) Successful in 41s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 1m57s
feat: Admin-managed exercise catalogs + frontend integration
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
2026-04-23 07:52:03 +02:00

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