import React, { useState, useEffect, useMemo, useCallback, useRef, lazy, Suspense } from 'react' import { Link } 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 { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips' import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../../utils/exerciseListQuery' import { useExerciseListCatalogsAndQuery } from '../../hooks/useExerciseListCatalogsAndQuery' 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 { 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 [selectedIds, setSelectedIds] = useState(() => new Set()) const [bulkModalOpen, setBulkModalOpen] = useState(false) const [bulkVisibility, setBulkVisibility] = useState('') const [bulkStatus, setBulkStatus] = useState('') const [bulkClubSelect, setBulkClubSelect] = useState('') const [bulkClubManual, setBulkClubManual] = useState('') const [bulkSubmitting, setBulkSubmitting] = useState(false) const [bulkPatchFocusAreas, setBulkPatchFocusAreas] = useState(false) const [bulkFocusAreaIds, setBulkFocusAreaIds] = useState([]) const [bulkPatchStyleDirections, setBulkPatchStyleDirections] = useState(false) const [bulkStyleDirectionIds, setBulkStyleDirectionIds] = useState([]) const [bulkPatchTrainingTypes, setBulkPatchTrainingTypes] = useState(false) const [bulkTrainingTypeIds, setBulkTrainingTypeIds] = useState([]) const [bulkPatchTargetGroups, setBulkPatchTargetGroups] = useState(false) const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([]) useEffect(() => { if (!user?.id) return if (prefsAppliedRef.current) return const merged = mergeExerciseListPrefsFromApi(user.exercise_list_prefs) setFilters(applyDashboardExerciseListUrl(merged)) 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 (!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 }) useEffect(() => { setSelectedIds(new Set()) }, [queryBase]) const focusOptions = useMemo( () => catalogs.focusAreas.map((fa) => ({ id: fa.id, label: `${fa.icon || ''} ${fa.name || ''}`.trim(), })), [catalogs.focusAreas] ) const styleOptions = useMemo( () => catalogs.styleDirections.map((s) => ({ id: s.id, label: s.name || String(s.id) })), [catalogs.styleDirections] ) const trainingTypeOptions = useMemo( () => catalogs.trainingTypes.map((t) => ({ id: t.id, label: t.name || String(t.id) })), [catalogs.trainingTypes] ) const targetGroupOptions = useMemo( () => catalogs.targetGroups.map((g) => ({ id: g.id, label: g.name || String(g.id) })), [catalogs.targetGroups] ) const skillOptions = useMemo( () => catalogs.skills.map((s) => ({ id: s.id, label: s.name || String(s.id) })), [catalogs.skills] ) const visibilityOptions = useMemo( () => [ { id: 'private', label: 'Privat' }, { id: 'club', label: 'Verein' }, { id: 'official', label: 'Offiziell' }, ], [] ) const statusOptions = useMemo( () => [ { id: 'draft', label: 'Entwurf' }, { id: 'in_review', label: 'In Prüfung' }, { id: 'approved', label: 'Freigegeben' }, { id: 'archived', label: 'Archiviert' }, ], [] ) const filterChips = useMemo( () => 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 = exercises.map((e) => (e.title || '').trim()).filter(Boolean) return [...new Set(titles)].slice(0, 80) }, [exercises]) 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((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 (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)) } 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 (Sichtbarkeit, Status oder eine der Zuordnungen mit gesetztem Häkchen „ersetzen“).' ) return } const ids = Array.from(selectedIds).filter((x) => Number.isFinite(x) && x > 0) if (ids.length === 0) { alert('Keine Übungen ausgewählt.') return } if (ids.length > BULK_MAX_IDS) { alert(`Maximal ${BULK_MAX_IDS} Übungen pro Vorgang. Bitte Auswahl oder mehrere Durchläufe verwenden.`) return } const payload = { exercise_ids: ids } if (bulkVisibility) payload.visibility = bulkVisibility if (bulkStatus) payload.status = bulkStatus if (bulkPatchFocusAreas) { payload.focus_area_ids = bulkFocusAreaIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0) } if (bulkPatchStyleDirections) { payload.style_direction_ids = bulkStyleDirectionIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0) } if (bulkPatchTrainingTypes) { payload.training_type_ids = bulkTrainingTypeIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0) } if (bulkPatchTargetGroups) { payload.target_group_ids = bulkTargetGroupIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0) } if (bulkVisibility === 'club') { const manual = String(bulkClubManual || '').trim() if (manual && /^\d+$/.test(manual)) payload.club_id = Number(manual) else if (bulkClubSelect && /^\d+$/.test(String(bulkClubSelect))) { payload.club_id = Number(bulkClubSelect) } } setBulkSubmitting(true) try { const res = await api.bulkPatchExercisesMetadata(payload) const updatedSet = new Set((res.updated || []).map((x) => Number(x))) let resolvedClubId = null if (bulkVisibility === 'club') { if (payload.club_id != null) resolvedClubId = payload.club_id else if (effectiveClubId != null && !Number.isNaN(effectiveClubId)) resolvedClubId = effectiveClubId } const clubLabel = resolvedClubId != null ? clubNameById[resolvedClubId] || `Verein #${resolvedClubId}` : null let nextPrimaryFocusName = null if (bulkPatchFocusAreas) { const faNums = bulkFocusAreaIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0) if (faNums.length > 0) { const opt = focusOptions.find((o) => Number(o.id) === Number(faNums[0])) nextPrimaryFocusName = String(opt?.label ?? '').trim() || String(faNums[0]) } } setExercises((prev) => prev.map((e) => { if (!updatedSet.has(Number(e.id))) return e const next = { ...e } if (bulkVisibility) { next.visibility = bulkVisibility next.club_id = bulkVisibility === 'club' ? resolvedClubId : null next.club_name = bulkVisibility === 'club' ? clubLabel : null } if (bulkStatus) next.status = bulkStatus if (bulkPatchFocusAreas) { if (nextPrimaryFocusName == null) delete next.focus_area else next.focus_area = nextPrimaryFocusName } return next }) ) let msg = `${res.updated_count ?? updatedSet.size} Übung(en) aktualisiert.` if (res.failed_count) msg += `\n${res.failed_count} nicht geändert (siehe Details).` if (Array.isArray(res.failed) && res.failed.length) { msg += '\n\n' + res.failed .slice(0, 12) .map((f) => `#${f.id}: ${f.detail}`) .join('\n') if (res.failed.length > 12) msg += '\n…' } alert(msg) setBulkModalOpen(false) clearSelection() } catch (err) { alert('Massenänderung fehlgeschlagen: ' + (err.message || String(err))) } finally { setBulkSubmitting(false) } } if (!catalogsReady && pageTab === 'list') { return (
Lade Kataloge…
Lade Progressionsgraphen…
Lade Übungen…
Keine Übungen gefunden.
Aktualisiere Treffer…
) : null}{exercises.length} angezeigt {hasMore ? ' · es gibt weitere Einträge' : ''}