From 131b7d591d1167308d27a708a8ea0cf1579009e4 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 29 Apr 2026 06:15:26 +0200 Subject: [PATCH] feat: enhance training planning page with exercise variant support - Introduced ExercisePickerModal for selecting exercises and their variants. - Updated state management to handle exercise titles and variants in training units. - Implemented enrichSectionsWithVariants function to fetch and integrate exercise details from the server. - Improved loading logic for training units to include exercise variant data, enhancing user experience in planning sessions. --- .../src/components/ExercisePickerModal.jsx | 441 ++++++++++++++++++ frontend/src/pages/TrainingPlanningPage.jsx | 163 +++++-- frontend/src/utils/api.js | 31 +- 3 files changed, 595 insertions(+), 40 deletions(-) create mode 100644 frontend/src/components/ExercisePickerModal.jsx diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx new file mode 100644 index 0000000..8525252 --- /dev/null +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -0,0 +1,441 @@ +/** + * Übungssuche mit Volltext-, KI-/Semantikfeld (aktuell gleiche Engine wie Suche) und erweiterten Filtern. + * Paginierung bis max. 100 Treffer pro Request (API-Limit). + */ +import React, { useState, useEffect, useMemo, useCallback } from 'react' +import api from '../utils/api' +import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels' +import MultiSelectCombo from './MultiSelectCombo' + +const PAGE_SIZE = 100 +const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) + +const INITIAL_FILTERS = { + focus_area_ids: [], + style_direction_ids: [], + training_type_ids: [], + target_group_ids: [], + skill_ids: [], + skill_min_level: '', + skill_max_level: '', + visibility_any: [], + status_any: [], +} + +export default function ExercisePickerModal({ open, onClose, onSelectExercise }) { + const [catalogs, setCatalogs] = useState({ + focusAreas: [], + styleDirections: [], + trainingTypes: [], + targetGroups: [], + skills: [], + }) + const [catalogsReady, setCatalogsReady] = useState(false) + const [searchInput, setSearchInput] = useState('') + const [aiSearchInput, setAiSearchInput] = useState('') + const [debouncedSearch, setDebouncedSearch] = useState('') + const [debouncedAi, setDebouncedAi] = useState('') + const [filters, setFilters] = useState(() => ({ ...INITIAL_FILTERS })) + const [filterOpen, setFilterOpen] = useState(false) + const [list, setList] = useState([]) + const [loading, setLoading] = useState(false) + const [loadingMore, setLoadingMore] = useState(false) + const [offset, setOffset] = useState(0) + const [hasMore, setHasMore] = useState(false) + + useEffect(() => { + const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 350) + return () => clearTimeout(t) + }, [searchInput]) + + useEffect(() => { + const t = setTimeout(() => setDebouncedAi(aiSearchInput.trim()), 350) + return () => clearTimeout(t) + }, [aiSearchInput]) + + useEffect(() => { + if (!open) return + let cancelled = false + ;(async () => { + try { + const [fa, sd, tt, tg, sk] = await Promise.all([ + api.listFocusAreas(), + api.listStyleDirections(), + api.listTrainingTypes(), + api.listTargetGroups(), + api.listSkills(), + ]) + if (!cancelled) { + setCatalogs({ + focusAreas: fa, + styleDirections: sd, + trainingTypes: tt, + targetGroups: tg, + skills: sk, + }) + setCatalogsReady(true) + } + } catch (e) { + console.error(e) + if (!cancelled) setCatalogsReady(true) + } + })() + return () => { + cancelled = true + } + }, [open]) + + useEffect(() => { + if (!open) { + setSearchInput('') + setAiSearchInput('') + setDebouncedSearch('') + setDebouncedAi('') + setFilters({ ...INITIAL_FILTERS }) + setFilterOpen(false) + setList([]) + setOffset(0) + setHasMore(false) + } + }, [open]) + + 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 queryBase = useMemo(() => { + const q = {} + const n = (v) => (v === '' || v == null ? undefined : Number(v)) + const ids = (arr) => + Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined + const fa = ids(filters.focus_area_ids) + if (fa?.length) q.focus_area_ids = fa + const sd = ids(filters.style_direction_ids) + if (sd?.length) q.style_direction_ids = sd + const tt = ids(filters.training_type_ids) + if (tt?.length) q.training_type_ids = tt + const tg = ids(filters.target_group_ids) + if (tg?.length) q.target_group_ids = tg + const sk = ids(filters.skill_ids) + if (sk?.length) q.skill_ids = sk + if (filters.skill_min_level) q.skill_min_level = n(filters.skill_min_level) + if (filters.skill_max_level) q.skill_max_level = n(filters.skill_max_level) + if (filters.visibility_any?.length) q.visibility_any = [...filters.visibility_any] + if (filters.status_any?.length) q.status_any = [...filters.status_any] + if (debouncedSearch) q.search = debouncedSearch + if (debouncedAi) q.ai_search = debouncedAi + return q + }, [filters, debouncedSearch, debouncedAi]) + + const reload = useCallback(async () => { + if (!open || !catalogsReady) return + setLoading(true) + setOffset(0) + try { + const batch = await api.listExercises({ + ...queryBase, + include_variants: true, + limit: PAGE_SIZE, + offset: 0, + }) + setList(Array.isArray(batch) ? batch : []) + setHasMore(batch?.length === PAGE_SIZE) + setOffset(batch?.length ?? 0) + } catch (e) { + console.error(e) + alert(e.message || 'Laden fehlgeschlagen') + setList([]) + setHasMore(false) + } finally { + setLoading(false) + } + }, [open, catalogsReady, queryBase]) + + useEffect(() => { + reload() + }, [reload]) + + const loadMore = async () => { + if (!hasMore || loadingMore || loading) return + setLoadingMore(true) + try { + const batch = await api.listExercises({ + ...queryBase, + include_variants: true, + limit: PAGE_SIZE, + offset, + }) + setList((prev) => [...prev, ...(Array.isArray(batch) ? batch : [])]) + setHasMore(batch?.length === PAGE_SIZE) + setOffset((o) => o + (batch?.length ?? 0)) + } catch (e) { + console.error(e) + alert(e.message || 'Mehr laden fehlgeschlagen') + } finally { + setLoadingMore(false) + } + } + + const resetFilters = () => setFilters({ ...INITIAL_FILTERS }) + + if (!open) return null + + return ( +
{ + if (e.target === e.currentTarget) onClose() + }} + > +
e.stopPropagation()} + > +
+

Übung auswählen

+ +
+ +
+
+
+ + setSearchInput(e.target.value)} + autoComplete="off" + /> +
+
+ + setAiSearchInput(e.target.value)} + autoComplete="off" + /> +
+
+ + + {loading && Suche läuft…} +
+ + {filterOpen && ( +
+

+ Zwischen den Bereichen gilt UND, innerhalb ODER wie in der Übungsübersicht. +

+
+
+ + setFilters((f) => ({ ...f, focus_area_ids: v }))} + options={focusOptions} + placeholder="Fokus …" + /> +
+
+ + setFilters((f) => ({ ...f, style_direction_ids: v }))} + options={styleOptions} + placeholder="Stilrichtung …" + /> +
+
+ + setFilters((f) => ({ ...f, training_type_ids: v }))} + options={trainingTypeOptions} + placeholder="Trainingsstil …" + /> +
+
+ + setFilters((f) => ({ ...f, target_group_ids: v }))} + options={targetGroupOptions} + placeholder="Zielgruppe …" + /> +
+
+
+ + setFilters((f) => ({ ...f, skill_ids: v }))} + options={skillOptions} + placeholder="Fähigkeit …" + /> +
+ + +
+
+
+
+ + setFilters((f) => ({ ...f, visibility_any: v }))} + options={visibilityOptions} + placeholder="Sichtbarkeit …" + /> +
+
+ + setFilters((f) => ({ ...f, status_any: v }))} + options={statusOptions} + placeholder="Status …" + /> +
+
+
+ )} +
+
+ +
+ {!catalogsReady || (loading && list.length === 0) ? ( +
+
+
+ ) : list.length === 0 ? ( +

Keine Treffer.

+ ) : ( + <> +

+ {list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''} +

+
    + {list.map((ex) => ( +
  • + +
  • + ))} +
+ {hasMore && ( +
+ +
+ )} + + )} +
+
+
+ ) +} diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx index 7b968c7..601f9a4 100644 --- a/frontend/src/pages/TrainingPlanningPage.jsx +++ b/frontend/src/pages/TrainingPlanningPage.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react' import { Link } from 'react-router-dom' import api from '../utils/api' import { useAuth } from '../context/AuthContext' +import ExercisePickerModal from '../components/ExercisePickerModal' function defaultSection(title = 'Hauptteil') { return { title, guidance_notes: '', items: [] } @@ -12,6 +13,8 @@ function exerciseRow() { item_type: 'exercise', exercise_id: '', exercise_variant_id: '', + exercise_title: '', + variants: [], planned_duration_min: '', actual_duration_min: '', notes: '', @@ -36,6 +39,8 @@ function normalizeUnitToForm(fullUnit) { item_type: 'exercise', exercise_id: it.exercise_id, exercise_variant_id: it.exercise_variant_id ?? '', + exercise_title: it.exercise_title || '', + variants: [], planned_duration_min: it.planned_duration_min !== null && it.planned_duration_min !== undefined ? String(it.planned_duration_min) @@ -59,6 +64,8 @@ function normalizeUnitToForm(fullUnit) { item_type: 'exercise', exercise_id: ex.exercise_id, exercise_variant_id: ex.exercise_variant_id ?? '', + exercise_title: ex.exercise_title || '', + variants: [], planned_duration_min: ex.planned_duration_min !== null && ex.planned_duration_min !== undefined ? String(ex.planned_duration_min) @@ -76,6 +83,48 @@ function normalizeUnitToForm(fullUnit) { return [defaultSection()] } +/** Lädt Varianten/Titel nach, wenn Einheit vom Server ohne variants[] im Client-State ist. */ +async function enrichSectionsWithVariants(sections) { + if (!sections?.length) return sections + const ids = [] + for (const sec of sections) { + for (const it of sec.items || []) { + if (it.item_type === 'note') continue + if (it.exercise_id) ids.push(it.exercise_id) + } + } + const unique = [...new Set(ids)] + const cache = new Map() + await Promise.all( + unique.map(async (id) => { + try { + const ex = await api.getExercise(id) + cache.set(id, { + title: ex.title || '', + variants: Array.isArray(ex.variants) ? ex.variants : [], + }) + } catch { + cache.set(id, { title: '', variants: [] }) + } + }) + ) + return sections.map((sec) => ({ + ...sec, + items: (sec.items || []).map((it) => { + if (it.item_type === 'note') return it + if (!it.exercise_id) return it + const c = cache.get(it.exercise_id) + if (!c) return it + return { + ...it, + exercise_title: it.exercise_title || c.title, + variants: + Array.isArray(it.variants) && it.variants.length > 0 ? it.variants : c.variants, + } + }), + })) +} + function parseMin(v) { if (v === '' || v === null || v === undefined) return null const n = parseInt(String(v), 10) @@ -129,13 +178,14 @@ function TrainingPlanningPage() { const [groups, setGroups] = useState([]) const [selectedGroupId, setSelectedGroupId] = useState('') const [units, setUnits] = useState([]) - const [exercises, setExercises] = useState([]) const [planTemplates, setPlanTemplates] = useState([]) const [loading, setLoading] = useState(true) const [showModal, setShowModal] = useState(false) const [editingUnit, setEditingUnit] = useState(null) const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('') const [quickTemplateId, setQuickTemplateId] = useState('') + const [exercisePickerOpen, setExercisePickerOpen] = useState(false) + const [exercisePickerTarget, setExercisePickerTarget] = useState(null) const today = new Date().toISOString().split('T')[0] const thirtyDaysLater = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] @@ -180,12 +230,8 @@ function TrainingPlanningPage() { const loadData = async () => { try { - const [groupsData, exercisesData] = await Promise.all([ - api.listTrainingGroups({ status: 'active' }), - api.listExercises({ include_variants: true }) - ]) + const groupsData = await api.listTrainingGroups({ status: 'active' }) setGroups(groupsData) - setExercises(exercisesData) await loadPlanTemplates() if (groupsData.length > 0) { @@ -291,6 +337,8 @@ function TrainingPlanningPage() { const fullUnit = await api.getTrainingUnit(unit.id) setEditingUnit(fullUnit) setDraftPlanTemplateId(fullUnit.plan_template_id ? String(fullUnit.plan_template_id) : '') + let sections = normalizeUnitToForm(fullUnit) + sections = await enrichSectionsWithVariants(sections) setFormData({ group_id: fullUnit.group_id, planned_date: fullUnit.planned_date || '', @@ -304,7 +352,7 @@ function TrainingPlanningPage() { status: fullUnit.status || 'planned', notes: fullUnit.notes || '', trainer_notes: fullUnit.trainer_notes || '', - sections: normalizeUnitToForm(fullUnit) + sections, }) setShowModal(true) } catch (err) { @@ -437,7 +485,11 @@ function TrainingPlanningPage() { items: s.items.map((it, ii) => { if (ii !== iIdx) return it const next = { ...it, [field]: value } - if (field === 'exercise_id') next.exercise_variant_id = '' + if (field === 'exercise_id') { + next.exercise_variant_id = '' + next.exercise_title = '' + next.variants = [] + } return next }) } @@ -1017,32 +1069,45 @@ function TrainingPlanningPage() {
- + + {(it.exercise_title || it.exercise_id) && ( + + {it.exercise_title || + (it.exercise_id ? `Übung #${it.exercise_id}` : '')} + + )} +
{(() => { - const picked = exercises.find((e) => e.id === it.exercise_id) - const variantOpts = Array.isArray(picked?.variants) ? picked.variants : [] + const variantOpts = Array.isArray(it.variants) ? it.variants : [] return (