diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx
new file mode 100644
index 0000000..8525252
--- /dev/null
+++ b/frontend/src/components/ExercisePickerModal.jsx
@@ -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 (
+
{
+ if (e.target === e.currentTarget) onClose()
+ }}
+ >
+
e.stopPropagation()}
+ >
+
+
Übung auswählen
+
+
+
+
+
+
+
+ setSearchInput(e.target.value)}
+ autoComplete="off"
+ />
+
+
+
+ setAiSearchInput(e.target.value)}
+ autoComplete="off"
+ />
+
+
+
+
+ {loading && Suche läuft…}
+
+
+ {filterOpen && (
+
+
+ Zwischen den Bereichen gilt UND, innerhalb ODER wie in der Übungsübersicht.
+
+
+
+
+ setFilters((f) => ({ ...f, focus_area_ids: v }))}
+ options={focusOptions}
+ placeholder="Fokus …"
+ />
+
+
+
+ setFilters((f) => ({ ...f, style_direction_ids: v }))}
+ options={styleOptions}
+ placeholder="Stilrichtung …"
+ />
+
+
+
+ setFilters((f) => ({ ...f, training_type_ids: v }))}
+ options={trainingTypeOptions}
+ placeholder="Trainingsstil …"
+ />
+
+
+
+ setFilters((f) => ({ ...f, target_group_ids: v }))}
+ options={targetGroupOptions}
+ placeholder="Zielgruppe …"
+ />
+
+
+
+
+
setFilters((f) => ({ ...f, skill_ids: v }))}
+ options={skillOptions}
+ placeholder="Fähigkeit …"
+ />
+
+
+
+
+
+
+
+
+ setFilters((f) => ({ ...f, visibility_any: v }))}
+ options={visibilityOptions}
+ placeholder="Sichtbarkeit …"
+ />
+
+
+
+ setFilters((f) => ({ ...f, status_any: v }))}
+ options={statusOptions}
+ placeholder="Status …"
+ />
+
+
+
+ )}
+
+
+
+
+ {!catalogsReady || (loading && list.length === 0) ? (
+
+ ) : list.length === 0 ? (
+
Keine Treffer.
+ ) : (
+ <>
+
+ {list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''}
+
+
+ {list.map((ex) => (
+ -
+
+
+ ))}
+
+ {hasMore && (
+
+
+
+ )}
+ >
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx
index 7b968c7..601f9a4 100644
--- a/frontend/src/pages/TrainingPlanningPage.jsx
+++ b/frontend/src/pages/TrainingPlanningPage.jsx
@@ -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() {
-
)}
+ {
+ 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)
+ }}
+ />
)
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index af9bf2b..02496ca 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -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()
}
// ============================================================================