/** * Ü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, splitMnCatalogRules, splitScalarCatalogRules, } from '../constants/exerciseListFilters' import MultiSelectCombo from './MultiSelectCombo' import ExerciseFocusRulePicker from './ExerciseFocusRulePicker' import CatalogRulePicker from './CatalogRulePicker' const PAGE_SIZE = 100 const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS } /** Stub-Ziel für API-Validator (mind. Ziel oder Durchführung); Nutzer ergänzt Details in der Übungsbearbeitung. */ const QUICK_CREATE_GOAL_PLACEHOLDER = 'Aus der Trainingsplanung angelegt — bitte Ziel und Durchführung in der Übungsbearbeitung ergänzen.' export default function ExercisePickerModal({ open, onClose, onSelectExercise, multiSelect = false, onSelectExercises = null, enableQuickCreateDraft = false, /** Wenn gesetzt: z. B. ['simple'] oder ['combination'] — sonst alle Übungsarten */ exerciseKindAny = undefined, }) { 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 [quickOpen, setQuickOpen] = useState(false) const [quickTitle, setQuickTitle] = useState('') const [quickSummary, setQuickSummary] = useState('') const [quickSaving, setQuickSaving] = useState(false) 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([]) setQuickOpen(false) setQuickTitle('') setQuickSummary('') setQuickSaving(false) 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 fMn = splitMnCatalogRules(filters.focus_rules) if (fMn.includeIds.length) q.focus_area_must_include_ids = fMn.includeIds if (fMn.excludeIds.length) q.focus_area_must_exclude_ids = fMn.excludeIds 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 sdMn = splitMnCatalogRules(filters.style_direction_rules) if (sdMn.includeIds.length) q.style_direction_must_include_ids = sdMn.includeIds if (sdMn.excludeIds.length) q.style_direction_must_exclude_ids = sdMn.excludeIds const sdLegacy = ids(filters.style_direction_ids) if (sdLegacy?.length) q.style_direction_ids = sdLegacy const ttMn = splitMnCatalogRules(filters.training_type_rules) if (ttMn.includeIds.length) q.training_type_must_include_ids = ttMn.includeIds if (ttMn.excludeIds.length) q.training_type_must_exclude_ids = ttMn.excludeIds const ttLegacy = ids(filters.training_type_ids) if (ttLegacy?.length) q.training_type_ids = ttLegacy const tgMn = splitMnCatalogRules(filters.target_group_rules) if (tgMn.includeIds.length) q.target_group_must_include_ids = tgMn.includeIds if (tgMn.excludeIds.length) q.target_group_must_exclude_ids = tgMn.excludeIds const tgLegacy = ids(filters.target_group_ids) if (tgLegacy?.length) q.target_group_ids = tgLegacy const visMn = splitScalarCatalogRules(filters.visibility_rules) if (visMn.includeVals.length) q.visibility_any = visMn.includeVals if (visMn.excludeVals.length) q.visibility_exclude_any = visMn.excludeVals const stMn = splitScalarCatalogRules(filters.status_rules) if (stMn.includeVals.length) q.status_any = stMn.includeVals if (stMn.excludeVals.length) q.status_exclude_any = stMn.excludeVals 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.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 if ( Array.isArray(exerciseKindAny) && exerciseKindAny.length > 0 ) { q.exercise_kind_any = exerciseKindAny } return q }, [filters, debouncedSearch, debouncedAi, exerciseKindAny]) 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 }) const submitQuickCreate = async () => { const title = (quickTitle || '').trim() if (title.length < 3) { alert('Titel: mindestens 3 Zeichen.') return } const summaryRaw = (quickSummary || '').trim() setQuickSaving(true) try { const created = await api.createExercise({ title, summary: summaryRaw || null, goal: QUICK_CREATE_GOAL_PLACEHOLDER, execution: null, visibility: 'private', status: 'draft', equipment: [], focus_areas_multi: [], training_styles_multi: [], training_types_multi: [], target_groups_multi: [], age_groups: [], skills: [], club_id: null, }) if (!created?.id) { throw new Error('Anlegen fehlgeschlagen') } if (multiSelect && typeof onSelectExercises === 'function') { await Promise.resolve(onSelectExercises([created])) } else if (typeof onSelectExercise === 'function') { await Promise.resolve(onSelectExercise(created)) } onClose() } catch (e) { console.error(e) alert(e.message || 'Übung konnte nicht angelegt werden') } finally { setQuickSaving(false) } } if (!open) return null return (
Wird mit Sichtbarkeit privat und Status Entwurf gespeichert und erscheint auf dem Dashboard zum Weiterbearbeiten. Nach dem Speichern wird die Übung direkt in den Ablauf übernommen.
Felder gelten mit UND. Kataloge: mehrere „+“ = alle zutreffend; „−“ schließt aus. Sichtbarkeit/Status: mehrere „+“ = eine davon (ODER); „−“ blendet aus.
Hinweis: Für die Planung werden archivierte Übungen bei der Suche immer mit eingeschlossen (bestehende Zuordnungen).
Keine Treffer.
) : ( <>{list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''}