Some checks failed
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 29s
- Bumped application version to 0.8.40 and updated module versions accordingly. - Introduced new focus area filtering options in the ExercisesListPage, allowing users to include or exclude exercises based on specified focus areas. - Added utility functions for deduplicating and merging focus area IDs to improve filtering logic. - Enhanced the ExercisePickerModal and ExercisesListPage components to support new focus rules and improve user experience with focus area selections.
1420 lines
54 KiB
JavaScript
1420 lines
54 KiB
JavaScript
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 (
|
||
<div
|
||
className="exercise-card__meta-compact"
|
||
title={tip}
|
||
aria-label={`Sichtbarkeit: ${visLabel}. Status: ${stLabel}.`}
|
||
>
|
||
<span className="exercise-card__meta-glyph">
|
||
<VisIcon size={15} strokeWidth={2} aria-hidden />
|
||
</span>
|
||
<span className="exercise-card__meta-sep" aria-hidden>
|
||
·
|
||
</span>
|
||
<span className="exercise-card__meta-glyph">
|
||
<StatIcon size={15} strokeWidth={2} aria-hidden />
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div className="app-page">
|
||
<div className="empty-state" style={{ padding: '2.5rem 1rem' }}>
|
||
<div className="spinner" />
|
||
<p className="muted" style={{ marginTop: '12px' }}>
|
||
Lade Kataloge…
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="app-page">
|
||
<div className="exercises-page__header">
|
||
<h1 className="page-title exercises-page__title">Übungen</h1>
|
||
{pageTab === 'list' ? (
|
||
<Link to="/exercises/new" className="btn btn-primary">
|
||
+ Neu
|
||
</Link>
|
||
) : (
|
||
<span aria-hidden="true" />
|
||
)}
|
||
</div>
|
||
|
||
<PageSectionNav
|
||
ariaLabel="Übungen Bereiche"
|
||
value={pageTab}
|
||
onChange={setPageTab}
|
||
items={EXERCISES_PAGE_TABS}
|
||
className="exercises-page-toolbar-tabs"
|
||
/>
|
||
|
||
{pageTab === 'progression' ? (
|
||
<ExerciseProgressionGraphPanel />
|
||
) : (
|
||
<>
|
||
<div className="card exercise-search-bar">
|
||
<label className="form-label">Volltextsuche (Titel, Ziel, …)</label>
|
||
<datalist id="exercise-search-titles">
|
||
{searchTitleSuggestions.map((t) => (
|
||
<option key={t} value={t} />
|
||
))}
|
||
</datalist>
|
||
<input
|
||
type="search"
|
||
className="form-input exercise-search-bar__primary"
|
||
placeholder="Suchbegriffe…"
|
||
value={searchInput}
|
||
onChange={(e) => setSearchInput(e.target.value)}
|
||
autoComplete="on"
|
||
name="exercise-fulltext-search"
|
||
list="exercise-search-titles"
|
||
enterKeyHint="search"
|
||
/>
|
||
<label className="form-label">Ergänzende Suche / KI-Vorbereitung (Beta)</label>
|
||
<input
|
||
type="search"
|
||
className="form-input"
|
||
placeholder="zweiter Begriff — zusätzliche Volltextsuche (ODER)"
|
||
value={aiSearchInput}
|
||
onChange={(e) => setAiSearchInput(e.target.value)}
|
||
autoComplete="on"
|
||
name="exercise-ai-search"
|
||
list="exercise-search-titles"
|
||
enterKeyHint="search"
|
||
/>
|
||
<div className="exercise-search-bar__actions">
|
||
<button type="button" className="btn btn-secondary exercise-filter-trigger" onClick={() => setFilterModalOpen(true)}>
|
||
Filter
|
||
{filterChips.length > 0 ? (
|
||
<span className="exercise-filter-badge" aria-hidden>
|
||
{filterChips.length}
|
||
</span>
|
||
) : null}
|
||
</button>
|
||
{filterChips.length > 0 ? (
|
||
<button type="button" className="btn" onClick={resetAllFilters}>
|
||
Alle entfernen
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
{filterChips.length > 0 ? (
|
||
<div className="exercise-filter-chips-row" role="list" aria-label="Aktive Filter">
|
||
{filterChips.map((c) => (
|
||
<button
|
||
key={c.key}
|
||
type="button"
|
||
role="listitem"
|
||
className="exercise-filter-chip"
|
||
title="Filter entfernen"
|
||
onClick={() => c.onRemove()}
|
||
>
|
||
<span className="exercise-filter-chip__text">{c.label}</span>
|
||
<span className="exercise-filter-chip__x" aria-hidden>
|
||
×
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
<p className="exercise-search-hint">
|
||
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 ? (
|
||
<>
|
||
{' '}
|
||
<button type="button" className="btn btn-secondary btn-small" onClick={toggleSelectAllPage}>
|
||
{allOnPageSelected ? 'Auswahl auf dieser Seite aufheben' : 'Alle auf dieser Seite auswählen'}
|
||
</button>
|
||
</>
|
||
) : null}
|
||
</p>
|
||
</div>
|
||
|
||
{selectedIds.size > 0 ? (
|
||
<div className="card exercise-bulk-toolbar">
|
||
<strong>{selectedIds.size} ausgewählt</strong>
|
||
<button type="button" className="btn btn-secondary btn-small" onClick={clearSelection}>
|
||
Auswahl aufheben
|
||
</button>
|
||
<button type="button" className="btn btn-primary btn-small" onClick={openBulkModal}>
|
||
Massenänderung…
|
||
</button>
|
||
<span className="exercise-bulk-toolbar__meta">
|
||
Bis zu {BULK_MAX_IDS} pro Anfrage. Für „Verein“ ohne Auswahl: aktiver Vereinskontext (
|
||
<code>X-Active-Club-Id</code>
|
||
).
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
|
||
{filterModalOpen && (
|
||
<div
|
||
className="admin-modal-backdrop"
|
||
role="presentation"
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) setFilterModalOpen(false)
|
||
}}
|
||
>
|
||
<div
|
||
className="admin-modal-sheet exercise-filter-modal"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="exercise-filter-modal-title"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="admin-modal-sheet__header">
|
||
<h3 id="exercise-filter-modal-title" className="admin-modal-sheet__title">
|
||
Übungen filtern
|
||
</h3>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary admin-modal-sheet__close"
|
||
onClick={() => setFilterModalOpen(false)}
|
||
>
|
||
Schließen
|
||
</button>
|
||
</div>
|
||
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
|
||
<p className="muted" style={{ marginTop: 0, marginBottom: '14px' }}>
|
||
Zwischen den Bereichen gilt <strong>UND</strong>. 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 <strong>ODER</strong>.
|
||
</p>
|
||
|
||
<section className="exercise-filter-section">
|
||
<h4 className="exercise-filter-section-title">Zuordnung</h4>
|
||
<ExerciseFocusRulePicker
|
||
focusOptions={focusOptions}
|
||
focusRules={filters.focus_rules}
|
||
focusOnlyWithout={filters.focus_only_without}
|
||
legacyFocusAreaIds={filters.focus_area_ids}
|
||
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
|
||
/>
|
||
<div className="exercise-filters-modal-grid" style={{ marginTop: '14px' }}>
|
||
<div>
|
||
<label className="form-label">Stilrichtung</label>
|
||
<MultiSelectCombo
|
||
value={filters.style_direction_ids}
|
||
onChange={(v) => setFilters({ ...filters, style_direction_ids: v })}
|
||
options={styleOptions}
|
||
placeholder="Stilrichtung suchen …"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="form-label">Trainingsstil</label>
|
||
<MultiSelectCombo
|
||
value={filters.training_type_ids}
|
||
onChange={(v) => setFilters({ ...filters, training_type_ids: v })}
|
||
options={trainingTypeOptions}
|
||
placeholder="Trainingsstil suchen …"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="form-label">Zielgruppe</label>
|
||
<MultiSelectCombo
|
||
value={filters.target_group_ids}
|
||
onChange={(v) => setFilters({ ...filters, target_group_ids: v })}
|
||
options={targetGroupOptions}
|
||
placeholder="Zielgruppe suchen …"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="exercise-filter-section">
|
||
<h4 className="exercise-filter-section-title">Fähigkeit und zugehörige Stufe</h4>
|
||
<div className="exercise-filter-skill-block">
|
||
<label className="form-label">Fähigkeit</label>
|
||
<MultiSelectCombo
|
||
value={filters.skill_ids}
|
||
onChange={(v) => setFilters({ ...filters, skill_ids: v })}
|
||
options={skillOptions}
|
||
placeholder="Fähigkeit suchen …"
|
||
/>
|
||
<p className="exercise-filter-skill-hint">
|
||
Die Stufen filtern nach dem Niveau der Zuordnung Übung ↔ Fähigkeit (von–bis).
|
||
</p>
|
||
<div className="exercise-filter-skill-levels-row">
|
||
<label className="exercise-filter-skill-level-field">
|
||
<span className="exercise-filter-skill-level-caption">von</span>
|
||
<select
|
||
className="form-input exercise-filter-level-select"
|
||
title="Mindest-Stufe"
|
||
value={filters.skill_min_level}
|
||
onChange={(e) => setFilters({ ...filters, skill_min_level: e.target.value })}
|
||
>
|
||
<option value="">–</option>
|
||
{LEVEL_FILTER_OPTS.map((o) => (
|
||
<option key={o.value} value={String(o.level)} title={o.label}>
|
||
{o.level}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<span className="exercise-filter-skill-dash" aria-hidden>
|
||
–
|
||
</span>
|
||
<label className="exercise-filter-skill-level-field">
|
||
<span className="exercise-filter-skill-level-caption">bis</span>
|
||
<select
|
||
className="form-input exercise-filter-level-select"
|
||
title="Höchst-Stufe"
|
||
value={filters.skill_max_level}
|
||
onChange={(e) => setFilters({ ...filters, skill_max_level: e.target.value })}
|
||
>
|
||
<option value="">–</option>
|
||
{LEVEL_FILTER_OPTS.map((o) => (
|
||
<option key={`m-${o.value}`} value={String(o.level)} title={o.label}>
|
||
{o.level}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="exercise-filter-section">
|
||
<h4 className="exercise-filter-section-title">Ausblenden</h4>
|
||
<p className="muted" style={{ marginTop: 0, marginBottom: '12px', fontSize: '13px' }}>
|
||
Negativlisten schließen Treffer aus (weitere Felder weiterhin mit UND verknüpft).
|
||
</p>
|
||
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two">
|
||
<div>
|
||
<label className="form-label">Sichtbarkeit nicht anzeigen</label>
|
||
<MultiSelectCombo
|
||
value={filters.visibility_exclude_any}
|
||
onChange={(v) => setFilters({ ...filters, visibility_exclude_any: v })}
|
||
options={visibilityOptions}
|
||
placeholder="z. B. Global ausblenden …"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="form-label">Status nicht anzeigen</label>
|
||
<MultiSelectCombo
|
||
value={filters.status_exclude_any}
|
||
onChange={(v) => setFilters({ ...filters, status_exclude_any: v })}
|
||
options={statusOptions}
|
||
placeholder="z. B. Entwurf ausblenden …"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div style={{ marginTop: '14px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||
<label
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '10px',
|
||
cursor: filters.focus_only_without ? 'not-allowed' : 'pointer',
|
||
opacity: filters.focus_only_without ? 0.55 : 1,
|
||
}}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
disabled={!!filters.focus_only_without}
|
||
checked={!!filters.exclude_without_focus}
|
||
onChange={(e) =>
|
||
setFilters((prev) => ({
|
||
...prev,
|
||
exclude_without_focus: e.target.checked,
|
||
...(e.target.checked ? { focus_only_without: false } : {}),
|
||
}))
|
||
}
|
||
/>
|
||
<span>Übungen ohne Fokusbereich ausblenden</span>
|
||
</label>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={!!filters.include_archived}
|
||
onChange={(e) => setFilters({ ...filters, include_archived: e.target.checked })}
|
||
/>
|
||
<span>Archivierte Übungen einblenden (ohne Haken werden sie standardmäßig ausgeblendet)</span>
|
||
</label>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="exercise-filter-section exercise-filter-section--last">
|
||
<h4 className="exercise-filter-section-title">Freigabe</h4>
|
||
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two">
|
||
<div>
|
||
<label className="form-label">Sichtbarkeit</label>
|
||
<MultiSelectCombo
|
||
value={filters.visibility_any}
|
||
onChange={(v) => setFilters({ ...filters, visibility_any: v })}
|
||
options={visibilityOptions}
|
||
placeholder="Sichtbarkeit wählen …"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="form-label">Status</label>
|
||
<MultiSelectCombo
|
||
value={filters.status_any}
|
||
onChange={(v) => setFilters({ ...filters, status_any: v })}
|
||
options={statusOptions}
|
||
placeholder="Status wählen …"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
<div className="exercise-filter-modal__footer">
|
||
<button type="button" className="btn btn-secondary" disabled={savingExercisePrefs} onClick={handleSaveExerciseFilterPrefs}>
|
||
{savingExercisePrefs ? 'Speichern…' : 'Als Standard speichern'}
|
||
</button>
|
||
<button type="button" className="btn" onClick={resetAllFilters}>
|
||
Alle Filter zurücksetzen
|
||
</button>
|
||
<button type="button" className="btn btn-primary" onClick={() => setFilterModalOpen(false)}>
|
||
Fertig
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{bulkModalOpen ? (
|
||
<div
|
||
className="admin-modal-backdrop"
|
||
role="presentation"
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) setBulkModalOpen(false)
|
||
}}
|
||
>
|
||
<div
|
||
className="admin-modal-sheet exercise-filter-modal"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="exercise-bulk-modal-title"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="admin-modal-sheet__header">
|
||
<h3 id="exercise-bulk-modal-title" className="admin-modal-sheet__title">
|
||
Massenänderung
|
||
</h3>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary admin-modal-sheet__close"
|
||
onClick={() => setBulkModalOpen(false)}
|
||
>
|
||
Schließen
|
||
</button>
|
||
</div>
|
||
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
|
||
<p className="muted" style={{ marginTop: 0 }}>
|
||
Es werden <strong>{selectedIds.size}</strong> Ü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).
|
||
</p>
|
||
<p className="form-sub" style={{ marginTop: 0, marginBottom: '14px' }}>
|
||
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.
|
||
</p>
|
||
<div className="form-row">
|
||
<label className="form-label">Sichtbarkeit</label>
|
||
<select
|
||
className="form-input"
|
||
value={bulkVisibility}
|
||
onChange={(e) => setBulkVisibility(e.target.value)}
|
||
>
|
||
{bulkVisibilityOptions.map((o) => (
|
||
<option key={o.id === '' ? '_unchanged' : o.id} value={o.id}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
{bulkVisibility === 'club' ? (
|
||
<div className="form-row">
|
||
<label className="form-label">Verein zuordnen</label>
|
||
<select
|
||
className="form-input"
|
||
value={bulkClubSelect}
|
||
onChange={(e) => setBulkClubSelect(e.target.value)}
|
||
>
|
||
<option value="">Aktiver Verein (Vereins-Umschalter / Header)</option>
|
||
{(user?.clubs || []).map((c) => (
|
||
<option key={c.id} value={String(c.id)}>
|
||
{c.name || `#${c.id}`}
|
||
</option>
|
||
))}
|
||
</select>
|
||
{isPlatformAdmin ? (
|
||
<>
|
||
<label className="form-label" style={{ marginTop: '10px' }}>
|
||
Oder Vereins-ID (Plattform-Admin)
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
className="form-input"
|
||
placeholder="Leer = wie Dropdown / aktiver Verein"
|
||
value={bulkClubManual}
|
||
onChange={(e) => setBulkClubManual(e.target.value)}
|
||
/>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
<div className="form-row">
|
||
<label className="form-label">Status</label>
|
||
<select
|
||
className="form-input"
|
||
value={bulkStatus}
|
||
onChange={(e) => setBulkStatus(e.target.value)}
|
||
>
|
||
<option value="">— nicht ändern —</option>
|
||
{statusOptions.map((o) => (
|
||
<option key={o.id} value={o.id}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<section className="exercise-filter-section" style={{ marginTop: '8px', paddingTop: '12px' }}>
|
||
<h4 className="exercise-filter-section-title">Zuordnung (optional)</h4>
|
||
<div className="exercise-filters-modal-grid">
|
||
<div>
|
||
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={bulkPatchFocusAreas}
|
||
onChange={(e) => {
|
||
const on = e.target.checked
|
||
setBulkPatchFocusAreas(on)
|
||
if (!on) setBulkFocusAreaIds([])
|
||
}}
|
||
/>
|
||
Fokusbereiche ersetzen
|
||
</label>
|
||
{bulkPatchFocusAreas ? (
|
||
<MultiSelectCombo
|
||
value={bulkFocusAreaIds}
|
||
onChange={setBulkFocusAreaIds}
|
||
options={focusOptions}
|
||
placeholder="Fokusbereiche wählen …"
|
||
/>
|
||
) : null}
|
||
</div>
|
||
<div>
|
||
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={bulkPatchStyleDirections}
|
||
onChange={(e) => {
|
||
const on = e.target.checked
|
||
setBulkPatchStyleDirections(on)
|
||
if (!on) setBulkStyleDirectionIds([])
|
||
}}
|
||
/>
|
||
Stilrichtungen ersetzen
|
||
</label>
|
||
{bulkPatchStyleDirections ? (
|
||
<MultiSelectCombo
|
||
value={bulkStyleDirectionIds}
|
||
onChange={setBulkStyleDirectionIds}
|
||
options={styleOptions}
|
||
placeholder="Stilrichtungen wählen …"
|
||
/>
|
||
) : null}
|
||
</div>
|
||
<div>
|
||
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={bulkPatchTrainingTypes}
|
||
onChange={(e) => {
|
||
const on = e.target.checked
|
||
setBulkPatchTrainingTypes(on)
|
||
if (!on) setBulkTrainingTypeIds([])
|
||
}}
|
||
/>
|
||
Trainingsstile ersetzen
|
||
</label>
|
||
{bulkPatchTrainingTypes ? (
|
||
<MultiSelectCombo
|
||
value={bulkTrainingTypeIds}
|
||
onChange={setBulkTrainingTypeIds}
|
||
options={trainingTypeOptions}
|
||
placeholder="Trainingsstile wählen …"
|
||
/>
|
||
) : null}
|
||
</div>
|
||
<div>
|
||
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={bulkPatchTargetGroups}
|
||
onChange={(e) => {
|
||
const on = e.target.checked
|
||
setBulkPatchTargetGroups(on)
|
||
if (!on) setBulkTargetGroupIds([])
|
||
}}
|
||
/>
|
||
Zielgruppen ersetzen
|
||
</label>
|
||
{bulkPatchTargetGroups ? (
|
||
<MultiSelectCombo
|
||
value={bulkTargetGroupIds}
|
||
onChange={setBulkTargetGroupIds}
|
||
options={targetGroupOptions}
|
||
placeholder="Zielgruppen wählen …"
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
<div className="exercise-filter-modal__footer">
|
||
<button
|
||
type="button"
|
||
className="btn"
|
||
disabled={bulkSubmitting}
|
||
onClick={() => setBulkModalOpen(false)}
|
||
>
|
||
Abbrechen
|
||
</button>
|
||
<button type="button" className="btn btn-primary" disabled={bulkSubmitting} onClick={handleBulkSubmit}>
|
||
{bulkSubmitting ? 'Speichern…' : 'Anwenden'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{listFetching && exercises.length === 0 ? (
|
||
<div className="card empty-state" style={{ padding: '2rem 1rem' }}>
|
||
<div className="spinner" />
|
||
<p className="muted" style={{ marginTop: '12px' }}>
|
||
Lade Übungen…
|
||
</p>
|
||
</div>
|
||
) : exercises.length === 0 ? (
|
||
<div className="card">
|
||
<p className="exercises-empty-text">Keine Übungen gefunden.</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{listFetching ? (
|
||
<p className="exercises-meta-line exercises-meta-line--muted">Aktualisiere Treffer…</p>
|
||
) : null}
|
||
<p className="exercises-meta-line">
|
||
{exercises.length} angezeigt
|
||
{hasMore ? ' · es gibt weitere Einträge' : ''}
|
||
</p>
|
||
<div className="exercises-list-grid">
|
||
{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 (
|
||
<div key={exercise.id} className={exerciseCardClassName(exercise, user?.id)}>
|
||
<div className="exercise-card-layout exercise-card-layout--grow">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedIds.has(Number(exercise.id))}
|
||
onChange={() => toggleSelect(exercise.id)}
|
||
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`}
|
||
className="exercise-card-layout__check"
|
||
/>
|
||
<div className="exercise-card__body exercise-card-body-flex">
|
||
<h3 className="exercise-card-title">
|
||
<Link to={`/exercises/${exercise.id}`}>
|
||
{exercise.title}
|
||
</Link>
|
||
</h3>
|
||
<div className="exercise-card-tags">
|
||
{focusNames.map((name) => (
|
||
<span key={`fa:${name}`} className="exercise-tag exercise-tag--accent">{name}</span>
|
||
))}
|
||
{styleNames.map((name) => (
|
||
<span key={`sd:${name}`} className="exercise-tag exercise-tag--style">{name}</span>
|
||
))}
|
||
{typeNames.map((name) => (
|
||
<span key={`tt:${name}`} className="exercise-tag exercise-tag--training">{name}</span>
|
||
))}
|
||
</div>
|
||
{summaryHtml ? (
|
||
<div
|
||
className="exercise-card-summary exercise-card-summary--rich"
|
||
dangerouslySetInnerHTML={{ __html: summaryHtml }}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div className="exercise-card__footer">
|
||
<ExerciseCardScopeStatus exercise={exercise} />
|
||
<div className="exercise-card__actions exercise-card__actions--icons">
|
||
<Link
|
||
to={`/exercises/${exercise.id}`}
|
||
className="exercise-card__icon-btn"
|
||
title="Ansehen"
|
||
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ ansehen`}
|
||
>
|
||
<Eye size={18} strokeWidth={2} aria-hidden />
|
||
</Link>
|
||
<Link
|
||
to={`/exercises/${exercise.id}/edit`}
|
||
className="exercise-card__icon-btn"
|
||
title="Bearbeiten"
|
||
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ bearbeiten`}
|
||
>
|
||
<Pencil size={18} strokeWidth={2} aria-hidden />
|
||
</Link>
|
||
{canUserRequestExerciseDelete(user, exercise) ? (
|
||
<button
|
||
type="button"
|
||
className="exercise-card__icon-btn exercise-card__icon-btn--danger"
|
||
title="Löschen"
|
||
aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ löschen`}
|
||
onClick={() => handleDelete(exercise)}
|
||
>
|
||
<Trash2 size={18} strokeWidth={2} aria-hidden />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
{hasMore && (
|
||
<div className="exercises-load-more">
|
||
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
|
||
{loadingMore ? 'Laden…' : 'Mehr laden'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default ExercisesListPage
|