shinkan-jinkendo/frontend/src/components/exercises/ExercisesListPageRoot.jsx
Lars b2157d8a40
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
Update Planning Exercise Suggestion and Context Handling
- 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.
2026-05-23 10:42:17 +02:00

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