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 (
)
}
return (
Übungen
{pageTab === 'list' ? (
+ Neu
) : (
)}
{pageTab === 'progression' ? (
) : (
<>
Volltextsuche (Titel, Ziel, …)
{searchTitleSuggestions.map((t) => (
))}
setSearchInput(e.target.value)}
autoComplete="on"
name="exercise-fulltext-search"
list="exercise-search-titles"
enterKeyHint="search"
/>
Ergänzende Suche / KI-Vorbereitung (Beta)
setAiSearchInput(e.target.value)}
autoComplete="on"
name="exercise-ai-search"
list="exercise-search-titles"
enterKeyHint="search"
/>
setFilterModalOpen(true)}>
Filter
{filterChips.length > 0 ? (
{filterChips.length}
) : null}
{filterChips.length > 0 ? (
Alle entfernen
) : null}
{filterChips.length > 0 ? (
{filterChips.map((c) => (
c.onRemove()}
>
{c.label}
×
))}
) : 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 ? (
<>
{' '}
{allOnPageSelected ? 'Auswahl auf dieser Seite aufheben' : 'Alle auf dieser Seite auswählen'}
>
) : null}
{selectedIds.size > 0 ? (
{selectedIds.size} ausgewählt
Auswahl aufheben
Massenänderung…
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
setFilterModalOpen(false)}
>
Schließen
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 }))}
/>
Stilrichtung
setFilters({ ...filters, style_direction_ids: v })}
options={styleOptions}
placeholder="Stilrichtung suchen …"
/>
Trainingsstil
setFilters({ ...filters, training_type_ids: v })}
options={trainingTypeOptions}
placeholder="Trainingsstil suchen …"
/>
Zielgruppe
setFilters({ ...filters, target_group_ids: v })}
options={targetGroupOptions}
placeholder="Zielgruppe suchen …"
/>
Fähigkeit und zugehörige Stufe
Fähigkeit
setFilters({ ...filters, skill_ids: v })}
options={skillOptions}
placeholder="Fähigkeit suchen …"
/>
Die Stufen filtern nach dem Niveau der Zuordnung Übung ↔ Fähigkeit (von–bis).
von
setFilters({ ...filters, skill_min_level: e.target.value })}
>
–
{LEVEL_FILTER_OPTS.map((o) => (
{o.level}
))}
–
bis
setFilters({ ...filters, skill_max_level: e.target.value })}
>
–
{LEVEL_FILTER_OPTS.map((o) => (
{o.level}
))}
Freigabe
Sichtbarkeit
setFilters({ ...filters, visibility_any: v })}
options={visibilityOptions}
placeholder="Sichtbarkeit wählen …"
/>
Status
setFilters({ ...filters, status_any: v })}
options={statusOptions}
placeholder="Status wählen …"
/>
{savingExercisePrefs ? 'Speichern…' : 'Als Standard speichern'}
Alle Filter zurücksetzen
setFilterModalOpen(false)}>
Fertig
)}
{bulkModalOpen ? (
{
if (e.target === e.currentTarget) setBulkModalOpen(false)
}}
>
e.stopPropagation()}
>
Massenänderung
setBulkModalOpen(false)}
>
Schließen
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.
Sichtbarkeit
setBulkVisibility(e.target.value)}
>
{bulkVisibilityOptions.map((o) => (
{o.label}
))}
{bulkVisibility === 'club' ? (
Verein zuordnen
setBulkClubSelect(e.target.value)}
>
Aktiver Verein (Vereins-Umschalter / Header)
{(user?.clubs || []).map((c) => (
{c.name || `#${c.id}`}
))}
{isPlatformAdmin ? (
<>
Oder Vereins-ID (Plattform-Admin)
setBulkClubManual(e.target.value)}
/>
>
) : null}
) : null}
Status
setBulkStatus(e.target.value)}
>
— nicht ändern —
{statusOptions.map((o) => (
{o.label}
))}
setBulkModalOpen(false)}
>
Abbrechen
{bulkSubmitting ? 'Speichern…' : 'Anwenden'}
) : null}
{listFetching && exercises.length === 0 ? (
) : exercises.length === 0 ? (
) : (
<>
{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) ? (
handleDelete(exercise)}
>
) : null}
)
})}
{hasMore && (
{loadingMore ? 'Laden…' : 'Mehr laden'}
)}
>
)}
>
)}
)
}
export default ExercisesListPage