shinkan-jinkendo/frontend/src/pages/TrainerContextsPage.jsx
Lars 5e2820c63c
Some checks failed
Deploy Development / deploy (push) Successful in 33s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 5s
Test Suite / playwright-tests (push) Failing after 1m55s
feat: Trainer-Kontext-System & Exercise Training Characters (v0.5.0)
Migration 012:
- exercise_training_characters (M:N junction table)
- trainer_contexts (Fokussierte Trainer-Ansichten)
- Indizes für Performance
- Example seed data

Backend (catalogs.py):
- GET /api/trainer-contexts (list own contexts)
- POST /api/trainer-contexts (create context)
- PUT /api/trainer-contexts/{id} (update own context)
- DELETE /api/trainer-contexts/{id} (delete own context)
- Enriched responses mit focus_area_name, style_direction_name, training_type_name
- Ownership-Validation (nur eigene Kontexte)

Frontend:
- TrainerContextsPage.jsx (vollständige CRUD-UI)
- Kaskadierende Dropdowns (Fokusbereich → Stilrichtung)
- is_style_independent Flag für stilunabhängige Kontexte
- api.js erweitert (listTrainerContexts, create, update, delete)

Architektur:
- Flat Catalogs mit M:N überall
- NULL = 'für alles geeignet'
- Trainer-Kontexte für fokussierte Ansichten
- Vorbereitung für 1000+ Übungen mit flexibler KI-Filterung

version: 0.5.0 (backend + frontend)
module: exercises 0.5.0, catalogs 1.5.0
page: TrainerContextsPage 1.0.0

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-23 14:22:17 +02:00

388 lines
13 KiB
JavaScript

import { useState, useEffect } from 'react'
import { api } from '../utils/api'
export default function TrainerContextsPage() {
const [contexts, setContexts] = useState([])
const [focusAreas, setFocusAreas] = useState([])
const [styleDirections, setStyleDirections] = useState([])
const [trainingTypes, setTrainingTypes] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
// Edit/Create state
const [editingContext, setEditingContext] = useState(null)
const [newContext, setNewContext] = useState({
name: '',
focus_area_id: null,
style_direction_id: null,
training_type_id: null,
is_style_independent: false,
description: '',
is_active: true
})
useEffect(() => {
loadData()
}, [])
async function loadData() {
setLoading(true)
setError('')
try {
const [ctx, fa, sd, tt] = await Promise.all([
api.listTrainerContexts(),
api.listFocusAreas(),
api.listStyleDirections(),
api.listTrainingTypes()
])
setContexts(ctx)
setFocusAreas(fa)
setStyleDirections(sd)
setTrainingTypes(tt)
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
async function createContext() {
if (!newContext.name) {
setError('Name ist Pflichtfeld')
return
}
try {
await api.createTrainerContext(newContext)
setNewContext({
name: '',
focus_area_id: null,
style_direction_id: null,
training_type_id: null,
is_style_independent: false,
description: '',
is_active: true
})
loadData()
} catch (e) {
setError(e.message)
}
}
async function updateContext(id, data) {
try {
await api.updateTrainerContext(id, data)
setEditingContext(null)
loadData()
} catch (e) {
setError(e.message)
}
}
async function deleteContext(id) {
if (!confirm('Trainer-Kontext wirklich löschen?')) return
try {
await api.deleteTrainerContext(id)
loadData()
} catch (e) {
setError(e.message)
}
}
// Filter style directions by selected focus area
function getFilteredStyleDirections(focusAreaId) {
if (!focusAreaId) return []
return styleDirections.filter(sd => sd.focus_area_id === parseInt(focusAreaId))
}
return (
<div style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
<h1>Meine Trainer-Bereiche</h1>
<p style={{ color: 'var(--text2)', marginBottom: '32px' }}>
Definiere deine Tätigkeitsbereiche für fokussierte Ansichten und Filter.
</p>
{error && (
<div style={{
padding: '16px',
marginBottom: '24px',
background: 'var(--danger)',
color: 'white',
borderRadius: '8px'
}}>
{error}
</div>
)}
{/* Create New Context */}
<div className="card" style={{ marginBottom: '32px' }}>
<h3>Neuer Trainer-Bereich</h3>
<div className="form-row">
<label className="form-label">Name *</label>
<input
className="form-input"
value={newContext.name}
onChange={e => setNewContext({ ...newContext, name: e.target.value })}
placeholder="z.B. Karate Goju-Ryu Breitensport"
/>
</div>
<div className="form-row">
<label className="form-label">Fokusbereich</label>
<select
className="form-input"
value={newContext.focus_area_id || ''}
onChange={e => setNewContext({
...newContext,
focus_area_id: e.target.value ? parseInt(e.target.value) : null,
style_direction_id: null // Reset style when focus changes
})}
>
<option value="">- Alle Fokusbereiche -</option>
{focusAreas.map(fa => (
<option key={fa.id} value={fa.id}>{fa.icon} {fa.name}</option>
))}
</select>
</div>
{newContext.focus_area_id && (
<div className="form-row">
<label className="form-label">Stilrichtung</label>
<select
className="form-input"
value={newContext.style_direction_id || ''}
onChange={e => setNewContext({
...newContext,
style_direction_id: e.target.value ? parseInt(e.target.value) : null
})}
>
<option value="">- Alle Stilrichtungen -</option>
{getFilteredStyleDirections(newContext.focus_area_id).map(sd => (
<option key={sd.id} value={sd.id}>{sd.name}</option>
))}
</select>
</div>
)}
<div className="form-row">
<label className="form-label">Trainingsstil</label>
<select
className="form-input"
value={newContext.training_type_id || ''}
onChange={e => setNewContext({
...newContext,
training_type_id: e.target.value ? parseInt(e.target.value) : null
})}
>
<option value="">- Alle Trainingsstile -</option>
{trainingTypes.map(tt => (
<option key={tt.id} value={tt.id}>{tt.name}</option>
))}
</select>
</div>
<div className="form-row">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={newContext.is_style_independent}
onChange={e => setNewContext({ ...newContext, is_style_independent: e.target.checked })}
/>
Stilrichtungsunabhängig (z.B. Leistungssport Kumite für alle Stilrichtungen)
</label>
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
value={newContext.description}
onChange={e => setNewContext({ ...newContext, description: e.target.value })}
rows={2}
placeholder="Optionale Beschreibung..."
/>
</div>
<button className="btn btn-primary" onClick={createContext}>Anlegen</button>
</div>
{/* List of Contexts */}
<div style={{ display: 'grid', gap: '16px' }}>
{loading && <p>Laden...</p>}
{!loading && contexts.length === 0 && (
<div className="card">
<p style={{ margin: 0, color: 'var(--text2)' }}>
Noch keine Trainer-Bereiche definiert. Lege oben deinen ersten Bereich an.
</p>
</div>
)}
{contexts.map(ctx => (
<div key={ctx.id} className="card">
{editingContext?.id === ctx.id ? (
// Edit Mode
<div>
<div className="form-row">
<label className="form-label">Name</label>
<input
className="form-input"
value={editingContext.name}
onChange={e => setEditingContext({ ...editingContext, name: e.target.value })}
/>
</div>
<div className="form-row">
<label className="form-label">Fokusbereich</label>
<select
className="form-input"
value={editingContext.focus_area_id || ''}
onChange={e => setEditingContext({
...editingContext,
focus_area_id: e.target.value ? parseInt(e.target.value) : null,
style_direction_id: null
})}
>
<option value="">- Alle -</option>
{focusAreas.map(fa => (
<option key={fa.id} value={fa.id}>{fa.name}</option>
))}
</select>
</div>
{editingContext.focus_area_id && (
<div className="form-row">
<label className="form-label">Stilrichtung</label>
<select
className="form-input"
value={editingContext.style_direction_id || ''}
onChange={e => setEditingContext({
...editingContext,
style_direction_id: e.target.value ? parseInt(e.target.value) : null
})}
>
<option value="">- Alle -</option>
{getFilteredStyleDirections(editingContext.focus_area_id).map(sd => (
<option key={sd.id} value={sd.id}>{sd.name}</option>
))}
</select>
</div>
)}
<div className="form-row">
<label className="form-label">Trainingsstil</label>
<select
className="form-input"
value={editingContext.training_type_id || ''}
onChange={e => setEditingContext({
...editingContext,
training_type_id: e.target.value ? parseInt(e.target.value) : null
})}
>
<option value="">- Alle -</option>
{trainingTypes.map(tt => (
<option key={tt.id} value={tt.id}>{tt.name}</option>
))}
</select>
</div>
<div className="form-row">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={editingContext.is_style_independent}
onChange={e => setEditingContext({ ...editingContext, is_style_independent: e.target.checked })}
/>
Stilrichtungsunabhängig
</label>
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
value={editingContext.description || ''}
onChange={e => setEditingContext({ ...editingContext, description: e.target.value })}
rows={2}
/>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-primary" onClick={() => updateContext(ctx.id, editingContext)}>
Speichern
</button>
<button className="btn" onClick={() => setEditingContext(null)}>
Abbrechen
</button>
</div>
</div>
) : (
// Display Mode
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h3 style={{ margin: '0 0 8px 0' }}>
{ctx.name}
{!ctx.is_active && (
<span style={{ marginLeft: '8px', fontSize: '14px', color: 'var(--text3)' }}>
(inaktiv)
</span>
)}
</h3>
<div style={{ fontSize: '14px', color: 'var(--text2)', marginBottom: '4px' }}>
{ctx.focus_area_name && (
<span>
{ctx.focus_area_icon} {ctx.focus_area_name}
</span>
)}
{!ctx.focus_area_name && <span>Alle Fokusbereiche</span>}
{ctx.style_direction_name && (
<span> {ctx.style_direction_name}</span>
)}
{ctx.focus_area_name && !ctx.style_direction_name && !ctx.is_style_independent && (
<span> Alle Stilrichtungen</span>
)}
{ctx.is_style_independent && (
<span style={{ color: 'var(--accent)' }}> Stilunabhängig</span>
)}
{ctx.training_type_name && (
<span> {ctx.training_type_name}</span>
)}
</div>
{ctx.description && (
<p style={{ fontSize: '13px', color: 'var(--text3)', margin: '8px 0 0 0' }}>
{ctx.description}
</p>
)}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
className="btn"
onClick={() => setEditingContext(ctx)}
style={{ padding: '4px 12px', fontSize: '14px' }}
>
Bearbeiten
</button>
<button
className="btn"
onClick={() => deleteContext(ctx.id)}
style={{ padding: '4px 12px', fontSize: '14px' }}
>
Löschen
</button>
</div>
</div>
</div>
)}
</div>
))}
</div>
</div>
)
}