2026-05-06 13:52:24 +02:00
|
|
|
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
|
2026-04-27 14:27:25 +02:00
|
|
|
|
import { Link } from 'react-router-dom'
|
2026-05-06 12:41:04 +02:00
|
|
|
|
import {
|
|
|
|
|
|
Eye,
|
|
|
|
|
|
Pencil,
|
|
|
|
|
|
Trash2,
|
|
|
|
|
|
Globe,
|
|
|
|
|
|
Users,
|
|
|
|
|
|
Lock,
|
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
|
Archive,
|
|
|
|
|
|
CircleDot,
|
|
|
|
|
|
FilePenLine,
|
|
|
|
|
|
} from 'lucide-react'
|
2026-04-27 14:27:25 +02:00
|
|
|
|
import api from '../utils/api'
|
2026-05-05 22:18:04 +02:00
|
|
|
|
import { useAuth } from '../context/AuthContext'
|
2026-04-27 18:25:23 +02:00
|
|
|
|
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
2026-04-28 08:02:08 +02:00
|
|
|
|
import MultiSelectCombo from '../components/MultiSelectCombo'
|
2026-05-06 17:15:44 +02:00
|
|
|
|
import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker'
|
2026-04-30 11:47:50 +02:00
|
|
|
|
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
2026-05-06 12:49:35 +02:00
|
|
|
|
import PageSectionNav from '../components/PageSectionNav'
|
2026-05-06 13:52:24 +02:00
|
|
|
|
import {
|
|
|
|
|
|
INITIAL_EXERCISE_LIST_FILTERS,
|
|
|
|
|
|
mergeExerciseListPrefsFromApi,
|
|
|
|
|
|
compactExerciseListPrefsPayload,
|
|
|
|
|
|
} from '../constants/exerciseListFilters'
|
2026-05-06 15:15:26 +02:00
|
|
|
|
import { sanitizeExerciseRichText, coerceApiNameList } from '../utils/sanitizeHtml'
|
2026-05-06 13:52:24 +02:00
|
|
|
|
import { canUserRequestExerciseDelete } from '../utils/exercisePermissions'
|
2026-04-27 14:27:25 +02:00
|
|
|
|
|
2026-04-27 14:48:46 +02:00
|
|
|
|
const PAGE_SIZE = 100
|
2026-05-05 22:18:04 +02:00
|
|
|
|
const BULK_MAX_IDS = 500
|
2026-05-06 12:49:35 +02:00
|
|
|
|
const EXERCISES_PAGE_TABS = [
|
|
|
|
|
|
{ id: 'list', label: 'Liste' },
|
|
|
|
|
|
{ id: 'progression', label: 'Progressionsgraphen' },
|
|
|
|
|
|
]
|
2026-04-27 18:25:23 +02:00
|
|
|
|
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
|
2026-04-27 14:48:46 +02:00
|
|
|
|
|
2026-05-06 12:20:22 +02:00
|
|
|
|
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(' ')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 12:41:04 +02:00
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-28 08:58:17 +02:00
|
|
|
|
function levelOptionShort(levelStr) {
|
|
|
|
|
|
const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr))
|
|
|
|
|
|
return o ? String(o.level) : String(levelStr)
|
2026-04-28 08:21:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-27 14:27:25 +02:00
|
|
|
|
function ExercisesListPage() {
|
2026-05-06 13:52:24 +02:00
|
|
|
|
const { user, checkAuth } = useAuth()
|
2026-05-05 22:18:04 +02:00
|
|
|
|
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
|
|
|
|
|
|
2026-04-27 14:27:25 +02:00
|
|
|
|
const [exercises, setExercises] = useState([])
|
2026-04-27 18:25:23 +02:00
|
|
|
|
const [catalogs, setCatalogs] = useState({
|
|
|
|
|
|
focusAreas: [],
|
|
|
|
|
|
styleDirections: [],
|
|
|
|
|
|
trainingTypes: [],
|
|
|
|
|
|
targetGroups: [],
|
|
|
|
|
|
skills: [],
|
|
|
|
|
|
})
|
|
|
|
|
|
const [catalogsReady, setCatalogsReady] = useState(false)
|
2026-04-28 15:24:05 +02:00
|
|
|
|
const [listFetching, setListFetching] = useState(false)
|
2026-04-27 14:48:46 +02:00
|
|
|
|
const [loadingMore, setLoadingMore] = useState(false)
|
|
|
|
|
|
const [offset, setOffset] = useState(0)
|
|
|
|
|
|
const [hasMore, setHasMore] = useState(false)
|
2026-04-27 18:25:23 +02:00
|
|
|
|
const [searchInput, setSearchInput] = useState('')
|
|
|
|
|
|
const [aiSearchInput, setAiSearchInput] = useState('')
|
|
|
|
|
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
|
|
|
|
|
const [debouncedAiSearch, setDebouncedAiSearch] = useState('')
|
2026-05-06 13:52:24 +02:00
|
|
|
|
const [filters, setFilters] = useState(() => ({ ...INITIAL_EXERCISE_LIST_FILTERS }))
|
2026-04-28 08:21:55 +02:00
|
|
|
|
const [filterModalOpen, setFilterModalOpen] = useState(false)
|
2026-05-06 13:52:24 +02:00
|
|
|
|
const [savingExercisePrefs, setSavingExercisePrefs] = useState(false)
|
2026-04-30 11:47:50 +02:00
|
|
|
|
const [pageTab, setPageTab] = useState('list')
|
2026-05-06 13:52:24 +02:00
|
|
|
|
const prefsAppliedRef = useRef(false)
|
2026-04-28 08:21:55 +02:00
|
|
|
|
|
2026-05-05 22:18:04 +02:00
|
|
|
|
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)
|
2026-05-06 11:02:46 +02:00
|
|
|
|
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([])
|
2026-05-05 22:18:04 +02:00
|
|
|
|
|
2026-05-06 13:52:24 +02:00
|
|
|
|
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])
|
|
|
|
|
|
|
2026-04-27 14:27:25 +02:00
|
|
|
|
useEffect(() => {
|
2026-04-27 18:25:23 +02:00
|
|
|
|
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
|
|
|
|
|
|
return () => clearTimeout(t)
|
|
|
|
|
|
}, [searchInput])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const t = setTimeout(() => setDebouncedAiSearch(aiSearchInput.trim()), 400)
|
|
|
|
|
|
return () => clearTimeout(t)
|
|
|
|
|
|
}, [aiSearchInput])
|
|
|
|
|
|
|
2026-04-28 08:21:55 +02:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!filterModalOpen) return
|
|
|
|
|
|
const onKey = (e) => {
|
|
|
|
|
|
if (e.key === 'Escape') setFilterModalOpen(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
window.addEventListener('keydown', onKey)
|
|
|
|
|
|
return () => window.removeEventListener('keydown', onKey)
|
|
|
|
|
|
}, [filterModalOpen])
|
|
|
|
|
|
|
2026-04-28 07:25:33 +02:00
|
|
|
|
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' },
|
|
|
|
|
|
],
|
|
|
|
|
|
[]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-28 08:58:17 +02:00
|
|
|
|
const filterChips = useMemo(() => {
|
|
|
|
|
|
const chips = []
|
|
|
|
|
|
|
2026-05-06 17:15:44 +02:00
|
|
|
|
;(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 })),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-28 08:58:17 +02:00
|
|
|
|
;(filters.focus_area_ids || []).forEach((id) => {
|
|
|
|
|
|
const opt = focusOptions.find((o) => String(o.id) === String(id))
|
|
|
|
|
|
chips.push({
|
|
|
|
|
|
key: `fa-${id}`,
|
2026-05-06 17:15:44 +02:00
|
|
|
|
label: `Fokus (ODER, älter): ${opt?.label ?? id}`,
|
2026-04-28 08:58:17 +02:00
|
|
|
|
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)),
|
|
|
|
|
|
})),
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-06 13:52:24 +02:00
|
|
|
|
;(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 })),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-28 08:58:17 +02:00
|
|
|
|
return chips
|
|
|
|
|
|
}, [
|
|
|
|
|
|
filters,
|
|
|
|
|
|
focusOptions,
|
|
|
|
|
|
styleOptions,
|
|
|
|
|
|
trainingTypeOptions,
|
|
|
|
|
|
targetGroupOptions,
|
|
|
|
|
|
skillOptions,
|
|
|
|
|
|
visibilityOptions,
|
|
|
|
|
|
statusOptions,
|
|
|
|
|
|
])
|
|
|
|
|
|
|
2026-04-28 15:24:05 +02:00
|
|
|
|
/** 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])
|
|
|
|
|
|
|
2026-04-27 18:25:23 +02:00
|
|
|
|
const queryBase = useMemo(() => {
|
|
|
|
|
|
const q = {}
|
|
|
|
|
|
const n = (v) => (v === '' || v == null ? undefined : Number(v))
|
2026-04-28 08:02:08 +02:00
|
|
|
|
const ids = (arr) =>
|
|
|
|
|
|
Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined
|
2026-05-06 17:15:44 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-04-28 08:02:08 +02:00
|
|
|
|
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
|
2026-04-27 18:25:23 +02:00
|
|
|
|
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)
|
2026-04-28 08:02:08 +02:00
|
|
|
|
if (filters.visibility_any?.length) q.visibility_any = [...filters.visibility_any]
|
|
|
|
|
|
if (filters.status_any?.length) q.status_any = [...filters.status_any]
|
2026-05-06 13:52:24 +02:00
|
|
|
|
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
|
2026-04-27 18:25:23 +02:00
|
|
|
|
if (debouncedSearch) q.search = debouncedSearch
|
|
|
|
|
|
if (debouncedAiSearch) q.ai_search = debouncedAiSearch
|
|
|
|
|
|
return q
|
|
|
|
|
|
}, [filters, debouncedSearch, debouncedAiSearch])
|
|
|
|
|
|
|
2026-05-05 22:18:04 +02:00
|
|
|
|
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])
|
|
|
|
|
|
|
2026-04-27 18:25:23 +02:00
|
|
|
|
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(() => {
|
2026-04-30 11:47:50 +02:00
|
|
|
|
if (!catalogsReady || pageTab !== 'list') return
|
2026-04-27 14:48:46 +02:00
|
|
|
|
let cancelled = false
|
|
|
|
|
|
const run = async () => {
|
2026-04-28 15:24:05 +02:00
|
|
|
|
setListFetching(true)
|
2026-04-27 14:48:46 +02:00
|
|
|
|
setOffset(0)
|
|
|
|
|
|
try {
|
2026-04-27 18:25:23 +02:00
|
|
|
|
const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset: 0 })
|
2026-04-27 14:48:46 +02:00
|
|
|
|
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 {
|
2026-04-28 15:24:05 +02:00
|
|
|
|
if (!cancelled) setListFetching(false)
|
2026-04-27 14:48:46 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
run()
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
2026-04-30 11:47:50 +02:00
|
|
|
|
}, [queryBase, catalogsReady, pageTab])
|
2026-04-27 14:27:25 +02:00
|
|
|
|
|
2026-04-27 14:48:46 +02:00
|
|
|
|
const loadMore = async () => {
|
|
|
|
|
|
if (loadingMore || !hasMore) return
|
|
|
|
|
|
setLoadingMore(true)
|
2026-04-27 14:27:25 +02:00
|
|
|
|
try {
|
2026-04-27 18:25:23 +02:00
|
|
|
|
const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset })
|
2026-04-27 14:48:46 +02:00
|
|
|
|
setExercises((prev) => [...prev, ...batch])
|
|
|
|
|
|
setHasMore(batch.length === PAGE_SIZE)
|
|
|
|
|
|
setOffset((o) => o + batch.length)
|
2026-04-27 14:27:25 +02:00
|
|
|
|
} catch (err) {
|
2026-04-27 14:48:46 +02:00
|
|
|
|
alert('Fehler: ' + err.message)
|
2026-04-27 14:27:25 +02:00
|
|
|
|
} finally {
|
2026-04-27 14:48:46 +02:00
|
|
|
|
setLoadingMore(false)
|
2026-04-27 14:27:25 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleDelete = async (exercise) => {
|
|
|
|
|
|
if (!confirm(`Übung "${exercise.title}" wirklich löschen?`)) return
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.deleteExercise(exercise.id)
|
2026-04-27 14:48:46 +02:00
|
|
|
|
setExercises((prev) => prev.filter((e) => e.id !== exercise.id))
|
2026-04-27 14:27:25 +02:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
alert('Fehler beim Löschen: ' + err.message)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-06 13:52:24 +02:00
|
|
|
|
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])
|
2026-04-28 08:21:55 +02:00
|
|
|
|
|
2026-05-05 22:18:04 +02:00
|
|
|
|
const openBulkModal = () => {
|
|
|
|
|
|
setBulkVisibility('')
|
|
|
|
|
|
setBulkStatus('')
|
|
|
|
|
|
setBulkClubSelect('')
|
|
|
|
|
|
setBulkClubManual('')
|
2026-05-06 11:02:46 +02:00
|
|
|
|
setBulkPatchFocusAreas(false)
|
|
|
|
|
|
setBulkFocusAreaIds([])
|
|
|
|
|
|
setBulkPatchStyleDirections(false)
|
|
|
|
|
|
setBulkStyleDirectionIds([])
|
|
|
|
|
|
setBulkPatchTrainingTypes(false)
|
|
|
|
|
|
setBulkTrainingTypeIds([])
|
|
|
|
|
|
setBulkPatchTargetGroups(false)
|
|
|
|
|
|
setBulkTargetGroupIds([])
|
2026-05-05 22:18:04 +02:00
|
|
|
|
setBulkModalOpen(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleBulkSubmit = async () => {
|
2026-05-06 11:02:46 +02:00
|
|
|
|
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“).'
|
|
|
|
|
|
)
|
2026-05-05 22:18:04 +02:00
|
|
|
|
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
|
2026-05-06 11:02:46 +02:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-05-05 22:18:04 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-06 11:02:46 +02:00
|
|
|
|
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])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-05 22:18:04 +02:00
|
|
|
|
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
|
2026-05-06 11:02:46 +02:00
|
|
|
|
if (bulkPatchFocusAreas) {
|
|
|
|
|
|
if (nextPrimaryFocusName == null) delete next.focus_area
|
|
|
|
|
|
else next.focus_area = nextPrimaryFocusName
|
|
|
|
|
|
}
|
2026-05-05 22:18:04 +02:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-30 11:47:50 +02:00
|
|
|
|
if (!catalogsReady && pageTab === 'list') {
|
2026-04-27 14:27:25 +02:00
|
|
|
|
return (
|
2026-05-06 11:24:44 +02:00
|
|
|
|
<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>
|
2026-04-27 14:27:25 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-05 12:41:59 +02:00
|
|
|
|
<div className="app-page">
|
2026-05-06 11:24:44 +02:00
|
|
|
|
<div className="exercises-page__header">
|
|
|
|
|
|
<h1 className="page-title exercises-page__title">Übungen</h1>
|
2026-04-30 11:47:50 +02:00
|
|
|
|
{pageTab === 'list' ? (
|
|
|
|
|
|
<Link to="/exercises/new" className="btn btn-primary">
|
|
|
|
|
|
+ Neu
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
) : (
|
2026-05-06 11:24:44 +02:00
|
|
|
|
<span aria-hidden="true" />
|
2026-04-30 11:47:50 +02:00
|
|
|
|
)}
|
2026-04-27 14:48:46 +02:00
|
|
|
|
</div>
|
2026-04-27 14:27:25 +02:00
|
|
|
|
|
2026-05-06 12:49:35 +02:00
|
|
|
|
<PageSectionNav
|
|
|
|
|
|
ariaLabel="Übungen Bereiche"
|
|
|
|
|
|
value={pageTab}
|
|
|
|
|
|
onChange={setPageTab}
|
|
|
|
|
|
items={EXERCISES_PAGE_TABS}
|
|
|
|
|
|
className="exercises-page-toolbar-tabs"
|
|
|
|
|
|
/>
|
2026-04-30 11:47:50 +02:00
|
|
|
|
|
|
|
|
|
|
{pageTab === 'progression' ? (
|
|
|
|
|
|
<ExerciseProgressionGraphPanel />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
2026-05-06 11:24:44 +02:00
|
|
|
|
<div className="card exercise-search-bar">
|
2026-04-28 08:21:55 +02:00
|
|
|
|
<label className="form-label">Volltextsuche (Titel, Ziel, …)</label>
|
2026-04-28 15:24:05 +02:00
|
|
|
|
<datalist id="exercise-search-titles">
|
|
|
|
|
|
{searchTitleSuggestions.map((t) => (
|
|
|
|
|
|
<option key={t} value={t} />
|
|
|
|
|
|
))}
|
|
|
|
|
|
</datalist>
|
2026-04-28 08:21:55 +02:00
|
|
|
|
<input
|
|
|
|
|
|
type="search"
|
2026-05-06 11:24:44 +02:00
|
|
|
|
className="form-input exercise-search-bar__primary"
|
2026-04-28 08:21:55 +02:00
|
|
|
|
placeholder="Suchbegriffe…"
|
|
|
|
|
|
value={searchInput}
|
|
|
|
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
2026-04-28 15:24:05 +02:00
|
|
|
|
autoComplete="on"
|
|
|
|
|
|
name="exercise-fulltext-search"
|
|
|
|
|
|
list="exercise-search-titles"
|
|
|
|
|
|
enterKeyHint="search"
|
2026-04-28 08:21:55 +02:00
|
|
|
|
/>
|
|
|
|
|
|
<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)}
|
2026-04-28 15:24:05 +02:00
|
|
|
|
autoComplete="on"
|
|
|
|
|
|
name="exercise-ai-search"
|
|
|
|
|
|
list="exercise-search-titles"
|
|
|
|
|
|
enterKeyHint="search"
|
2026-04-28 08:21:55 +02:00
|
|
|
|
/>
|
|
|
|
|
|
<div className="exercise-search-bar__actions">
|
|
|
|
|
|
<button type="button" className="btn btn-secondary exercise-filter-trigger" onClick={() => setFilterModalOpen(true)}>
|
|
|
|
|
|
Filter
|
2026-04-28 08:58:17 +02:00
|
|
|
|
{filterChips.length > 0 ? (
|
2026-04-28 08:21:55 +02:00
|
|
|
|
<span className="exercise-filter-badge" aria-hidden>
|
2026-04-28 08:58:17 +02:00
|
|
|
|
{filterChips.length}
|
2026-04-28 08:21:55 +02:00
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</button>
|
2026-04-28 08:58:17 +02:00
|
|
|
|
{filterChips.length > 0 ? (
|
2026-04-28 08:21:55 +02:00
|
|
|
|
<button type="button" className="btn" onClick={resetAllFilters}>
|
2026-04-28 08:58:17 +02:00
|
|
|
|
Alle entfernen
|
2026-04-28 08:21:55 +02:00
|
|
|
|
</button>
|
|
|
|
|
|
) : null}
|
2026-04-27 14:48:46 +02:00
|
|
|
|
</div>
|
2026-04-28 08:58:17 +02:00
|
|
|
|
{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}
|
2026-05-06 11:24:44 +02:00
|
|
|
|
<p className="exercise-search-hint">
|
2026-04-28 15:24:05 +02:00
|
|
|
|
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.
|
2026-05-05 22:18:04 +02:00
|
|
|
|
{exercises.length > 0 ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{' '}
|
2026-05-06 11:24:44 +02:00
|
|
|
|
<button type="button" className="btn btn-secondary btn-small" onClick={toggleSelectAllPage}>
|
2026-05-05 22:18:04 +02:00
|
|
|
|
{allOnPageSelected ? 'Auswahl auf dieser Seite aufheben' : 'Alle auf dieser Seite auswählen'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : null}
|
2026-04-28 08:21:55 +02:00
|
|
|
|
</p>
|
2026-04-27 14:48:46 +02:00
|
|
|
|
</div>
|
2026-04-27 14:27:25 +02:00
|
|
|
|
|
2026-05-05 22:18:04 +02:00
|
|
|
|
{selectedIds.size > 0 ? (
|
2026-05-06 11:24:44 +02:00
|
|
|
|
<div className="card exercise-bulk-toolbar">
|
2026-05-05 22:18:04 +02:00
|
|
|
|
<strong>{selectedIds.size} ausgewählt</strong>
|
2026-05-06 11:24:44 +02:00
|
|
|
|
<button type="button" className="btn btn-secondary btn-small" onClick={clearSelection}>
|
2026-05-05 22:18:04 +02:00
|
|
|
|
Auswahl aufheben
|
|
|
|
|
|
</button>
|
2026-05-06 11:24:44 +02:00
|
|
|
|
<button type="button" className="btn btn-primary btn-small" onClick={openBulkModal}>
|
2026-05-06 11:02:46 +02:00
|
|
|
|
Massenänderung…
|
2026-05-05 22:18:04 +02:00
|
|
|
|
</button>
|
2026-05-06 11:24:44 +02:00
|
|
|
|
<span className="exercise-bulk-toolbar__meta">
|
2026-05-05 22:18:04 +02:00
|
|
|
|
Bis zu {BULK_MAX_IDS} pro Anfrage. Für „Verein“ ohne Auswahl: aktiver Vereinskontext (
|
|
|
|
|
|
<code>X-Active-Club-Id</code>
|
|
|
|
|
|
).
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
2026-04-28 08:21:55 +02:00
|
|
|
|
{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">
|
2026-05-06 11:24:44 +02:00
|
|
|
|
<p className="muted" style={{ marginTop: 0, marginBottom: '14px' }}>
|
2026-05-06 17:15:44 +02:00
|
|
|
|
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>.
|
2026-04-28 08:21:55 +02:00
|
|
|
|
</p>
|
2026-04-28 08:58:17 +02:00
|
|
|
|
|
|
|
|
|
|
<section className="exercise-filter-section">
|
|
|
|
|
|
<h4 className="exercise-filter-section-title">Zuordnung</h4>
|
2026-05-06 17:15:44 +02:00
|
|
|
|
<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' }}>
|
2026-04-28 08:58:17 +02:00
|
|
|
|
<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>
|
2026-04-28 08:21:55 +02:00
|
|
|
|
</div>
|
2026-04-28 08:58:17 +02:00
|
|
|
|
</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">
|
2026-04-28 08:21:55 +02:00
|
|
|
|
<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 …"
|
|
|
|
|
|
/>
|
2026-04-28 08:58:17 +02:00
|
|
|
|
<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>
|
2026-04-28 08:21:55 +02:00
|
|
|
|
</div>
|
2026-04-28 08:58:17 +02:00
|
|
|
|
</section>
|
|
|
|
|
|
|
2026-05-06 13:52:24 +02:00
|
|
|
|
<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' }}>
|
2026-05-06 17:15:44 +02:00
|
|
|
|
<label
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
gap: '10px',
|
|
|
|
|
|
cursor: filters.focus_only_without ? 'not-allowed' : 'pointer',
|
|
|
|
|
|
opacity: filters.focus_only_without ? 0.55 : 1,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-05-06 13:52:24 +02:00
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
2026-05-06 17:15:44 +02:00
|
|
|
|
disabled={!!filters.focus_only_without}
|
2026-05-06 13:52:24 +02:00
|
|
|
|
checked={!!filters.exclude_without_focus}
|
2026-05-06 17:15:44 +02:00
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setFilters((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
exclude_without_focus: e.target.checked,
|
|
|
|
|
|
...(e.target.checked ? { focus_only_without: false } : {}),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
2026-05-06 13:52:24 +02:00
|
|
|
|
/>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-04-28 08:58:17 +02:00
|
|
|
|
<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">
|
2026-04-28 08:21:55 +02:00
|
|
|
|
<div>
|
2026-04-28 08:58:17 +02:00
|
|
|
|
<label className="form-label">Sichtbarkeit</label>
|
|
|
|
|
|
<MultiSelectCombo
|
|
|
|
|
|
value={filters.visibility_any}
|
|
|
|
|
|
onChange={(v) => setFilters({ ...filters, visibility_any: v })}
|
|
|
|
|
|
options={visibilityOptions}
|
|
|
|
|
|
placeholder="Sichtbarkeit wählen …"
|
|
|
|
|
|
/>
|
2026-04-28 08:21:55 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
2026-04-28 08:58:17 +02:00
|
|
|
|
<label className="form-label">Status</label>
|
|
|
|
|
|
<MultiSelectCombo
|
|
|
|
|
|
value={filters.status_any}
|
|
|
|
|
|
onChange={(v) => setFilters({ ...filters, status_any: v })}
|
|
|
|
|
|
options={statusOptions}
|
|
|
|
|
|
placeholder="Status wählen …"
|
|
|
|
|
|
/>
|
2026-04-28 08:21:55 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-28 08:58:17 +02:00
|
|
|
|
</section>
|
2026-04-28 08:21:55 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="exercise-filter-modal__footer">
|
2026-05-06 13:52:24 +02:00
|
|
|
|
<button type="button" className="btn btn-secondary" disabled={savingExercisePrefs} onClick={handleSaveExerciseFilterPrefs}>
|
|
|
|
|
|
{savingExercisePrefs ? 'Speichern…' : 'Als Standard speichern'}
|
|
|
|
|
|
</button>
|
2026-04-28 08:21:55 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-05 22:18:04 +02:00
|
|
|
|
{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">
|
2026-05-06 11:02:46 +02:00
|
|
|
|
Massenänderung
|
2026-05-05 22:18:04 +02:00
|
|
|
|
</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">
|
2026-05-06 11:24:44 +02:00
|
|
|
|
<p className="muted" style={{ marginTop: 0 }}>
|
2026-05-05 22:18:04 +02:00
|
|
|
|
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>
|
2026-05-06 11:24:44 +02:00
|
|
|
|
<p className="form-sub" style={{ marginTop: 0, marginBottom: '14px' }}>
|
2026-05-06 11:02:46 +02:00
|
|
|
|
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>
|
2026-05-05 22:18:04 +02:00
|
|
|
|
<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>
|
2026-05-06 11:02:46 +02:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-05-05 22:18:04 +02:00
|
|
|
|
</div>
|
2026-05-06 11:24:44 +02:00
|
|
|
|
<div className="exercise-filter-modal__footer">
|
2026-05-05 22:18:04 +02:00
|
|
|
|
<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}
|
|
|
|
|
|
|
2026-04-28 15:24:05 +02:00
|
|
|
|
{listFetching && exercises.length === 0 ? (
|
2026-05-06 11:24:44 +02:00
|
|
|
|
<div className="card empty-state" style={{ padding: '2rem 1rem' }}>
|
|
|
|
|
|
<div className="spinner" />
|
|
|
|
|
|
<p className="muted" style={{ marginTop: '12px' }}>
|
|
|
|
|
|
Lade Übungen…
|
|
|
|
|
|
</p>
|
2026-04-28 15:24:05 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
) : exercises.length === 0 ? (
|
2026-04-27 14:48:46 +02:00
|
|
|
|
<div className="card">
|
2026-05-06 11:24:44 +02:00
|
|
|
|
<p className="exercises-empty-text">Keine Übungen gefunden.</p>
|
2026-04-27 14:48:46 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
2026-04-28 15:24:05 +02:00
|
|
|
|
{listFetching ? (
|
2026-05-06 11:24:44 +02:00
|
|
|
|
<p className="exercises-meta-line exercises-meta-line--muted">Aktualisiere Treffer…</p>
|
2026-04-28 15:24:05 +02:00
|
|
|
|
) : null}
|
2026-05-06 11:24:44 +02:00
|
|
|
|
<p className="exercises-meta-line">
|
2026-04-27 14:48:46 +02:00
|
|
|
|
{exercises.length} angezeigt
|
|
|
|
|
|
{hasMore ? ' · es gibt weitere Einträge' : ''}
|
|
|
|
|
|
</p>
|
2026-05-06 11:24:44 +02:00
|
|
|
|
<div className="exercises-list-grid">
|
2026-05-06 12:20:22 +02:00
|
|
|
|
{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>
|
2026-04-27 14:27:25 +02:00
|
|
|
|
</div>
|
2026-05-06 12:41:04 +02:00
|
|
|
|
<div className="exercise-card__footer">
|
|
|
|
|
|
<ExerciseCardScopeStatus exercise={exercise} />
|
|
|
|
|
|
<div className="exercise-card__actions exercise-card__actions--icons">
|
2026-05-06 12:20:22 +02:00
|
|
|
|
<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>
|
2026-05-06 13:52:24 +02:00
|
|
|
|
{canUserRequestExerciseDelete(user, exercise) ? (
|
2026-05-06 12:20:22 +02:00
|
|
|
|
<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>
|
2026-05-06 13:52:24 +02:00
|
|
|
|
) : null}
|
2026-05-06 12:41:04 +02:00
|
|
|
|
</div>
|
2026-05-05 22:18:04 +02:00
|
|
|
|
</div>
|
2026-04-27 14:27:25 +02:00
|
|
|
|
</div>
|
2026-05-06 12:20:22 +02:00
|
|
|
|
)
|
|
|
|
|
|
})}
|
2026-04-27 14:27:25 +02:00
|
|
|
|
</div>
|
2026-04-27 14:48:46 +02:00
|
|
|
|
{hasMore && (
|
2026-05-06 11:24:44 +02:00
|
|
|
|
<div className="exercises-load-more">
|
2026-04-27 14:48:46 +02:00
|
|
|
|
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
|
|
|
|
|
|
{loadingMore ? 'Laden…' : 'Mehr laden'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-04-30 11:47:50 +02:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-04-27 14:27:25 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default ExercisesListPage
|