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.
This commit is contained in:
parent
69b26fc928
commit
131b7d591d
441
frontend/src/components/ExercisePickerModal.jsx
Normal file
441
frontend/src/components/ExercisePickerModal.jsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="admin-modal-sheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{ maxWidth: '920px', width: '100%', maxHeight: '92vh', display: 'flex', flexDirection: 'column' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="admin-modal-sheet__header">
|
||||
<h3 className="admin-modal-sheet__title">Übung auswählen</h3>
|
||||
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0 1rem 0.75rem', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||||
<div style={{ display: 'grid', gap: '0.65rem' }}>
|
||||
<div>
|
||||
<label className="form-label">Volltextsuche</label>
|
||||
<input
|
||||
type="search"
|
||||
className="form-input"
|
||||
placeholder="Stichwort, Titelfragment…"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">
|
||||
Semantisch /{' '}
|
||||
<span title="aktuell gleiche Datenbanksuche wie Volltext; später KI-Verfeinerung möglich">KI-Feld</span>
|
||||
</label>
|
||||
<input
|
||||
type="search"
|
||||
className="form-input"
|
||||
placeholder="zweites Suchkonzept oder Umschreibung…"
|
||||
value={aiSearchInput}
|
||||
onChange={(e) => setAiSearchInput(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setFilterOpen(!filterOpen)}>
|
||||
{filterOpen ? 'Filter ausblenden' : 'Erweiterte Filter'}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={resetFilters}>
|
||||
Filter zurücksetzen
|
||||
</button>
|
||||
{loading && <span style={{ fontSize: '13px', color: 'var(--text2)' }}>Suche läuft…</span>}
|
||||
</div>
|
||||
|
||||
{filterOpen && (
|
||||
<div style={{ marginTop: '0.35rem', fontSize: '13px', color: 'var(--text2)' }}>
|
||||
<p style={{ margin: '0 0 12px 0' }}>
|
||||
Zwischen den Bereichen gilt <strong>UND</strong>, innerhalb ODER wie in der Übungsübersicht.
|
||||
</p>
|
||||
<div className="exercise-filters-modal-grid">
|
||||
<div>
|
||||
<label className="form-label">Fokus</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.focus_area_ids}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, focus_area_ids: v }))}
|
||||
options={focusOptions}
|
||||
placeholder="Fokus …"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Stilrichtung</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.style_direction_ids}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, style_direction_ids: v }))}
|
||||
options={styleOptions}
|
||||
placeholder="Stilrichtung …"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Trainingsstil</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.training_type_ids}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, training_type_ids: v }))}
|
||||
options={trainingTypeOptions}
|
||||
placeholder="Trainingsstil …"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Zielgruppe</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.target_group_ids}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, target_group_ids: v }))}
|
||||
options={targetGroupOptions}
|
||||
placeholder="Zielgruppe …"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<label className="form-label">Fähigkeit</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.skill_ids}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, skill_ids: v }))}
|
||||
options={skillOptions}
|
||||
placeholder="Fähigkeit …"
|
||||
/>
|
||||
<div className="exercise-filter-skill-levels-row" style={{ marginTop: 8 }}>
|
||||
<select
|
||||
className="form-input exercise-filter-level-select"
|
||||
title="von"
|
||||
value={filters.skill_min_level}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, skill_min_level: e.target.value }))}
|
||||
>
|
||||
<option value="">Stufe von</option>
|
||||
{LEVEL_FILTER_OPTS.map((o) => (
|
||||
<option key={o.value} value={String(o.level)}>
|
||||
{o.level}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="form-input exercise-filter-level-select"
|
||||
title="bis"
|
||||
value={filters.skill_max_level}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, skill_max_level: e.target.value }))}
|
||||
>
|
||||
<option value="">Stufe bis</option>
|
||||
{LEVEL_FILTER_OPTS.map((o) => (
|
||||
<option key={`m-${o.value}`} value={String(o.level)}>
|
||||
{o.level}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two" style={{ marginTop: 12 }}>
|
||||
<div>
|
||||
<label className="form-label">Sichtbarkeit</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.visibility_any}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, visibility_any: v }))}
|
||||
options={visibilityOptions}
|
||||
placeholder="Sichtbarkeit …"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Status</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.status_any}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, status_any: v }))}
|
||||
options={statusOptions}
|
||||
placeholder="Status …"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '12px 1rem' }}>
|
||||
{!catalogsReady || (loading && list.length === 0) ? (
|
||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : list.length === 0 ? (
|
||||
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>Keine Treffer.</p>
|
||||
) : (
|
||||
<>
|
||||
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: 10 }}>
|
||||
{list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''}
|
||||
</p>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||
{list.map((ex) => (
|
||||
<li key={ex.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectExercise(ex)
|
||||
onClose()
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
padding: '10px 12px',
|
||||
marginBottom: 8,
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface2)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<strong style={{ display: 'block' }}>{ex.title}</strong>
|
||||
{(ex.summary || '').trim().length > 0 && (
|
||||
<span style={{ fontSize: '12px', color: 'var(--text2)' }}>
|
||||
{(ex.summary || '').length > 120 ? `${(ex.summary || '').slice(0, 120)}…` : ex.summary}
|
||||
</span>
|
||||
)}
|
||||
{ex.focus_area && (
|
||||
<span className="exercise-tag exercise-tag--accent" style={{ marginTop: 6 }}>
|
||||
{ex.focus_area}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{hasMore && (
|
||||
<div style={{ textAlign: 'center', marginTop: 12 }}>
|
||||
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
|
||||
{loadingMore ? 'Laden…' : 'Mehr laden'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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() {
|
|||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', minWidth: 0 }}>
|
||||
<select
|
||||
className="form-input"
|
||||
value={
|
||||
it.exercise_id === '' || it.exercise_id == null ? '' : String(it.exercise_id)
|
||||
}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value
|
||||
updateItem(
|
||||
sIdx,
|
||||
iIdx,
|
||||
'exercise_id',
|
||||
raw === '' ? '' : parseInt(raw, 10)
|
||||
)
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
<option value="">Übung wählen</option>
|
||||
{exercises.map((exercise) => (
|
||||
<option key={exercise.id} value={exercise.id}>
|
||||
{exercise.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ margin: 0, whiteSpace: 'nowrap' }}
|
||||
onClick={() => {
|
||||
setExercisePickerTarget({ sIdx, iIdx })
|
||||
setExercisePickerOpen(true)
|
||||
}}
|
||||
>
|
||||
Übung suchen…
|
||||
</button>
|
||||
{(it.exercise_title || it.exercise_id) && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.875rem',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
wordBreak: 'break-word'
|
||||
}}
|
||||
title={
|
||||
it.exercise_title ||
|
||||
(it.exercise_id ? `Übung #${it.exercise_id}` : '')
|
||||
}
|
||||
>
|
||||
{it.exercise_title ||
|
||||
(it.exercise_id ? `Übung #${it.exercise_id}` : '')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
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 (
|
||||
<select
|
||||
className="form-input"
|
||||
|
|
@ -1266,6 +1331,42 @@ function TrainingPlanningPage() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ExercisePickerModal
|
||||
open={exercisePickerOpen}
|
||||
onClose={() => {
|
||||
setExercisePickerOpen(false)
|
||||
setExercisePickerTarget(null)
|
||||
}}
|
||||
onSelectExercise={(ex) => {
|
||||
if (!exercisePickerTarget) return
|
||||
const { sIdx, iIdx } = exercisePickerTarget
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
sections: prev.sections.map((s, si) =>
|
||||
si !== sIdx
|
||||
? s
|
||||
: {
|
||||
...s,
|
||||
items: s.items.map((row, ii) =>
|
||||
ii !== iIdx
|
||||
? row
|
||||
: row.item_type !== 'exercise'
|
||||
? row
|
||||
: {
|
||||
...row,
|
||||
exercise_id: ex.id,
|
||||
exercise_variant_id: '',
|
||||
exercise_title: ex.title || '',
|
||||
variants: Array.isArray(ex.variants) ? ex.variants : [],
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}))
|
||||
setExercisePickerOpen(false)
|
||||
setExercisePickerTarget(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -23,17 +23,30 @@ async function request(endpoint, options = {}) {
|
|||
headers['X-Auth-Token'] = token
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers
|
||||
})
|
||||
const url = `${API_URL}${endpoint}`
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
||||
throw new Error(error.detail || `HTTP ${response.status}`)
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
||||
throw new Error(error.detail || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
} catch (e) {
|
||||
if (e instanceof TypeError && (e.message === 'Failed to fetch' || e.message.includes('fetch'))) {
|
||||
const hint =
|
||||
API_URL && API_URL.length > 0
|
||||
? `Verbindung zum API unter ${API_URL} fehlgeschlagen. Läuft das Backend (z. B. Port 8098) und ist CORS erlaubt?`
|
||||
: 'Kein VITE_API_URL gesetzt: Anfragen gehen an die Frontend-URL und schlagen oft fehl. Setze in .env z. B. VITE_API_URL=http://localhost:8098 und starte Vite neu.'
|
||||
throw new Error(hint)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user