All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s
Test Suite / pytest-backend (pull_request) Successful in 37s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 13s
Test Suite / k6 /health Baseline (pull_request) Successful in 33s
Test Suite / playwright-tests (pull_request) Successful in 1m15s
- Incremented version to 0.8.183, reflecting the implementation of Phase C1 enhancements. - Added support for progression graph auto-matching and variant-aware successors in exercise suggestions. - Updated request and response structures to include `anchor_exercise_variant_id`, `progression_graph_name`, and `suggested_variant_id`. - Enhanced frontend components to integrate planning AI search capabilities, including a new modal for exercise creation and improved context display in the exercise list. - Updated changelog to document these significant improvements in planning AI functionality.
935 lines
34 KiB
JavaScript
935 lines
34 KiB
JavaScript
import React, { useState, useEffect, useMemo, useCallback, useRef, lazy, Suspense } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import api from '../../utils/api'
|
|
import { useAuth } from '../../context/AuthContext'
|
|
import { activeClubMemberships, getTenantClubDependencyKey } from '../../utils/activeClub'
|
|
import PageSectionNav from '../PageSectionNav'
|
|
import ExerciseListCard from './ExerciseListCard'
|
|
import ExerciseListFilterModal from './ExerciseListFilterModal'
|
|
import ExerciseListBulkModal from './ExerciseListBulkModal'
|
|
import ExerciseListSearchBar from './ExerciseListSearchBar'
|
|
import ExerciseListBulkToolbar from './ExerciseListBulkToolbar'
|
|
import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal'
|
|
import ExercisePeekModal from '../ExercisePeekModal'
|
|
import NavStateLink from '../NavStateLink'
|
|
import { ExerciseAiQuickCreateTeaser } from '../ExerciseAiQuickCreateOffer'
|
|
import ExerciseAiQuickCreateModal from './ExerciseAiQuickCreateModal'
|
|
import ExerciseAiSuggestPreviewModal from '../ExerciseAiSuggestPreviewModal'
|
|
import {
|
|
buildQuickCreateAiPreview,
|
|
buildQuickCreateExercisePayloadFromDraft,
|
|
aiPreviewToQuickCreateDraft,
|
|
} from '../../utils/exerciseAiQuickCreate'
|
|
import { useExerciseAiQuickCreateFields } from '../../hooks/useExerciseAiQuickCreateFields'
|
|
import { buildExercisesListReturnContext } from '../../utils/navReturnContext'
|
|
import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips'
|
|
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
|
|
import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../../utils/exerciseListQuery'
|
|
import { readExerciseListSessionState, writeExerciseListSessionState } from '../../utils/exerciseListSessionState'
|
|
import {
|
|
mergeSelectedWithListEntries,
|
|
normalizeSelectedEntries,
|
|
snapshotExerciseForSelection,
|
|
} from '../../utils/exerciseListSelection'
|
|
import { useExerciseListCatalogsAndQuery } from '../../hooks/useExerciseListCatalogsAndQuery'
|
|
import { usePlanningExerciseSuggestSearch } from '../../hooks/usePlanningExerciseSuggestSearch'
|
|
import {
|
|
INITIAL_EXERCISE_LIST_FILTERS,
|
|
mergeExerciseListPrefsFromApi,
|
|
compactExerciseListPrefsPayload,
|
|
} from '../../constants/exerciseListFilters'
|
|
|
|
const ExerciseProgressionGraphPanel = lazy(() => import('../ExerciseProgressionGraphPanel'))
|
|
|
|
const BULK_MAX_IDS = 500
|
|
const EXERCISES_PAGE_TABS = [
|
|
{ id: 'list', label: 'Liste' },
|
|
{ id: 'progression', label: 'Progressionsgraphen' },
|
|
]
|
|
|
|
function ExercisesListPageRoot() {
|
|
const navigate = useNavigate()
|
|
const { user, checkAuth } = useAuth()
|
|
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
|
const isSuperadmin = user?.role === 'superadmin'
|
|
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
|
|
|
|
const [mineOnly, setMineOnly] = useState(() => {
|
|
try {
|
|
const sp = new URLSearchParams(window.location.search)
|
|
return sp.get('mine') === '1' || sp.get('created_by_me') === '1'
|
|
} catch {
|
|
return 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 [selectedEntries, setSelectedEntries] = useState(() => [])
|
|
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([])
|
|
const [peekExercise, setPeekExercise] = useState(null)
|
|
const [saveModuleModalOpen, setSaveModuleModalOpen] = useState(false)
|
|
const [aiQuickCreateEnabled, setAiQuickCreateEnabled] = useState(false)
|
|
const [aiQuickCreateModalOpen, setAiQuickCreateModalOpen] = useState(false)
|
|
const [quickSaving, setQuickSaving] = useState(false)
|
|
const [quickAiError, setQuickAiError] = useState('')
|
|
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
|
|
|
|
const planningKi = usePlanningExerciseSuggestSearch({
|
|
enabled: pageTab === 'list' && aiQuickCreateEnabled,
|
|
})
|
|
|
|
const {
|
|
title: quickTitle,
|
|
sketch: quickSketch,
|
|
focusAreaId: quickFocusAreaId,
|
|
setTitle: setQuickTitle,
|
|
setSketch: setQuickSketch,
|
|
setFocusAreaId: setQuickFocusAreaId,
|
|
} = useExerciseAiQuickCreateFields(
|
|
aiQuickCreateModalOpen
|
|
? planningKi.submittedQuery || planningKi.searchInput || debouncedSearch
|
|
: debouncedSearch,
|
|
{
|
|
enabled: pageTab === 'list' && aiQuickCreateModalOpen,
|
|
},
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!user?.id) return
|
|
if (prefsAppliedRef.current) return
|
|
const session = readExerciseListSessionState()
|
|
const merged = mergeExerciseListPrefsFromApi(user.exercise_list_prefs)
|
|
const filtersFromSession = session?.filters
|
|
setFilters(applyDashboardExerciseListUrl(filtersFromSession ?? merged))
|
|
if (session) {
|
|
setSearchInput(session.searchInput || '')
|
|
setAiSearchInput(session.aiSearchInput || '')
|
|
setMineOnly(session.mineOnly)
|
|
setSelectedEntries(normalizeSelectedEntries(session.selectedEntries))
|
|
}
|
|
try {
|
|
const sp = new URLSearchParams(window.location.search)
|
|
if (sp.get('mine') === '1' || sp.get('created_by_me') === '1') setMineOnly(true)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
prefsAppliedRef.current = true
|
|
}, [user?.id, user?.exercise_list_prefs])
|
|
|
|
useEffect(() => {
|
|
if (!prefsAppliedRef.current) return
|
|
writeExerciseListSessionState({
|
|
filters,
|
|
searchInput,
|
|
aiSearchInput,
|
|
mineOnly,
|
|
selectedEntries,
|
|
})
|
|
}, [filters, searchInput, aiSearchInput, mineOnly, selectedEntries])
|
|
|
|
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 queryBase = useMemo(
|
|
() => buildExerciseListQueryBase(filters, debouncedSearch, debouncedAiSearch, mineOnly),
|
|
[filters, debouncedSearch, debouncedAiSearch, mineOnly]
|
|
)
|
|
|
|
const {
|
|
catalogs,
|
|
catalogsReady,
|
|
exercises,
|
|
setExercises,
|
|
listFetching,
|
|
loadingMore,
|
|
hasMore,
|
|
loadMore,
|
|
} = useExerciseListCatalogsAndQuery({
|
|
queryBase,
|
|
pageTab,
|
|
tenantClubDepKey,
|
|
skipListFetch: aiQuickCreateEnabled,
|
|
})
|
|
|
|
const listExercises = exercises
|
|
const listFetchingResolved = listFetching
|
|
const exercisesForDisplay = aiQuickCreateEnabled ? planningKi.rows : listExercises
|
|
const listFetchingDisplay = aiQuickCreateEnabled ? planningKi.loading : listFetchingResolved
|
|
const hasMoreDisplay = aiQuickCreateEnabled ? false : hasMore
|
|
|
|
const selectedIds = useMemo(
|
|
() => new Set(selectedEntries.map((e) => Number(e.id)).filter((id) => Number.isFinite(id) && id > 0)),
|
|
[selectedEntries]
|
|
)
|
|
|
|
const selectedExercisesDisplay = useMemo(
|
|
() => mergeSelectedWithListEntries(selectedEntries, exercisesForDisplay),
|
|
[selectedEntries, exercisesForDisplay],
|
|
)
|
|
|
|
const filterResultExercises = useMemo(
|
|
() => exercisesForDisplay.filter((e) => !selectedIds.has(Number(e.id))),
|
|
[exercisesForDisplay, selectedIds],
|
|
)
|
|
|
|
const showKiCreateTeaser =
|
|
aiQuickCreateEnabled &&
|
|
planningKi.hasSearched &&
|
|
!planningKi.loading &&
|
|
catalogsReady &&
|
|
filterResultExercises.length > 0
|
|
|
|
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: skillCatalogPathLabel(s) || 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(
|
|
() =>
|
|
buildExerciseListFilterChips({
|
|
mineOnly,
|
|
setMineOnly,
|
|
filters,
|
|
setFilters,
|
|
focusOptions,
|
|
styleOptions,
|
|
trainingTypeOptions,
|
|
targetGroupOptions,
|
|
skillOptions,
|
|
visibilityOptions,
|
|
statusOptions,
|
|
}),
|
|
[
|
|
mineOnly,
|
|
filters,
|
|
focusOptions,
|
|
styleOptions,
|
|
trainingTypeOptions,
|
|
targetGroupOptions,
|
|
skillOptions,
|
|
visibilityOptions,
|
|
statusOptions,
|
|
]
|
|
)
|
|
|
|
/** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */
|
|
const searchTitleSuggestions = useMemo(() => {
|
|
const titles = listExercises.map((e) => (e.title || '').trim()).filter(Boolean)
|
|
return [...new Set(titles)].slice(0, 80)
|
|
}, [listExercises])
|
|
|
|
const clubNameById = useMemo(() => {
|
|
const m = {}
|
|
for (const c of activeClubMemberships(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((exercise) => {
|
|
const snap = snapshotExerciseForSelection(exercise)
|
|
if (!snap) return
|
|
setSelectedEntries((prev) => {
|
|
const idx = prev.findIndex((e) => Number(e.id) === snap.id)
|
|
if (idx >= 0) return prev.filter((_, i) => i !== idx)
|
|
return [...prev, snap]
|
|
})
|
|
}, [])
|
|
|
|
const clearSelection = useCallback(() => setSelectedEntries([]), [])
|
|
|
|
const toggleSelectAllPage = useCallback(() => {
|
|
setSelectedEntries((prev) => {
|
|
const ids = new Set(prev.map((e) => Number(e.id)))
|
|
const pageIds = filterResultExercises.map((e) => Number(e.id))
|
|
const allSel = pageIds.length > 0 && pageIds.every((id) => ids.has(id))
|
|
if (allSel) {
|
|
const remove = new Set(pageIds)
|
|
return prev.filter((e) => !remove.has(Number(e.id)))
|
|
}
|
|
const next = [...prev]
|
|
for (const ex of filterResultExercises) {
|
|
const snap = snapshotExerciseForSelection(ex)
|
|
if (!snap || ids.has(snap.id)) continue
|
|
ids.add(snap.id)
|
|
next.push(snap)
|
|
}
|
|
return next
|
|
})
|
|
}, [filterResultExercises])
|
|
|
|
const allOnPageSelected = useMemo(
|
|
() =>
|
|
filterResultExercises.length > 0 &&
|
|
filterResultExercises.every((e) => selectedIds.has(Number(e.id))),
|
|
[filterResultExercises, selectedIds]
|
|
)
|
|
|
|
const selectedExercisesInListOrder = selectedExercisesDisplay
|
|
|
|
const exercisesModuleReturnContext = useMemo(() => buildExercisesListReturnContext(), [])
|
|
|
|
const runQuickCreateAiSuggest = useCallback(async () => {
|
|
const title = (quickTitle || '').trim()
|
|
if (title.length < 3) {
|
|
alert('Titel: mindestens 3 Zeichen.')
|
|
return
|
|
}
|
|
const sketch = (quickSketch || '').trim()
|
|
|
|
const focusId = parseInt(String(quickFocusAreaId).trim(), 10)
|
|
if (!Number.isFinite(focusId) || focusId < 1) {
|
|
alert('Bitte einen Fokusbereich wählen.')
|
|
return
|
|
}
|
|
|
|
const focusRow = (catalogs.focusAreas || []).find((x) => Number(x.id) === focusId)
|
|
const focusHint = (focusRow?.name || '').trim()
|
|
|
|
setQuickAiError('')
|
|
setQuickCreateDraft(null)
|
|
setQuickSaving(true)
|
|
try {
|
|
const aiRes = await api.suggestExerciseAi({
|
|
title,
|
|
goal: sketch || undefined,
|
|
execution: '',
|
|
preparation: '',
|
|
trainer_notes: '',
|
|
focus_area_hint: focusHint || undefined,
|
|
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
|
|
include_summary: true,
|
|
include_skills: true,
|
|
include_instructions: true,
|
|
})
|
|
|
|
const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: sketch })
|
|
if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) {
|
|
throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.')
|
|
}
|
|
setQuickCreateDraft(
|
|
aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }),
|
|
)
|
|
setAiQuickCreateModalOpen(false)
|
|
} catch (e) {
|
|
console.error(e)
|
|
const msg = e?.message || String(e)
|
|
setQuickAiError(msg)
|
|
alert(msg || 'KI-Vorschlag fehlgeschlagen')
|
|
} finally {
|
|
setQuickSaving(false)
|
|
}
|
|
}, [quickTitle, quickSketch, quickFocusAreaId, catalogs.focusAreas])
|
|
|
|
const applyQuickCreateDraft = useCallback(async () => {
|
|
if (!quickCreateDraft) return
|
|
|
|
setQuickSaving(true)
|
|
setQuickAiError('')
|
|
try {
|
|
const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft)
|
|
const created = await api.createExercise(payload)
|
|
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
|
|
setQuickCreateDraft(null)
|
|
setAiQuickCreateEnabled(false)
|
|
setExercises((prev) => [created, ...prev])
|
|
navigate(`/exercises/${created.id}/edit`, {
|
|
state: { returnContext: exercisesModuleReturnContext },
|
|
})
|
|
} catch (e) {
|
|
console.error(e)
|
|
const msg = e?.message || String(e)
|
|
setQuickAiError(msg)
|
|
alert(msg || 'Übung konnte nicht angelegt werden')
|
|
} finally {
|
|
setQuickSaving(false)
|
|
}
|
|
}, [quickCreateDraft, setExercises, navigate, exercisesModuleReturnContext])
|
|
|
|
const bulkVisibilityOptions = useMemo(() => {
|
|
const base = [
|
|
{ id: '', label: '— nicht ändern —' },
|
|
{ id: 'private', label: 'Privat' },
|
|
{ id: 'club', label: 'Verein' },
|
|
]
|
|
if (isSuperadmin) base.push({ id: 'official', label: 'Offiziell (global)' })
|
|
return base
|
|
}, [isSuperadmin])
|
|
|
|
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))
|
|
setSelectedEntries((prev) => prev.filter((e) => Number(e.id) !== Number(exercise.id)))
|
|
} catch (err) {
|
|
alert('Fehler beim Löschen: ' + err.message)
|
|
}
|
|
}
|
|
|
|
const resetAllFilters = useCallback(() => {
|
|
setMineOnly(false)
|
|
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 (Freigabelevel, 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' ? (
|
|
<div className="exercises-page__header-actions">
|
|
<label
|
|
className="exercises-ai-assistant-toggle"
|
|
title="Planungs-KI-Suche in der Bibliothek und Neuanlage per KI-Dialog (ohne normales Formular „+ Neu“)"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={aiQuickCreateEnabled}
|
|
onChange={(e) => {
|
|
setAiQuickCreateEnabled(e.target.checked)
|
|
if (!e.target.checked) setAiQuickCreateModalOpen(false)
|
|
}}
|
|
/>
|
|
<span className="exercises-ai-assistant-toggle__track" aria-hidden="true" />
|
|
<span>Neu mit KI-Assistent</span>
|
|
</label>
|
|
{aiQuickCreateEnabled ? (
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
onClick={() => setAiQuickCreateModalOpen(true)}
|
|
>
|
|
+ Neue Übung mit KI
|
|
</button>
|
|
) : (
|
|
<NavStateLink
|
|
to="/exercises/new"
|
|
returnContext={exercisesModuleReturnContext}
|
|
className="btn btn-primary"
|
|
>
|
|
+ Neu
|
|
</NavStateLink>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<span aria-hidden="true" />
|
|
)}
|
|
</div>
|
|
|
|
<PageSectionNav
|
|
ariaLabel="Übungen Bereiche"
|
|
value={pageTab}
|
|
onChange={setPageTab}
|
|
items={EXERCISES_PAGE_TABS}
|
|
className="exercises-page-toolbar-tabs"
|
|
/>
|
|
|
|
{pageTab === 'progression' ? (
|
|
<Suspense
|
|
fallback={
|
|
<div className="card empty-state" style={{ padding: '2rem 1rem' }}>
|
|
<div className="spinner" />
|
|
<p className="muted" style={{ marginTop: '12px' }}>
|
|
Lade Progressionsgraphen…
|
|
</p>
|
|
</div>
|
|
}
|
|
>
|
|
<ExerciseProgressionGraphPanel />
|
|
</Suspense>
|
|
) : (
|
|
<>
|
|
<ExerciseListSearchBar
|
|
kiSearchMode={aiQuickCreateEnabled}
|
|
planningSearchInput={planningKi.searchInput}
|
|
onPlanningSearchInputChange={planningKi.setSearchInput}
|
|
onSubmitPlanningSearch={() => planningKi.submitSearch()}
|
|
planningSearchLoading={planningKi.loading}
|
|
planningHasSearched={planningKi.hasSearched}
|
|
planningRetrievalPhase={planningKi.retrievalPhase}
|
|
planningContextSummary={planningKi.contextSummary}
|
|
planningTargetProfileSummary={planningKi.targetProfileSummary}
|
|
planningSearchError={planningKi.error}
|
|
searchTitleSuggestions={searchTitleSuggestions}
|
|
searchInput={searchInput}
|
|
onSearchInputChange={setSearchInput}
|
|
aiSearchInput={aiSearchInput}
|
|
onAiSearchInputChange={setAiSearchInput}
|
|
mineOnly={mineOnly}
|
|
onToggleMineOnly={() => setMineOnly((v) => !v)}
|
|
onOpenFilter={() => setFilterModalOpen(true)}
|
|
filterChips={filterChips}
|
|
onResetAllFilters={resetAllFilters}
|
|
exerciseCount={exercisesForDisplay.length}
|
|
allOnPageSelected={allOnPageSelected}
|
|
onToggleSelectAllPage={toggleSelectAllPage}
|
|
/>
|
|
|
|
{showKiCreateTeaser ? (
|
|
<ExerciseAiQuickCreateTeaser
|
|
onExpand={() => setAiQuickCreateModalOpen(true)}
|
|
disabled={quickSaving}
|
|
/>
|
|
) : null}
|
|
|
|
<ExerciseListBulkToolbar
|
|
selectedCount={selectedIds.size}
|
|
bulkMaxIds={BULK_MAX_IDS}
|
|
onClearSelection={clearSelection}
|
|
onOpenBulkModal={openBulkModal}
|
|
onOpenSaveModuleModal={() => setSaveModuleModalOpen(true)}
|
|
/>
|
|
|
|
<ExerciseListFilterModal
|
|
open={filterModalOpen}
|
|
onClose={() => setFilterModalOpen(false)}
|
|
filters={filters}
|
|
setFilters={setFilters}
|
|
focusOptions={focusOptions}
|
|
styleOptions={styleOptions}
|
|
trainingTypeOptions={trainingTypeOptions}
|
|
targetGroupOptions={targetGroupOptions}
|
|
skillsCatalog={catalogs.skills}
|
|
visibilityOptions={visibilityOptions}
|
|
statusOptions={statusOptions}
|
|
savingExercisePrefs={savingExercisePrefs}
|
|
onSaveStandard={handleSaveExerciseFilterPrefs}
|
|
onResetAll={resetAllFilters}
|
|
/>
|
|
|
|
<ExerciseListBulkModal
|
|
open={bulkModalOpen}
|
|
onClose={() => setBulkModalOpen(false)}
|
|
onSubmit={handleBulkSubmit}
|
|
bulkSubmitting={bulkSubmitting}
|
|
selectedCount={selectedIds.size}
|
|
bulkMaxIds={BULK_MAX_IDS}
|
|
user={user}
|
|
isPlatformAdmin={isPlatformAdmin}
|
|
statusOptions={statusOptions}
|
|
bulkVisibilityOptions={bulkVisibilityOptions}
|
|
focusOptions={focusOptions}
|
|
styleOptions={styleOptions}
|
|
trainingTypeOptions={trainingTypeOptions}
|
|
targetGroupOptions={targetGroupOptions}
|
|
bulkVisibility={bulkVisibility}
|
|
setBulkVisibility={setBulkVisibility}
|
|
bulkStatus={bulkStatus}
|
|
setBulkStatus={setBulkStatus}
|
|
bulkClubSelect={bulkClubSelect}
|
|
setBulkClubSelect={setBulkClubSelect}
|
|
bulkClubManual={bulkClubManual}
|
|
setBulkClubManual={setBulkClubManual}
|
|
bulkPatchFocusAreas={bulkPatchFocusAreas}
|
|
setBulkPatchFocusAreas={setBulkPatchFocusAreas}
|
|
bulkFocusAreaIds={bulkFocusAreaIds}
|
|
setBulkFocusAreaIds={setBulkFocusAreaIds}
|
|
bulkPatchStyleDirections={bulkPatchStyleDirections}
|
|
setBulkPatchStyleDirections={setBulkPatchStyleDirections}
|
|
bulkStyleDirectionIds={bulkStyleDirectionIds}
|
|
setBulkStyleDirectionIds={setBulkStyleDirectionIds}
|
|
bulkPatchTrainingTypes={bulkPatchTrainingTypes}
|
|
setBulkPatchTrainingTypes={setBulkPatchTrainingTypes}
|
|
bulkTrainingTypeIds={bulkTrainingTypeIds}
|
|
setBulkTrainingTypeIds={setBulkTrainingTypeIds}
|
|
bulkPatchTargetGroups={bulkPatchTargetGroups}
|
|
setBulkPatchTargetGroups={setBulkPatchTargetGroups}
|
|
bulkTargetGroupIds={bulkTargetGroupIds}
|
|
setBulkTargetGroupIds={setBulkTargetGroupIds}
|
|
/>
|
|
|
|
<SaveSelectedExercisesAsModuleModal
|
|
open={saveModuleModalOpen}
|
|
onClose={() => setSaveModuleModalOpen(false)}
|
|
selectedExercises={selectedExercisesInListOrder}
|
|
returnContext={exercisesModuleReturnContext}
|
|
onSuccess={clearSelection}
|
|
/>
|
|
|
|
<ExercisePeekModal
|
|
open={peekExercise != null}
|
|
exerciseId={peekExercise?.id}
|
|
titleFallback={peekExercise?.title}
|
|
onClose={() => setPeekExercise(null)}
|
|
/>
|
|
|
|
{listFetchingDisplay && exercisesForDisplay.length === 0 && selectedEntries.length === 0 ? (
|
|
<div className="card empty-state" style={{ padding: '2rem 1rem' }}>
|
|
<div className="spinner" />
|
|
<p className="muted" style={{ marginTop: '12px' }}>
|
|
{aiQuickCreateEnabled ? 'Planungs-KI lädt Vorschläge…' : 'Lade Übungen…'}
|
|
</p>
|
|
</div>
|
|
) : exercisesForDisplay.length === 0 && selectedEntries.length === 0 ? (
|
|
<div className="card">
|
|
<p className="exercises-empty-text">
|
|
{aiQuickCreateEnabled
|
|
? planningKi.hasSearched
|
|
? 'Keine passenden Übungen — neue Übung mit KI anlegen?'
|
|
: 'Planungs-Anfrage formulieren und „Vorschläge laden“ klicken — oder „Neue Übung mit KI“ oben.'
|
|
: debouncedSearch.length >= 3
|
|
? 'Keine Übungen gefunden.'
|
|
: 'Keine Übungen gefunden — Suchbegriff eingeben oder Filter anpassen.'}
|
|
</p>
|
|
{aiQuickCreateEnabled && planningKi.hasSearched ? (
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
style={{ marginTop: '12px' }}
|
|
onClick={() => setAiQuickCreateModalOpen(true)}
|
|
>
|
|
Neue Übung mit KI …
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{selectedEntries.length > 0 ? (
|
|
<section className="exercises-selection-section" data-testid="exercises-selection-section">
|
|
<div className="exercises-selection-section__head">
|
|
<h2 className="exercises-selection-section__title">Auswahl ({selectedEntries.length})</h2>
|
|
<p className="exercises-selection-section__hint">
|
|
Bleibt sichtbar, auch wenn du den Filter wechselst — ideal für die Modul-Zusammenstellung.
|
|
</p>
|
|
</div>
|
|
<div className="exercises-list-grid exercises-list-grid--selection">
|
|
{selectedExercisesDisplay.map((exercise) => (
|
|
<ExerciseListCard
|
|
key={`sel-${exercise.id}`}
|
|
exercise={exercise}
|
|
user={user}
|
|
selectedIds={selectedIds}
|
|
toggleSelect={toggleSelect}
|
|
onDelete={handleDelete}
|
|
onPeek={(ex) => setPeekExercise({ id: ex.id, title: ex.title })}
|
|
selectionPinned
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
{filterResultExercises.length === 0 ? (
|
|
selectedEntries.length > 0 ? (
|
|
<p className="exercises-meta-line exercises-meta-line--muted">
|
|
Keine weiteren Treffer für den aktuellen Filter.
|
|
</p>
|
|
) : null
|
|
) : (
|
|
<>
|
|
{listFetchingDisplay ? (
|
|
<p className="exercises-meta-line exercises-meta-line--muted">Aktualisiere Treffer…</p>
|
|
) : null}
|
|
<p className="exercises-meta-line">
|
|
{filterResultExercises.length} Treffer
|
|
{aiQuickCreateEnabled ? ' (Planungs-KI)' : ''}
|
|
{selectedEntries.length > 0 ? ' (ohne bereits Ausgewählte)' : ''}
|
|
{hasMoreDisplay ? ' · es gibt weitere Einträge' : ''}
|
|
</p>
|
|
<div className="exercises-list-grid" data-testid="exercises-list-grid">
|
|
{filterResultExercises.map((exercise) => (
|
|
<ExerciseListCard
|
|
key={exercise.id}
|
|
exercise={exercise}
|
|
user={user}
|
|
selectedIds={selectedIds}
|
|
toggleSelect={toggleSelect}
|
|
onDelete={handleDelete}
|
|
onPeek={(ex) => setPeekExercise({ id: ex.id, title: ex.title })}
|
|
/>
|
|
))}
|
|
</div>
|
|
{hasMoreDisplay && (
|
|
<div className="exercises-load-more">
|
|
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
|
|
{loadingMore ? 'Laden…' : 'Mehr laden'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<ExerciseAiQuickCreateModal
|
|
open={aiQuickCreateModalOpen}
|
|
onClose={() => {
|
|
if (!quickSaving) setAiQuickCreateModalOpen(false)
|
|
}}
|
|
searchLabel={
|
|
planningKi.submittedQuery || planningKi.searchInput || debouncedSearch || undefined
|
|
}
|
|
title={quickTitle}
|
|
onTitleChange={setQuickTitle}
|
|
sketch={quickSketch}
|
|
onSketchChange={setQuickSketch}
|
|
focusAreaId={quickFocusAreaId}
|
|
onFocusAreaChange={setQuickFocusAreaId}
|
|
focusAreas={catalogs.focusAreas}
|
|
catalogsReady={catalogsReady}
|
|
busy={quickSaving}
|
|
error={quickAiError}
|
|
onRunAi={runQuickCreateAiSuggest}
|
|
/>
|
|
|
|
<ExerciseAiSuggestPreviewModal
|
|
draft={quickCreateDraft}
|
|
onDraftChange={setQuickCreateDraft}
|
|
onDiscard={() => setQuickCreateDraft(null)}
|
|
onApply={applyQuickCreateDraft}
|
|
focusAreas={catalogs.focusAreas}
|
|
skillsCatalog={catalogs.skills}
|
|
dialogTitle="Neue Übung — KI-Entwurf bearbeiten"
|
|
hint="Texte sind formatiert — passe sie an und lege die Übung als Entwurf an."
|
|
applyLabel={quickSaving ? 'Wird angelegt…' : 'Übung anlegen'}
|
|
applyDisabled={quickSaving}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default ExercisesListPageRoot
|