import React, { useState, useEffect, useMemo, useCallback } from 'react' import { Link } from 'react-router-dom' import api from '../utils/api' import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels' import MultiSelectCombo from '../components/MultiSelectCombo' import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel' 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: [], } function levelOptionShort(levelStr) { const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr)) return o ? String(o.level) : String(levelStr) } function ExercisesListPage() { const [exercises, setExercises] = useState([]) const [catalogs, setCatalogs] = useState({ focusAreas: [], styleDirections: [], trainingTypes: [], targetGroups: [], skills: [], }) const [catalogsReady, setCatalogsReady] = useState(false) const [listFetching, setListFetching] = useState(false) const [loadingMore, setLoadingMore] = useState(false) const [offset, setOffset] = useState(0) const [hasMore, setHasMore] = useState(false) const [searchInput, setSearchInput] = useState('') const [aiSearchInput, setAiSearchInput] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('') const [debouncedAiSearch, setDebouncedAiSearch] = useState('') const [filters, setFilters] = useState(() => ({ ...INITIAL_FILTERS })) const [filterModalOpen, setFilterModalOpen] = useState(false) const [pageTab, setPageTab] = useState('list') useEffect(() => { const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400) return () => clearTimeout(t) }, [searchInput]) useEffect(() => { const t = setTimeout(() => setDebouncedAiSearch(aiSearchInput.trim()), 400) return () => clearTimeout(t) }, [aiSearchInput]) useEffect(() => { if (!filterModalOpen) return const onKey = (e) => { if (e.key === 'Escape') setFilterModalOpen(false) } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [filterModalOpen]) 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 filterChips = useMemo(() => { const chips = [] ;(filters.focus_area_ids || []).forEach((id) => { const opt = focusOptions.find((o) => String(o.id) === String(id)) chips.push({ key: `fa-${id}`, label: `Fokus: ${opt?.label ?? id}`, onRemove: () => setFilters((prev) => ({ ...prev, focus_area_ids: prev.focus_area_ids.filter((x) => String(x) !== String(id)), })), }) }) ;(filters.style_direction_ids || []).forEach((id) => { const opt = styleOptions.find((o) => String(o.id) === String(id)) chips.push({ key: `sd-${id}`, label: `Stil: ${opt?.label ?? id}`, onRemove: () => setFilters((prev) => ({ ...prev, style_direction_ids: prev.style_direction_ids.filter((x) => String(x) !== String(id)), })), }) }) ;(filters.training_type_ids || []).forEach((id) => { const opt = trainingTypeOptions.find((o) => String(o.id) === String(id)) chips.push({ key: `tt-${id}`, label: `Trainingsstil: ${opt?.label ?? id}`, onRemove: () => setFilters((prev) => ({ ...prev, training_type_ids: prev.training_type_ids.filter((x) => String(x) !== String(id)), })), }) }) ;(filters.target_group_ids || []).forEach((id) => { const opt = targetGroupOptions.find((o) => String(o.id) === String(id)) chips.push({ key: `tg-${id}`, label: `Zielgruppe: ${opt?.label ?? id}`, onRemove: () => setFilters((prev) => ({ ...prev, target_group_ids: prev.target_group_ids.filter((x) => String(x) !== String(id)), })), }) }) ;(filters.skill_ids || []).forEach((id) => { const opt = skillOptions.find((o) => String(o.id) === String(id)) chips.push({ key: `sk-${id}`, label: `Fähigkeit: ${opt?.label ?? id}`, onRemove: () => setFilters((prev) => ({ ...prev, skill_ids: prev.skill_ids.filter((x) => String(x) !== String(id)), })), }) }) if (filters.skill_min_level || filters.skill_max_level) { const a = filters.skill_min_level ? levelOptionShort(filters.skill_min_level) : '…' const b = filters.skill_max_level ? levelOptionShort(filters.skill_max_level) : '…' chips.push({ key: 'skill-levels', label: `Stufe ${a}–${b}`, onRemove: () => setFilters((prev) => ({ ...prev, skill_min_level: '', skill_max_level: '', })), }) } ;(filters.visibility_any || []).forEach((id) => { const opt = visibilityOptions.find((o) => String(o.id) === String(id)) chips.push({ key: `vis-${id}`, label: `Sichtbarkeit: ${opt?.label ?? id}`, onRemove: () => setFilters((prev) => ({ ...prev, visibility_any: prev.visibility_any.filter((x) => String(x) !== String(id)), })), }) }) ;(filters.status_any || []).forEach((id) => { const opt = statusOptions.find((o) => String(o.id) === String(id)) chips.push({ key: `st-${id}`, label: `Status: ${opt?.label ?? id}`, onRemove: () => setFilters((prev) => ({ ...prev, status_any: prev.status_any.filter((x) => String(x) !== String(id)), })), }) }) return chips }, [ filters, focusOptions, styleOptions, trainingTypeOptions, targetGroupOptions, skillOptions, visibilityOptions, statusOptions, ]) /** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */ const searchTitleSuggestions = useMemo(() => { const titles = exercises.map((e) => (e.title || '').trim()).filter(Boolean) return [...new Set(titles)].slice(0, 80) }, [exercises]) 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 (debouncedAiSearch) q.ai_search = debouncedAiSearch return q }, [filters, debouncedSearch, debouncedAiSearch]) useEffect(() => { 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 (err) { if (!cancelled) { console.error(err) alert('Kataloge konnten nicht geladen werden: ' + err.message) setCatalogsReady(true) } } })() return () => { cancelled = true } }, []) useEffect(() => { if (!catalogsReady || pageTab !== 'list') return let cancelled = false const run = async () => { setListFetching(true) setOffset(0) try { const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset: 0 }) if (cancelled) return setExercises(batch) setHasMore(batch.length === PAGE_SIZE) setOffset(batch.length) } catch (err) { if (!cancelled) { console.error('Failed to load data:', err) alert('Fehler beim Laden: ' + err.message) } } finally { if (!cancelled) setListFetching(false) } } run() return () => { cancelled = true } }, [queryBase, catalogsReady, pageTab]) const loadMore = async () => { if (loadingMore || !hasMore) return setLoadingMore(true) try { const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset }) setExercises((prev) => [...prev, ...batch]) setHasMore(batch.length === PAGE_SIZE) setOffset((o) => o + batch.length) } catch (err) { alert('Fehler: ' + err.message) } finally { setLoadingMore(false) } } const handleDelete = async (exercise) => { if (!confirm(`Übung "${exercise.title}" wirklich löschen?`)) return try { await api.deleteExercise(exercise.id) setExercises((prev) => prev.filter((e) => e.id !== exercise.id)) } catch (err) { alert('Fehler beim Löschen: ' + err.message) } } const resetAllFilters = useCallback(() => setFilters({ ...INITIAL_FILTERS }), []) if (!catalogsReady && pageTab === 'list') { return (
Lade Kataloge…
Trefferliste aktualisiert sich kurz nach Eingabe. Titel der aktuellen Liste erscheinen als Vorschläge (Pfeil im Feld). Fachliche Filter über „Filter“ – zwischen Feldern UND, Auswahl mehrerer Werte je Feld mit ODER.
Zwischen den Bereichen gilt UND. Innerhalb eines Feldes werden mehrere Einträge mit{' '} ODER verknüpft.
Die Stufen filtern nach dem Niveau der Zuordnung Übung ↔ Fähigkeit (von–bis).
Lade Übungen…
Keine Übungen gefunden.
Aktualisiere Treffer…
) : null}{exercises.length} angezeigt {hasMore ? ' · es gibt weitere Einträge' : ''}
{exercise.summary.length > 160 ? `${exercise.summary.slice(0, 160)}…` : exercise.summary}
)}