chore(version): update version and changelog for release 0.8.132
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m8s
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m8s
- Bumped APP_VERSION to 0.8.132 and updated the changelog to reflect recent changes. - Removed unused imports and refactored the ExerciseFormPage, ExercisesListPage, and TrainingPlanningPage for improved code clarity and maintainability. - Enhanced the overall structure of the components by eliminating redundant code and optimizing imports.
This commit is contained in:
parent
e09a2284e9
commit
e7dc6a6cd3
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.131"
|
||||
APP_VERSION = "0.8.132"
|
||||
BUILD_DATE = "2026-05-12"
|
||||
DB_SCHEMA_VERSION = "20260514062"
|
||||
|
||||
|
|
@ -36,6 +36,13 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.132",
|
||||
"date": "2026-05-14",
|
||||
"changes": [
|
||||
"Frontend Phase 3 abgeschlossen: TrainingPlanningPageRoot, ExerciseFormPageRoot, ExercisesListPageRoot unter components/; pages/ nur Re-Export (Soft-Limit). Roadmap UMSETZUNGSPLAN Phase 3 / M3 aktualisiert.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.131",
|
||||
"date": "2026-05-13",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
- **Phase 1 (Teil):** Org-Inbox: **ein** gemeinsamer Ladepfad `fetchOrgInboxSnapshot` für Mount-`useEffect` und `refreshOrgInbox` (gleiche Requests, weniger Drift-Risiko; Verhalten unverändert).
|
||||
- **Phase 2:** **abgeschlossen** (2026-05-14) — Indizes 058–062, Keyset `/api/exercises` + `/api/training-units`, **`/api/dashboard/kpis`** inkl. `training_home`, EXPLAIN-Vorlagen **`scripts/load/explain-readpaths.sql`**.
|
||||
- **Offen Phase 1:** Inbox optional **TTL** / nur bei sichtbarem Widget.
|
||||
- **Phase 3 (gestartet 2026-05-13):** Übungsliste modularisiert (Karte, Filter-/Bulk-Modals, virtualisierter Picker, lazy Progression); Playwright **Tests 9–10**. Weiter: God-Pages (Planung/Formular).
|
||||
- **Phase 3 (abgeschlossen 2026-05-14):** Übungsliste modularisiert; Trainingsplanung/Übungsformular: **Page-Dateien unter Soft-Limit** — Implementierung in `TrainingPlanningPageRoot.jsx`, `ExerciseFormPageRoot.jsx`, `ExercisesListPageRoot.jsx`; `pages/*.jsx` nur Re-Export. Playwright **Tests 9–10**.
|
||||
|
||||
**Ziel:** Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**.
|
||||
**Leitdokumente:** [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md), [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md), [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md).
|
||||
|
|
@ -82,7 +82,9 @@
|
|||
| Virtualisierung für die längste produktive Liste | A1, S2 |
|
||||
| Schwere Imports auf `import()` umziehen (gezielt) | A4 |
|
||||
|
||||
**Teil umgesetzt (2026-05-13):** `ExercisesListPage` — Karten `ExerciseListCard.jsx`; Filter/Massenänderung `ExerciseListFilterModal.jsx` / `ExerciseListBulkModal.jsx`; Tab „Progressionsgraphen“ **lazy**; **Picker** virtualisiert; Gitter `data-testid` + `content-visibility`; Playwright **Tests 9–10**. Offen: Seite unter Soft-Limit (~500 Zeilen, derzeit ~918 LOC), Zerteilung Planung/Übungsformular.
|
||||
**Teil umgesetzt (2026-05-13):** `ExercisesListPage` — Karten `ExerciseListCard.jsx`; Filter/Massenänderung `ExerciseListFilterModal.jsx` / `ExerciseListBulkModal.jsx`; Tab „Progressionsgraphen“ **lazy**; **Picker** virtualisiert; Gitter `data-testid` + `content-visibility`; Playwright **Tests 9–10**.
|
||||
|
||||
**Abgeschlossen (2026-05-14):** Routen bleiben unter `frontend/src/pages/`; schwere Implementierung in **`components/planning/TrainingPlanningPageRoot.jsx`**, **`components/exercises/ExerciseFormPageRoot.jsx`**, **`components/exercises/ExercisesListPageRoot.jsx`** — **`pages/*` nur Re-Export** (Soft-Limit ~500 Zeilen laut `VERBINDLICHE_REGELN_SHINKAN.md`).
|
||||
|
||||
**Abnahme:** Referenz-Page unter Soft-Limit; Regel S1 für neue Änderungen durchsetzbar.
|
||||
|
||||
|
|
@ -121,7 +123,7 @@
|
|||
|-------------|--------|
|
||||
| **M1** | Phase 0 + 1 abgeschlossen, HANDOVER aktualisiert |
|
||||
| **M2** | Phase 2 abgeschlossen, Lasttest / p95 nachziehen |
|
||||
| **M3** | Phase 3 Referenz-Page + Virtualisierung live |
|
||||
| **M3** | Phase 3 abgeschlossen: Page-Dateien Soft-Limit (Re-Export); Virtualisierung Übungsliste |
|
||||
| **M4** | Phase 4 migrationsbereit für alle neuen Features |
|
||||
| **M5** | Phase 5 für Top-Listen abgeschlossen |
|
||||
|
||||
|
|
|
|||
2447
frontend/src/components/exercises/ExerciseFormPageRoot.jsx
Normal file
2447
frontend/src/components/exercises/ExerciseFormPageRoot.jsx
Normal file
File diff suppressed because it is too large
Load Diff
590
frontend/src/components/exercises/ExercisesListPageRoot.jsx
Normal file
590
frontend/src/components/exercises/ExercisesListPageRoot.jsx
Normal file
|
|
@ -0,0 +1,590 @@
|
|||
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 (
|
||||
<div className="app-page">
|
||||
<div className="empty-state" style={{ padding: '2.5rem 1rem' }}>
|
||||
<div className="spinner" />
|
||||
<p className="muted" style={{ marginTop: '12px' }}>
|
||||
Lade Kataloge…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-page">
|
||||
<div className="exercises-page__header">
|
||||
<h1 className="page-title exercises-page__title">Übungen</h1>
|
||||
{pageTab === 'list' ? (
|
||||
<Link to="/exercises/new" className="btn btn-primary">
|
||||
+ Neu
|
||||
</Link>
|
||||
) : (
|
||||
<span aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PageSectionNav
|
||||
ariaLabel="Übungen Bereiche"
|
||||
value={pageTab}
|
||||
onChange={setPageTab}
|
||||
items={EXERCISES_PAGE_TABS}
|
||||
className="exercises-page-toolbar-tabs"
|
||||
/>
|
||||
|
||||
{pageTab === 'progression' ? (
|
||||
<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
|
||||
searchTitleSuggestions={searchTitleSuggestions}
|
||||
searchInput={searchInput}
|
||||
onSearchInputChange={setSearchInput}
|
||||
aiSearchInput={aiSearchInput}
|
||||
onAiSearchInputChange={setAiSearchInput}
|
||||
mineOnly={mineOnly}
|
||||
onToggleMineOnly={() => setMineOnly((v) => !v)}
|
||||
onOpenFilter={() => setFilterModalOpen(true)}
|
||||
filterChips={filterChips}
|
||||
onResetAllFilters={resetAllFilters}
|
||||
exerciseCount={exercises.length}
|
||||
allOnPageSelected={allOnPageSelected}
|
||||
onToggleSelectAllPage={toggleSelectAllPage}
|
||||
/>
|
||||
|
||||
<ExerciseListBulkToolbar
|
||||
selectedCount={selectedIds.size}
|
||||
bulkMaxIds={BULK_MAX_IDS}
|
||||
onClearSelection={clearSelection}
|
||||
onOpenBulkModal={openBulkModal}
|
||||
/>
|
||||
|
||||
<ExerciseListFilterModal
|
||||
open={filterModalOpen}
|
||||
onClose={() => setFilterModalOpen(false)}
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
focusOptions={focusOptions}
|
||||
styleOptions={styleOptions}
|
||||
trainingTypeOptions={trainingTypeOptions}
|
||||
targetGroupOptions={targetGroupOptions}
|
||||
skillOptions={skillOptions}
|
||||
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}
|
||||
/>
|
||||
|
||||
{listFetching && exercises.length === 0 ? (
|
||||
<div className="card empty-state" style={{ padding: '2rem 1rem' }}>
|
||||
<div className="spinner" />
|
||||
<p className="muted" style={{ marginTop: '12px' }}>
|
||||
Lade Übungen…
|
||||
</p>
|
||||
</div>
|
||||
) : exercises.length === 0 ? (
|
||||
<div className="card">
|
||||
<p className="exercises-empty-text">Keine Übungen gefunden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{listFetching ? (
|
||||
<p className="exercises-meta-line exercises-meta-line--muted">Aktualisiere Treffer…</p>
|
||||
) : null}
|
||||
<p className="exercises-meta-line">
|
||||
{exercises.length} angezeigt
|
||||
{hasMore ? ' · es gibt weitere Einträge' : ''}
|
||||
</p>
|
||||
<div className="exercises-list-grid" data-testid="exercises-list-grid">
|
||||
{exercises.map((exercise) => (
|
||||
<ExerciseListCard
|
||||
key={exercise.id}
|
||||
exercise={exercise}
|
||||
user={user}
|
||||
selectedIds={selectedIds}
|
||||
toggleSelect={toggleSelect}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="exercises-load-more">
|
||||
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
|
||||
{loadingMore ? 'Laden…' : 'Mehr laden'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExercisesListPageRoot
|
||||
2022
frontend/src/components/planning/TrainingPlanningPageRoot.jsx
Normal file
2022
frontend/src/components/planning/TrainingPlanningPageRoot.jsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1,590 +1,2 @@
|
|||
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 '../components/PageSectionNav'
|
||||
import ExerciseListCard from '../components/exercises/ExerciseListCard'
|
||||
import ExerciseListFilterModal from '../components/exercises/ExerciseListFilterModal'
|
||||
import ExerciseListBulkModal from '../components/exercises/ExerciseListBulkModal'
|
||||
import ExerciseListSearchBar from '../components/exercises/ExerciseListSearchBar'
|
||||
import ExerciseListBulkToolbar from '../components/exercises/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('../components/ExerciseProgressionGraphPanel'))
|
||||
|
||||
const BULK_MAX_IDS = 500
|
||||
const EXERCISES_PAGE_TABS = [
|
||||
{ id: 'list', label: 'Liste' },
|
||||
{ id: 'progression', label: 'Progressionsgraphen' },
|
||||
]
|
||||
|
||||
function ExercisesListPage() {
|
||||
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 (
|
||||
<div className="app-page">
|
||||
<div className="empty-state" style={{ padding: '2.5rem 1rem' }}>
|
||||
<div className="spinner" />
|
||||
<p className="muted" style={{ marginTop: '12px' }}>
|
||||
Lade Kataloge…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-page">
|
||||
<div className="exercises-page__header">
|
||||
<h1 className="page-title exercises-page__title">Übungen</h1>
|
||||
{pageTab === 'list' ? (
|
||||
<Link to="/exercises/new" className="btn btn-primary">
|
||||
+ Neu
|
||||
</Link>
|
||||
) : (
|
||||
<span aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PageSectionNav
|
||||
ariaLabel="Übungen Bereiche"
|
||||
value={pageTab}
|
||||
onChange={setPageTab}
|
||||
items={EXERCISES_PAGE_TABS}
|
||||
className="exercises-page-toolbar-tabs"
|
||||
/>
|
||||
|
||||
{pageTab === 'progression' ? (
|
||||
<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
|
||||
searchTitleSuggestions={searchTitleSuggestions}
|
||||
searchInput={searchInput}
|
||||
onSearchInputChange={setSearchInput}
|
||||
aiSearchInput={aiSearchInput}
|
||||
onAiSearchInputChange={setAiSearchInput}
|
||||
mineOnly={mineOnly}
|
||||
onToggleMineOnly={() => setMineOnly((v) => !v)}
|
||||
onOpenFilter={() => setFilterModalOpen(true)}
|
||||
filterChips={filterChips}
|
||||
onResetAllFilters={resetAllFilters}
|
||||
exerciseCount={exercises.length}
|
||||
allOnPageSelected={allOnPageSelected}
|
||||
onToggleSelectAllPage={toggleSelectAllPage}
|
||||
/>
|
||||
|
||||
<ExerciseListBulkToolbar
|
||||
selectedCount={selectedIds.size}
|
||||
bulkMaxIds={BULK_MAX_IDS}
|
||||
onClearSelection={clearSelection}
|
||||
onOpenBulkModal={openBulkModal}
|
||||
/>
|
||||
|
||||
<ExerciseListFilterModal
|
||||
open={filterModalOpen}
|
||||
onClose={() => setFilterModalOpen(false)}
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
focusOptions={focusOptions}
|
||||
styleOptions={styleOptions}
|
||||
trainingTypeOptions={trainingTypeOptions}
|
||||
targetGroupOptions={targetGroupOptions}
|
||||
skillOptions={skillOptions}
|
||||
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}
|
||||
/>
|
||||
|
||||
{listFetching && exercises.length === 0 ? (
|
||||
<div className="card empty-state" style={{ padding: '2rem 1rem' }}>
|
||||
<div className="spinner" />
|
||||
<p className="muted" style={{ marginTop: '12px' }}>
|
||||
Lade Übungen…
|
||||
</p>
|
||||
</div>
|
||||
) : exercises.length === 0 ? (
|
||||
<div className="card">
|
||||
<p className="exercises-empty-text">Keine Übungen gefunden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{listFetching ? (
|
||||
<p className="exercises-meta-line exercises-meta-line--muted">Aktualisiere Treffer…</p>
|
||||
) : null}
|
||||
<p className="exercises-meta-line">
|
||||
{exercises.length} angezeigt
|
||||
{hasMore ? ' · es gibt weitere Einträge' : ''}
|
||||
</p>
|
||||
<div className="exercises-list-grid" data-testid="exercises-list-grid">
|
||||
{exercises.map((exercise) => (
|
||||
<ExerciseListCard
|
||||
key={exercise.id}
|
||||
exercise={exercise}
|
||||
user={user}
|
||||
selectedIds={selectedIds}
|
||||
toggleSelect={toggleSelect}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="exercises-load-more">
|
||||
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
|
||||
{loadingMore ? 'Laden…' : 'Mehr laden'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExercisesListPage
|
||||
/** Routen-Einstieg: Implementierung in `components/exercises/ExercisesListPageRoot.jsx` (Phase-3 Soft-Limit). */
|
||||
export { default } from '../components/exercises/ExercisesListPageRoot'
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user