feat: enhance training planning page with exercise variant support
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 2m3s

- Introduced ExercisePickerModal for selecting exercises and their variants.
- Updated state management to handle exercise titles and variants in training units.
- Implemented enrichSectionsWithVariants function to fetch and integrate exercise details from the server.
- Improved loading logic for training units to include exercise variant data, enhancing user experience in planning sessions.
This commit is contained in:
Lars 2026-04-29 06:15:26 +02:00
parent 69b26fc928
commit 131b7d591d
3 changed files with 595 additions and 40 deletions

View File

@ -0,0 +1,441 @@
/**
* Übungssuche mit Volltext-, KI-/Semantikfeld (aktuell gleiche Engine wie Suche) und erweiterten Filtern.
* Paginierung bis max. 100 Treffer pro Request (API-Limit).
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import api from '../utils/api'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import MultiSelectCombo from './MultiSelectCombo'
const PAGE_SIZE = 100
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
const INITIAL_FILTERS = {
focus_area_ids: [],
style_direction_ids: [],
training_type_ids: [],
target_group_ids: [],
skill_ids: [],
skill_min_level: '',
skill_max_level: '',
visibility_any: [],
status_any: [],
}
export default function ExercisePickerModal({ open, onClose, onSelectExercise }) {
const [catalogs, setCatalogs] = useState({
focusAreas: [],
styleDirections: [],
trainingTypes: [],
targetGroups: [],
skills: [],
})
const [catalogsReady, setCatalogsReady] = useState(false)
const [searchInput, setSearchInput] = useState('')
const [aiSearchInput, setAiSearchInput] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [debouncedAi, setDebouncedAi] = useState('')
const [filters, setFilters] = useState(() => ({ ...INITIAL_FILTERS }))
const [filterOpen, setFilterOpen] = useState(false)
const [list, setList] = useState([])
const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(false)
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 350)
return () => clearTimeout(t)
}, [searchInput])
useEffect(() => {
const t = setTimeout(() => setDebouncedAi(aiSearchInput.trim()), 350)
return () => clearTimeout(t)
}, [aiSearchInput])
useEffect(() => {
if (!open) return
let cancelled = false
;(async () => {
try {
const [fa, sd, tt, tg, sk] = await Promise.all([
api.listFocusAreas(),
api.listStyleDirections(),
api.listTrainingTypes(),
api.listTargetGroups(),
api.listSkills(),
])
if (!cancelled) {
setCatalogs({
focusAreas: fa,
styleDirections: sd,
trainingTypes: tt,
targetGroups: tg,
skills: sk,
})
setCatalogsReady(true)
}
} catch (e) {
console.error(e)
if (!cancelled) setCatalogsReady(true)
}
})()
return () => {
cancelled = true
}
}, [open])
useEffect(() => {
if (!open) {
setSearchInput('')
setAiSearchInput('')
setDebouncedSearch('')
setDebouncedAi('')
setFilters({ ...INITIAL_FILTERS })
setFilterOpen(false)
setList([])
setOffset(0)
setHasMore(false)
}
}, [open])
const focusOptions = useMemo(
() => catalogs.focusAreas.map((fa) => ({ id: fa.id, label: `${fa.icon || ''} ${fa.name || ''}`.trim() })),
[catalogs.focusAreas]
)
const styleOptions = useMemo(
() => catalogs.styleDirections.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
[catalogs.styleDirections]
)
const trainingTypeOptions = useMemo(
() => catalogs.trainingTypes.map((t) => ({ id: t.id, label: t.name || String(t.id) })),
[catalogs.trainingTypes]
)
const targetGroupOptions = useMemo(
() => catalogs.targetGroups.map((g) => ({ id: g.id, label: g.name || String(g.id) })),
[catalogs.targetGroups]
)
const skillOptions = useMemo(
() => catalogs.skills.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
[catalogs.skills]
)
const visibilityOptions = useMemo(
() => [
{ id: 'private', label: 'Privat' },
{ id: 'club', label: 'Verein' },
{ id: 'official', label: 'Offiziell' },
],
[]
)
const statusOptions = useMemo(
() => [
{ id: 'draft', label: 'Entwurf' },
{ id: 'in_review', label: 'In Prüfung' },
{ id: 'approved', label: 'Freigegeben' },
{ id: 'archived', label: 'Archiviert' },
],
[]
)
const queryBase = useMemo(() => {
const q = {}
const n = (v) => (v === '' || v == null ? undefined : Number(v))
const ids = (arr) =>
Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined
const fa = ids(filters.focus_area_ids)
if (fa?.length) q.focus_area_ids = fa
const sd = ids(filters.style_direction_ids)
if (sd?.length) q.style_direction_ids = sd
const tt = ids(filters.training_type_ids)
if (tt?.length) q.training_type_ids = tt
const tg = ids(filters.target_group_ids)
if (tg?.length) q.target_group_ids = tg
const sk = ids(filters.skill_ids)
if (sk?.length) q.skill_ids = sk
if (filters.skill_min_level) q.skill_min_level = n(filters.skill_min_level)
if (filters.skill_max_level) q.skill_max_level = n(filters.skill_max_level)
if (filters.visibility_any?.length) q.visibility_any = [...filters.visibility_any]
if (filters.status_any?.length) q.status_any = [...filters.status_any]
if (debouncedSearch) q.search = debouncedSearch
if (debouncedAi) q.ai_search = debouncedAi
return q
}, [filters, debouncedSearch, debouncedAi])
const reload = useCallback(async () => {
if (!open || !catalogsReady) return
setLoading(true)
setOffset(0)
try {
const batch = await api.listExercises({
...queryBase,
include_variants: true,
limit: PAGE_SIZE,
offset: 0,
})
setList(Array.isArray(batch) ? batch : [])
setHasMore(batch?.length === PAGE_SIZE)
setOffset(batch?.length ?? 0)
} catch (e) {
console.error(e)
alert(e.message || 'Laden fehlgeschlagen')
setList([])
setHasMore(false)
} finally {
setLoading(false)
}
}, [open, catalogsReady, queryBase])
useEffect(() => {
reload()
}, [reload])
const loadMore = async () => {
if (!hasMore || loadingMore || loading) return
setLoadingMore(true)
try {
const batch = await api.listExercises({
...queryBase,
include_variants: true,
limit: PAGE_SIZE,
offset,
})
setList((prev) => [...prev, ...(Array.isArray(batch) ? batch : [])])
setHasMore(batch?.length === PAGE_SIZE)
setOffset((o) => o + (batch?.length ?? 0))
} catch (e) {
console.error(e)
alert(e.message || 'Mehr laden fehlgeschlagen')
} finally {
setLoadingMore(false)
}
}
const resetFilters = () => setFilters({ ...INITIAL_FILTERS })
if (!open) return null
return (
<div
className="admin-modal-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
<div
className="admin-modal-sheet"
role="dialog"
aria-modal="true"
style={{ maxWidth: '920px', width: '100%', maxHeight: '92vh', display: 'flex', flexDirection: 'column' }}
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 className="admin-modal-sheet__title">Übung auswählen</h3>
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
Schließen
</button>
</div>
<div style={{ padding: '0 1rem 0.75rem', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
<div style={{ display: 'grid', gap: '0.65rem' }}>
<div>
<label className="form-label">Volltextsuche</label>
<input
type="search"
className="form-input"
placeholder="Stichwort, Titelfragment…"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
autoComplete="off"
/>
</div>
<div>
<label className="form-label">
Semantisch /{' '}
<span title="aktuell gleiche Datenbanksuche wie Volltext; später KI-Verfeinerung möglich">KI-Feld</span>
</label>
<input
type="search"
className="form-input"
placeholder="zweites Suchkonzept oder Umschreibung…"
value={aiSearchInput}
onChange={(e) => setAiSearchInput(e.target.value)}
autoComplete="off"
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'center' }}>
<button type="button" className="btn btn-secondary" onClick={() => setFilterOpen(!filterOpen)}>
{filterOpen ? 'Filter ausblenden' : 'Erweiterte Filter'}
</button>
<button type="button" className="btn" onClick={resetFilters}>
Filter zurücksetzen
</button>
{loading && <span style={{ fontSize: '13px', color: 'var(--text2)' }}>Suche läuft</span>}
</div>
{filterOpen && (
<div style={{ marginTop: '0.35rem', fontSize: '13px', color: 'var(--text2)' }}>
<p style={{ margin: '0 0 12px 0' }}>
Zwischen den Bereichen gilt <strong>UND</strong>, innerhalb ODER wie in der Übungsübersicht.
</p>
<div className="exercise-filters-modal-grid">
<div>
<label className="form-label">Fokus</label>
<MultiSelectCombo
value={filters.focus_area_ids}
onChange={(v) => setFilters((f) => ({ ...f, focus_area_ids: v }))}
options={focusOptions}
placeholder="Fokus …"
/>
</div>
<div>
<label className="form-label">Stilrichtung</label>
<MultiSelectCombo
value={filters.style_direction_ids}
onChange={(v) => setFilters((f) => ({ ...f, style_direction_ids: v }))}
options={styleOptions}
placeholder="Stilrichtung …"
/>
</div>
<div>
<label className="form-label">Trainingsstil</label>
<MultiSelectCombo
value={filters.training_type_ids}
onChange={(v) => setFilters((f) => ({ ...f, training_type_ids: v }))}
options={trainingTypeOptions}
placeholder="Trainingsstil …"
/>
</div>
<div>
<label className="form-label">Zielgruppe</label>
<MultiSelectCombo
value={filters.target_group_ids}
onChange={(v) => setFilters((f) => ({ ...f, target_group_ids: v }))}
options={targetGroupOptions}
placeholder="Zielgruppe …"
/>
</div>
</div>
<div style={{ marginTop: 12 }}>
<label className="form-label">Fähigkeit</label>
<MultiSelectCombo
value={filters.skill_ids}
onChange={(v) => setFilters((f) => ({ ...f, skill_ids: v }))}
options={skillOptions}
placeholder="Fähigkeit …"
/>
<div className="exercise-filter-skill-levels-row" style={{ marginTop: 8 }}>
<select
className="form-input exercise-filter-level-select"
title="von"
value={filters.skill_min_level}
onChange={(e) => setFilters((f) => ({ ...f, skill_min_level: e.target.value }))}
>
<option value="">Stufe von</option>
{LEVEL_FILTER_OPTS.map((o) => (
<option key={o.value} value={String(o.level)}>
{o.level}
</option>
))}
</select>
<select
className="form-input exercise-filter-level-select"
title="bis"
value={filters.skill_max_level}
onChange={(e) => setFilters((f) => ({ ...f, skill_max_level: e.target.value }))}
>
<option value="">Stufe bis</option>
{LEVEL_FILTER_OPTS.map((o) => (
<option key={`m-${o.value}`} value={String(o.level)}>
{o.level}
</option>
))}
</select>
</div>
</div>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two" style={{ marginTop: 12 }}>
<div>
<label className="form-label">Sichtbarkeit</label>
<MultiSelectCombo
value={filters.visibility_any}
onChange={(v) => setFilters((f) => ({ ...f, visibility_any: v }))}
options={visibilityOptions}
placeholder="Sichtbarkeit …"
/>
</div>
<div>
<label className="form-label">Status</label>
<MultiSelectCombo
value={filters.status_any}
onChange={(v) => setFilters((f) => ({ ...f, status_any: v }))}
options={statusOptions}
placeholder="Status …"
/>
</div>
</div>
</div>
)}
</div>
</div>
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '12px 1rem' }}>
{!catalogsReady || (loading && list.length === 0) ? (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<div className="spinner" />
</div>
) : list.length === 0 ? (
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>Keine Treffer.</p>
) : (
<>
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: 10 }}>
{list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''}
</p>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{list.map((ex) => (
<li key={ex.id}>
<button
type="button"
onClick={() => {
onSelectExercise(ex)
onClose()
}}
style={{
width: '100%',
textAlign: 'left',
padding: '10px 12px',
marginBottom: 8,
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
cursor: 'pointer',
}}
>
<strong style={{ display: 'block' }}>{ex.title}</strong>
{(ex.summary || '').trim().length > 0 && (
<span style={{ fontSize: '12px', color: 'var(--text2)' }}>
{(ex.summary || '').length > 120 ? `${(ex.summary || '').slice(0, 120)}` : ex.summary}
</span>
)}
{ex.focus_area && (
<span className="exercise-tag exercise-tag--accent" style={{ marginTop: 6 }}>
{ex.focus_area}
</span>
)}
</button>
</li>
))}
</ul>
{hasMore && (
<div style={{ textAlign: 'center', marginTop: 12 }}>
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
{loadingMore ? 'Laden…' : 'Mehr laden'}
</button>
</div>
)}
</>
)}
</div>
</div>
</div>
)
}

View File

@ -2,6 +2,7 @@ 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'
function defaultSection(title = 'Hauptteil') {
return { title, guidance_notes: '', items: [] }
@ -12,6 +13,8 @@ function exerciseRow() {
item_type: 'exercise',
exercise_id: '',
exercise_variant_id: '',
exercise_title: '',
variants: [],
planned_duration_min: '',
actual_duration_min: '',
notes: '',
@ -36,6 +39,8 @@ function normalizeUnitToForm(fullUnit) {
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)
@ -59,6 +64,8 @@ function normalizeUnitToForm(fullUnit) {
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)
@ -76,6 +83,48 @@ function normalizeUnitToForm(fullUnit) {
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)
@ -129,13 +178,14 @@ function TrainingPlanningPage() {
const [groups, setGroups] = useState([])
const [selectedGroupId, setSelectedGroupId] = useState('')
const [units, setUnits] = useState([])
const [exercises, setExercises] = 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 today = new Date().toISOString().split('T')[0]
const thirtyDaysLater = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
@ -180,12 +230,8 @@ function TrainingPlanningPage() {
const loadData = async () => {
try {
const [groupsData, exercisesData] = await Promise.all([
api.listTrainingGroups({ status: 'active' }),
api.listExercises({ include_variants: true })
])
const groupsData = await api.listTrainingGroups({ status: 'active' })
setGroups(groupsData)
setExercises(exercisesData)
await loadPlanTemplates()
if (groupsData.length > 0) {
@ -291,6 +337,8 @@ function TrainingPlanningPage() {
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 || '',
@ -304,7 +352,7 @@ function TrainingPlanningPage() {
status: fullUnit.status || 'planned',
notes: fullUnit.notes || '',
trainer_notes: fullUnit.trainer_notes || '',
sections: normalizeUnitToForm(fullUnit)
sections,
})
setShowModal(true)
} catch (err) {
@ -437,7 +485,11 @@ function TrainingPlanningPage() {
items: s.items.map((it, ii) => {
if (ii !== iIdx) return it
const next = { ...it, [field]: value }
if (field === 'exercise_id') next.exercise_variant_id = ''
if (field === 'exercise_id') {
next.exercise_variant_id = ''
next.exercise_title = ''
next.variants = []
}
return next
})
}
@ -1017,32 +1069,45 @@ function TrainingPlanningPage() {
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', minWidth: 0 }}>
<select
className="form-input"
value={
it.exercise_id === '' || it.exercise_id == null ? '' : String(it.exercise_id)
}
onChange={(e) => {
const raw = e.target.value
updateItem(
sIdx,
iIdx,
'exercise_id',
raw === '' ? '' : parseInt(raw, 10)
)
<div
style={{
display: 'flex',
gap: '8px',
flexWrap: 'wrap',
alignItems: 'center'
}}
style={{ margin: 0 }}
>
<option value="">Übung wählen</option>
{exercises.map((exercise) => (
<option key={exercise.id} value={exercise.id}>
{exercise.title}
</option>
))}
</select>
<button
type="button"
className="btn btn-secondary"
style={{ margin: 0, whiteSpace: 'nowrap' }}
onClick={() => {
setExercisePickerTarget({ sIdx, iIdx })
setExercisePickerOpen(true)
}}
>
Übung suchen
</button>
{(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 picked = exercises.find((e) => e.id === it.exercise_id)
const variantOpts = Array.isArray(picked?.variants) ? picked.variants : []
const variantOpts = Array.isArray(it.variants) ? it.variants : []
return (
<select
className="form-input"
@ -1266,6 +1331,42 @@ function TrainingPlanningPage() {
</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)
}}
/>
</div>
</div>
)

View File

@ -23,17 +23,30 @@ async function request(endpoint, options = {}) {
headers['X-Auth-Token'] = token
}
const response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers
})
const url = `${API_URL}${endpoint}`
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }))
throw new Error(error.detail || `HTTP ${response.status}`)
try {
const response = await fetch(url, {
...options,
headers,
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }))
throw new Error(error.detail || `HTTP ${response.status}`)
}
return response.json()
} catch (e) {
if (e instanceof TypeError && (e.message === 'Failed to fetch' || e.message.includes('fetch'))) {
const hint =
API_URL && API_URL.length > 0
? `Verbindung zum API unter ${API_URL} fehlgeschlagen. Läuft das Backend (z. B. Port 8098) und ist CORS erlaubt?`
: 'Kein VITE_API_URL gesetzt: Anfragen gehen an die Frontend-URL und schlagen oft fehl. Setze in .env z. B. VITE_API_URL=http://localhost:8098 und starte Vite neu.'
throw new Error(hint)
}
throw e
}
return response.json()
}
// ============================================================================