import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react' import { Link } from 'react-router-dom' import { Eye, Pencil, Trash2, Globe, Users, Lock, CheckCircle2, Archive, CircleDot, FilePenLine, } from 'lucide-react' import api from '../utils/api' import { useAuth } from '../context/AuthContext' import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels' import MultiSelectCombo from '../components/MultiSelectCombo' import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker' import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel' import PageSectionNav from '../components/PageSectionNav' import { INITIAL_EXERCISE_LIST_FILTERS, mergeExerciseListPrefsFromApi, compactExerciseListPrefsPayload, } from '../constants/exerciseListFilters' import { sanitizeExerciseRichText, coerceApiNameList } from '../utils/sanitizeHtml' import { canUserRequestExerciseDelete } from '../utils/exercisePermissions' const PAGE_SIZE = 100 const BULK_MAX_IDS = 500 const EXERCISES_PAGE_TABS = [ { id: 'list', label: 'Liste' }, { id: 'progression', label: 'Progressionsgraphen' }, ] const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) const VIS_LABELS = { official: 'Global', club: 'Verein', private: 'Privat' } const STATUS_LABELS = { draft: 'Entwurf', in_review: 'In Prüfung', approved: 'Freigegeben', archived: 'Archiv', } function visibilityLabel(v) { return VIS_LABELS[v] || v || '—' } function statusLabel(s) { return STATUS_LABELS[s] || s || '—' } function exerciseFocusNames(ex) { const fromApi = coerceApiNameList(ex.focus_area_names) if (fromApi.length) return fromApi if (ex.focus_area) return [ex.focus_area] return [] } function exerciseCardClassName(exercise, userId) { const vis = exercise.visibility || 'private' const visKey = vis === 'official' || vis === 'club' || vis === 'private' ? vis : 'private' const mine = userId != null && Number(exercise.created_by) === Number(userId) return ['card', 'exercise-card', `exercise-card--scope-${visKey}`, mine ? 'exercise-card--mine' : ''] .filter(Boolean) .join(' ') } function ExerciseCardScopeStatus({ exercise }) { const v = exercise.visibility || 'private' const s = exercise.status || 'draft' const visLabel = visibilityLabel(v) const stLabel = statusLabel(s) const tip = `${visLabel} · ${stLabel}` let VisIcon = Lock if (v === 'official') VisIcon = Globe else if (v === 'club') VisIcon = Users let StatIcon = FilePenLine if (s === 'approved') StatIcon = CheckCircle2 else if (s === 'archived') StatIcon = Archive else if (s === 'in_review') StatIcon = CircleDot return (
·
) } 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 { user, checkAuth } = useAuth() const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' 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_EXERCISE_LIST_FILTERS })) const [filterModalOpen, setFilterModalOpen] = useState(false) const [savingExercisePrefs, setSavingExercisePrefs] = useState(false) const [pageTab, setPageTab] = useState('list') const prefsAppliedRef = useRef(false) const [selectedIds, setSelectedIds] = useState(() => new Set()) const [bulkModalOpen, setBulkModalOpen] = useState(false) const [bulkVisibility, setBulkVisibility] = useState('') const [bulkStatus, setBulkStatus] = useState('') const [bulkClubSelect, setBulkClubSelect] = useState('') const [bulkClubManual, setBulkClubManual] = useState('') const [bulkSubmitting, setBulkSubmitting] = useState(false) const [bulkPatchFocusAreas, setBulkPatchFocusAreas] = useState(false) const [bulkFocusAreaIds, setBulkFocusAreaIds] = useState([]) const [bulkPatchStyleDirections, setBulkPatchStyleDirections] = useState(false) const [bulkStyleDirectionIds, setBulkStyleDirectionIds] = useState([]) const [bulkPatchTrainingTypes, setBulkPatchTrainingTypes] = useState(false) const [bulkTrainingTypeIds, setBulkTrainingTypeIds] = useState([]) const [bulkPatchTargetGroups, setBulkPatchTargetGroups] = useState(false) const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([]) useEffect(() => { if (!user?.id) return if (prefsAppliedRef.current) return setFilters(mergeExerciseListPrefsFromApi(user.exercise_list_prefs)) prefsAppliedRef.current = true }, [user?.id, user?.exercise_list_prefs]) useEffect(() => { if (!user?.id) prefsAppliedRef.current = false }, [user?.id]) 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_rules || []).forEach((r) => { const opt = focusOptions.find((o) => String(o.id) === String(r.focus_area_id)) const verb = r.mode === 'forbid' ? 'Fokus ohne' : 'Fokus mit' chips.push({ key: `fr-${r.key}`, label: `${verb}: ${opt?.label ?? r.focus_area_id}`, onRemove: () => setFilters((prev) => ({ ...prev, focus_rules: prev.focus_rules.filter((x) => x.key !== r.key), })), }) }) if (filters.focus_only_without) { chips.push({ key: 'focus-only-none', label: 'Nur ohne Fokusbereich', onRemove: () => setFilters((prev) => ({ ...prev, focus_only_without: false })), }) } ;(filters.focus_area_ids || []).forEach((id) => { const opt = focusOptions.find((o) => String(o.id) === String(id)) chips.push({ key: `fa-${id}`, label: `Fokus (ODER, älter): ${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)), })), }) }) ;(filters.visibility_exclude_any || []).forEach((id) => { const opt = visibilityOptions.find((o) => String(o.id) === String(id)) chips.push({ key: `vex-${id}`, label: `Sichtbarkeit ausblenden: ${opt?.label ?? id}`, onRemove: () => setFilters((prev) => ({ ...prev, visibility_exclude_any: prev.visibility_exclude_any.filter((x) => String(x) !== String(id)), })), }) }) ;(filters.status_exclude_any || []).forEach((id) => { const opt = statusOptions.find((o) => String(o.id) === String(id)) chips.push({ key: `sex-${id}`, label: `Status ausblenden: ${opt?.label ?? id}`, onRemove: () => setFilters((prev) => ({ ...prev, status_exclude_any: prev.status_exclude_any.filter((x) => String(x) !== String(id)), })), }) }) if (filters.exclude_without_focus) { chips.push({ key: 'ex-no-focus', label: 'Ohne Fokus ausblenden', onRemove: () => setFilters((prev) => ({ ...prev, exclude_without_focus: false })), }) } if (filters.include_archived) { chips.push({ key: 'inc-arch', label: 'Archivierte anzeigen', onRemove: () => setFilters((prev) => ({ ...prev, include_archived: false })), }) } 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 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 (debouncedAiSearch) q.ai_search = debouncedAiSearch return q }, [filters, debouncedSearch, debouncedAiSearch]) useEffect(() => { setSelectedIds(new Set()) }, [queryBase]) const clubNameById = useMemo(() => { const m = {} for (const c of user?.clubs || []) { if (c?.id != null) m[Number(c.id)] = c.name || `#${c.id}` } return m }, [user?.clubs]) const effectiveClubId = user?.effective_club_id != null && user.effective_club_id !== '' ? Number(user.effective_club_id) : user?.active_club_id != null && user.active_club_id !== '' ? Number(user.active_club_id) : null const toggleSelect = useCallback((id) => { setSelectedIds((prev) => { const n = new Set(prev) const nid = Number(id) if (Number.isNaN(nid)) return prev if (n.has(nid)) n.delete(nid) else n.add(nid) return n }) }, []) const clearSelection = useCallback(() => setSelectedIds(new Set()), []) const toggleSelectAllPage = useCallback(() => { setSelectedIds((prev) => { const n = new Set(prev) const allSel = exercises.length > 0 && exercises.every((e) => n.has(Number(e.id))) if (allSel) { exercises.forEach((e) => n.delete(Number(e.id))) } else { exercises.forEach((e) => n.add(Number(e.id))) } return n }) }, [exercises]) const allOnPageSelected = useMemo( () => exercises.length > 0 && exercises.every((e) => selectedIds.has(Number(e.id))), [exercises, selectedIds] ) const bulkVisibilityOptions = useMemo(() => { const base = [ { id: '', label: '— nicht ändern —' }, { id: 'private', label: 'Privat' }, { id: 'club', label: 'Verein' }, ] if (isPlatformAdmin) base.push({ id: 'official', label: 'Offiziell (global)' }) return base }, [isPlatformAdmin]) 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_EXERCISE_LIST_FILTERS }), []) const handleSaveExerciseFilterPrefs = useCallback(async () => { const uid = user?.id if (!uid) { alert('Nicht angemeldet.') return } setSavingExercisePrefs(true) try { const payload = compactExerciseListPrefsPayload(filters) await api.updateProfile(uid, { exercise_list_prefs: payload }) await checkAuth() alert('Standardfilter für die Übungsliste gespeichert.') } catch (e) { alert('Speichern fehlgeschlagen: ' + (e.message || String(e))) } finally { setSavingExercisePrefs(false) } }, [user?.id, filters, checkAuth]) const openBulkModal = () => { setBulkVisibility('') setBulkStatus('') setBulkClubSelect('') setBulkClubManual('') setBulkPatchFocusAreas(false) setBulkFocusAreaIds([]) setBulkPatchStyleDirections(false) setBulkStyleDirectionIds([]) setBulkPatchTrainingTypes(false) setBulkTrainingTypeIds([]) setBulkPatchTargetGroups(false) setBulkTargetGroupIds([]) setBulkModalOpen(true) } const handleBulkSubmit = async () => { const anyRelationPatch = bulkPatchFocusAreas || bulkPatchStyleDirections || bulkPatchTrainingTypes || bulkPatchTargetGroups if (!bulkVisibility && !bulkStatus && !anyRelationPatch) { alert( 'Bitte mindestens eine Änderung wählen (Sichtbarkeit, Status oder eine der Zuordnungen mit gesetztem Häkchen „ersetzen“).' ) return } const ids = Array.from(selectedIds).filter((x) => Number.isFinite(x) && x > 0) if (ids.length === 0) { alert('Keine Übungen ausgewählt.') return } if (ids.length > BULK_MAX_IDS) { alert(`Maximal ${BULK_MAX_IDS} Übungen pro Vorgang. Bitte Auswahl oder mehrere Durchläufe verwenden.`) return } const payload = { exercise_ids: ids } if (bulkVisibility) payload.visibility = bulkVisibility if (bulkStatus) payload.status = bulkStatus if (bulkPatchFocusAreas) { payload.focus_area_ids = bulkFocusAreaIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0) } if (bulkPatchStyleDirections) { payload.style_direction_ids = bulkStyleDirectionIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0) } if (bulkPatchTrainingTypes) { payload.training_type_ids = bulkTrainingTypeIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0) } if (bulkPatchTargetGroups) { payload.target_group_ids = bulkTargetGroupIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0) } if (bulkVisibility === 'club') { const manual = String(bulkClubManual || '').trim() if (manual && /^\d+$/.test(manual)) payload.club_id = Number(manual) else if (bulkClubSelect && /^\d+$/.test(String(bulkClubSelect))) { payload.club_id = Number(bulkClubSelect) } } setBulkSubmitting(true) try { const res = await api.bulkPatchExercisesMetadata(payload) const updatedSet = new Set((res.updated || []).map((x) => Number(x))) let resolvedClubId = null if (bulkVisibility === 'club') { if (payload.club_id != null) resolvedClubId = payload.club_id else if (effectiveClubId != null && !Number.isNaN(effectiveClubId)) resolvedClubId = effectiveClubId } const clubLabel = resolvedClubId != null ? clubNameById[resolvedClubId] || `Verein #${resolvedClubId}` : null let nextPrimaryFocusName = null if (bulkPatchFocusAreas) { const faNums = bulkFocusAreaIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0) if (faNums.length > 0) { const opt = focusOptions.find((o) => Number(o.id) === Number(faNums[0])) nextPrimaryFocusName = String(opt?.label ?? '').trim() || String(faNums[0]) } } setExercises((prev) => prev.map((e) => { if (!updatedSet.has(Number(e.id))) return e const next = { ...e } if (bulkVisibility) { next.visibility = bulkVisibility next.club_id = bulkVisibility === 'club' ? resolvedClubId : null next.club_name = bulkVisibility === 'club' ? clubLabel : null } if (bulkStatus) next.status = bulkStatus if (bulkPatchFocusAreas) { if (nextPrimaryFocusName == null) delete next.focus_area else next.focus_area = nextPrimaryFocusName } return next }) ) let msg = `${res.updated_count ?? updatedSet.size} Übung(en) aktualisiert.` if (res.failed_count) msg += `\n${res.failed_count} nicht geändert (siehe Details).` if (Array.isArray(res.failed) && res.failed.length) { msg += '\n\n' + res.failed .slice(0, 12) .map((f) => `#${f.id}: ${f.detail}`) .join('\n') if (res.failed.length > 12) msg += '\n…' } alert(msg) setBulkModalOpen(false) clearSelection() } catch (err) { alert('Massenänderung fehlgeschlagen: ' + (err.message || String(err))) } finally { setBulkSubmitting(false) } } if (!catalogsReady && pageTab === 'list') { return (

Lade Kataloge…

) } return (

Übungen

{pageTab === 'list' ? ( + Neu ) : (
{pageTab === 'progression' ? ( ) : ( <>
{searchTitleSuggestions.map((t) => ( setSearchInput(e.target.value)} autoComplete="on" name="exercise-fulltext-search" list="exercise-search-titles" enterKeyHint="search" /> setAiSearchInput(e.target.value)} autoComplete="on" name="exercise-ai-search" list="exercise-search-titles" enterKeyHint="search" />
{filterChips.length > 0 ? ( ) : null}
{filterChips.length > 0 ? (
{filterChips.map((c) => ( ))}
) : null}

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. {exercises.length > 0 ? ( <> {' '} ) : null}

{selectedIds.size > 0 ? (
{selectedIds.size} ausgewählt Bis zu {BULK_MAX_IDS} pro Anfrage. Für „Verein“ ohne Auswahl: aktiver Vereinskontext ( X-Active-Club-Id ).
) : null} {filterModalOpen && (
{ if (e.target === e.currentTarget) setFilterModalOpen(false) }} >
e.stopPropagation()} >

Übungen filtern

Zwischen den Bereichen gilt UND. Fokusbereiche: mehrere „+ mit“ bedeuten alle müssen gesetzt sein; „− ohne“ schließt Übungen aus, die diesen Fokus zusätzlich haben. Stilrichtung / Trainingsstil / Zielgruppe: mehrere Werte pro Feld mit ODER.

Zuordnung

setFilters((prev) => ({ ...prev, ...patch }))} />
setFilters({ ...filters, style_direction_ids: v })} options={styleOptions} placeholder="Stilrichtung suchen …" />
setFilters({ ...filters, training_type_ids: v })} options={trainingTypeOptions} placeholder="Trainingsstil suchen …" />
setFilters({ ...filters, target_group_ids: v })} options={targetGroupOptions} placeholder="Zielgruppe suchen …" />

Fähigkeit und zugehörige Stufe

setFilters({ ...filters, skill_ids: v })} options={skillOptions} placeholder="Fähigkeit suchen …" />

Die Stufen filtern nach dem Niveau der Zuordnung Übung ↔ Fähigkeit (von–bis).

Ausblenden

Negativlisten schließen Treffer aus (weitere Felder weiterhin mit UND verknüpft).

setFilters({ ...filters, visibility_exclude_any: v })} options={visibilityOptions} placeholder="z. B. Global ausblenden …" />
setFilters({ ...filters, status_exclude_any: v })} options={statusOptions} placeholder="z. B. Entwurf ausblenden …" />

Freigabe

setFilters({ ...filters, visibility_any: v })} options={visibilityOptions} placeholder="Sichtbarkeit wählen …" />
setFilters({ ...filters, status_any: v })} options={statusOptions} placeholder="Status wählen …" />
)} {bulkModalOpen ? (
{ if (e.target === e.currentTarget) setBulkModalOpen(false) }} >
e.stopPropagation()} >

Massenänderung

Es werden {selectedIds.size} Übung(en) aus der aktuellen Auswahl bearbeitet. Pro Durchlauf höchstens {BULK_MAX_IDS}. Ohne Berechtigung bleiben Einzelübungen unverändert (siehe Hinweis nach dem Speichern).

Unter „Zuordnung ersetzen“: die gewählte Liste ersetzt die bisherige Zuordnung bei allen betroffenen Übungen vollständig (leere Auswahl = alle Zuordnungen dieser Kategorie entfernen). Die erste Auswahl gilt als Primärzuordnung.

{bulkVisibility === 'club' ? (
{isPlatformAdmin ? ( <> setBulkClubManual(e.target.value)} /> ) : null}
) : null}

Zuordnung (optional)

{bulkPatchFocusAreas ? ( ) : null}
{bulkPatchStyleDirections ? ( ) : null}
{bulkPatchTrainingTypes ? ( ) : null}
{bulkPatchTargetGroups ? ( ) : null}
) : null} {listFetching && exercises.length === 0 ? (

Lade Übungen…

) : exercises.length === 0 ? (

Keine Übungen gefunden.

) : ( <> {listFetching ? (

Aktualisiere Treffer…

) : null}

{exercises.length} angezeigt {hasMore ? ' · es gibt weitere Einträge' : ''}

{exercises.map((exercise) => { const focusNames = exerciseFocusNames(exercise) const styleNames = coerceApiNameList(exercise.style_direction_names) const typeNames = coerceApiNameList(exercise.training_type_names) const summaryHtml = exercise.summary ? sanitizeExerciseRichText(exercise.summary) : '' return (
toggleSelect(exercise.id)} aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`} className="exercise-card-layout__check" />

{exercise.title}

{focusNames.map((name) => ( {name} ))} {styleNames.map((name) => ( {name} ))} {typeNames.map((name) => ( {name} ))}
{summaryHtml ? (
) : null}
{canUserRequestExerciseDelete(user, exercise) ? ( ) : null}
) })}
{hasMore && (
)} )} )}
) } export default ExercisesListPage