/** * Ü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 { useAuth } from '../context/AuthContext' import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels' import { INITIAL_EXERCISE_LIST_FILTERS, mergeExerciseListPrefsFromApi, } from '../constants/exerciseListFilters' import MultiSelectCombo from './MultiSelectCombo' import ExerciseFocusRulePicker from './ExerciseFocusRulePicker' const PAGE_SIZE = 100 const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS } export default function ExercisePickerModal({ open, onClose, onSelectExercise, multiSelect = false, onSelectExercises = null, }) { const { user } = useAuth() 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) const [multiPicked, setMultiPicked] = useState([]) const toggleMultiPick = (ex) => { setMultiPicked((prev) => prev.some((p) => p.id === ex.id) ? prev.filter((p) => p.id !== ex.id) : [...prev, ex] ) } 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) setMultiPicked([]) return } setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs)) }, [open, user?.exercise_list_prefs]) 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 mustInc = [] const mustExc = [] for (const r of filters.focus_rules || []) { const id = Number(r.focus_area_id) if (!Number.isFinite(id) || id < 1) continue if (r.mode === 'forbid') mustExc.push(id) else mustInc.push(id) } const uniqNums = (arr) => [...new Set(arr)] if (mustInc.length) q.focus_area_must_include_ids = uniqNums(mustInc) if (mustExc.length) q.focus_area_must_exclude_ids = uniqNums(mustExc) if (filters.focus_only_without) q.focus_only_without_focus_areas = true 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 (filters.visibility_exclude_any?.length) q.visibility_exclude_any = [...filters.visibility_exclude_any] if (filters.status_exclude_any?.length) q.status_exclude_any = [...filters.status_exclude_any] if (filters.exclude_without_focus) q.exclude_without_focus = true if (filters.include_archived) q.include_archived = true 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_archived: true, 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_archived: true, 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()} >

{multiSelect ? 'Übungen auswählen' : 'Ü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. Fokus: „+ mit“ / „− ohne“ kombinierbar (mehrere „+“ = alle gesetzt). Sonstige Mehrfachauswahl pro Feld mit ODER.

setFilters((f) => ({ ...f, ...patch }))} />
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 …" />

Ausblenden

setFilters((f) => ({ ...f, visibility_exclude_any: v }))} options={visibilityOptions} placeholder="ausblenden …" />
setFilters((f) => ({ ...f, status_exclude_any: v }))} options={statusOptions} placeholder="ausblenden …" />

Hinweis: Für die Planung werden archivierte Übungen bei der Suche immer mit eingeschlossen (bestehende Zuordnungen).

)}
{!catalogsReady || (loading && list.length === 0) ? (
) : list.length === 0 ? (

Keine Treffer.

) : ( <>

{list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''}

    {list.map((ex) => { const picked = multiPicked.some((p) => p.id === ex.id) const rowInner = ( <> {ex.title} {(ex.summary || '').trim().length > 0 && ( {(ex.summary || '').length > 120 ? `${(ex.summary || '').slice(0, 120)}…` : ex.summary} )} {ex.focus_area && ( {ex.focus_area} )} ) if (multiSelect) { return (
  • ) } return (
  • ) })}
{hasMore && (
)} {multiSelect && typeof onSelectExercises === 'function' ? (
{multiPicked.length} ausgewählt
) : null} )}
) }