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 { Link } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import ExercisePickerModal from '../components/ExercisePickerModal'
|
||||||
|
|
||||||
function defaultSection(title = 'Hauptteil') {
|
function defaultSection(title = 'Hauptteil') {
|
||||||
return { title, guidance_notes: '', items: [] }
|
return { title, guidance_notes: '', items: [] }
|
||||||
|
|
@ -12,6 +13,8 @@ function exerciseRow() {
|
||||||
item_type: 'exercise',
|
item_type: 'exercise',
|
||||||
exercise_id: '',
|
exercise_id: '',
|
||||||
exercise_variant_id: '',
|
exercise_variant_id: '',
|
||||||
|
exercise_title: '',
|
||||||
|
variants: [],
|
||||||
planned_duration_min: '',
|
planned_duration_min: '',
|
||||||
actual_duration_min: '',
|
actual_duration_min: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
|
@ -36,6 +39,8 @@ function normalizeUnitToForm(fullUnit) {
|
||||||
item_type: 'exercise',
|
item_type: 'exercise',
|
||||||
exercise_id: it.exercise_id,
|
exercise_id: it.exercise_id,
|
||||||
exercise_variant_id: it.exercise_variant_id ?? '',
|
exercise_variant_id: it.exercise_variant_id ?? '',
|
||||||
|
exercise_title: it.exercise_title || '',
|
||||||
|
variants: [],
|
||||||
planned_duration_min:
|
planned_duration_min:
|
||||||
it.planned_duration_min !== null && it.planned_duration_min !== undefined
|
it.planned_duration_min !== null && it.planned_duration_min !== undefined
|
||||||
? String(it.planned_duration_min)
|
? String(it.planned_duration_min)
|
||||||
|
|
@ -59,6 +64,8 @@ function normalizeUnitToForm(fullUnit) {
|
||||||
item_type: 'exercise',
|
item_type: 'exercise',
|
||||||
exercise_id: ex.exercise_id,
|
exercise_id: ex.exercise_id,
|
||||||
exercise_variant_id: ex.exercise_variant_id ?? '',
|
exercise_variant_id: ex.exercise_variant_id ?? '',
|
||||||
|
exercise_title: ex.exercise_title || '',
|
||||||
|
variants: [],
|
||||||
planned_duration_min:
|
planned_duration_min:
|
||||||
ex.planned_duration_min !== null && ex.planned_duration_min !== undefined
|
ex.planned_duration_min !== null && ex.planned_duration_min !== undefined
|
||||||
? String(ex.planned_duration_min)
|
? String(ex.planned_duration_min)
|
||||||
|
|
@ -76,6 +83,48 @@ function normalizeUnitToForm(fullUnit) {
|
||||||
return [defaultSection()]
|
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) {
|
function parseMin(v) {
|
||||||
if (v === '' || v === null || v === undefined) return null
|
if (v === '' || v === null || v === undefined) return null
|
||||||
const n = parseInt(String(v), 10)
|
const n = parseInt(String(v), 10)
|
||||||
|
|
@ -129,13 +178,14 @@ function TrainingPlanningPage() {
|
||||||
const [groups, setGroups] = useState([])
|
const [groups, setGroups] = useState([])
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState('')
|
const [selectedGroupId, setSelectedGroupId] = useState('')
|
||||||
const [units, setUnits] = useState([])
|
const [units, setUnits] = useState([])
|
||||||
const [exercises, setExercises] = useState([])
|
|
||||||
const [planTemplates, setPlanTemplates] = useState([])
|
const [planTemplates, setPlanTemplates] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [editingUnit, setEditingUnit] = useState(null)
|
const [editingUnit, setEditingUnit] = useState(null)
|
||||||
const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('')
|
const [draftPlanTemplateId, setDraftPlanTemplateId] = useState('')
|
||||||
const [quickTemplateId, setQuickTemplateId] = useState('')
|
const [quickTemplateId, setQuickTemplateId] = useState('')
|
||||||
|
const [exercisePickerOpen, setExercisePickerOpen] = useState(false)
|
||||||
|
const [exercisePickerTarget, setExercisePickerTarget] = useState(null)
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
const thirtyDaysLater = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).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 () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const [groupsData, exercisesData] = await Promise.all([
|
const groupsData = await api.listTrainingGroups({ status: 'active' })
|
||||||
api.listTrainingGroups({ status: 'active' }),
|
|
||||||
api.listExercises({ include_variants: true })
|
|
||||||
])
|
|
||||||
setGroups(groupsData)
|
setGroups(groupsData)
|
||||||
setExercises(exercisesData)
|
|
||||||
await loadPlanTemplates()
|
await loadPlanTemplates()
|
||||||
|
|
||||||
if (groupsData.length > 0) {
|
if (groupsData.length > 0) {
|
||||||
|
|
@ -291,6 +337,8 @@ function TrainingPlanningPage() {
|
||||||
const fullUnit = await api.getTrainingUnit(unit.id)
|
const fullUnit = await api.getTrainingUnit(unit.id)
|
||||||
setEditingUnit(fullUnit)
|
setEditingUnit(fullUnit)
|
||||||
setDraftPlanTemplateId(fullUnit.plan_template_id ? String(fullUnit.plan_template_id) : '')
|
setDraftPlanTemplateId(fullUnit.plan_template_id ? String(fullUnit.plan_template_id) : '')
|
||||||
|
let sections = normalizeUnitToForm(fullUnit)
|
||||||
|
sections = await enrichSectionsWithVariants(sections)
|
||||||
setFormData({
|
setFormData({
|
||||||
group_id: fullUnit.group_id,
|
group_id: fullUnit.group_id,
|
||||||
planned_date: fullUnit.planned_date || '',
|
planned_date: fullUnit.planned_date || '',
|
||||||
|
|
@ -304,7 +352,7 @@ function TrainingPlanningPage() {
|
||||||
status: fullUnit.status || 'planned',
|
status: fullUnit.status || 'planned',
|
||||||
notes: fullUnit.notes || '',
|
notes: fullUnit.notes || '',
|
||||||
trainer_notes: fullUnit.trainer_notes || '',
|
trainer_notes: fullUnit.trainer_notes || '',
|
||||||
sections: normalizeUnitToForm(fullUnit)
|
sections,
|
||||||
})
|
})
|
||||||
setShowModal(true)
|
setShowModal(true)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -437,7 +485,11 @@ function TrainingPlanningPage() {
|
||||||
items: s.items.map((it, ii) => {
|
items: s.items.map((it, ii) => {
|
||||||
if (ii !== iIdx) return it
|
if (ii !== iIdx) return it
|
||||||
const next = { ...it, [field]: value }
|
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
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -1017,32 +1069,45 @@ function TrainingPlanningPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', minWidth: 0 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', minWidth: 0 }}>
|
||||||
<select
|
<div
|
||||||
className="form-input"
|
style={{
|
||||||
value={
|
display: 'flex',
|
||||||
it.exercise_id === '' || it.exercise_id == null ? '' : String(it.exercise_id)
|
gap: '8px',
|
||||||
}
|
flexWrap: 'wrap',
|
||||||
onChange={(e) => {
|
alignItems: 'center'
|
||||||
const raw = e.target.value
|
|
||||||
updateItem(
|
|
||||||
sIdx,
|
|
||||||
iIdx,
|
|
||||||
'exercise_id',
|
|
||||||
raw === '' ? '' : parseInt(raw, 10)
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
style={{ margin: 0 }}
|
|
||||||
>
|
>
|
||||||
<option value="">Übung wählen</option>
|
<button
|
||||||
{exercises.map((exercise) => (
|
type="button"
|
||||||
<option key={exercise.id} value={exercise.id}>
|
className="btn btn-secondary"
|
||||||
{exercise.title}
|
style={{ margin: 0, whiteSpace: 'nowrap' }}
|
||||||
</option>
|
onClick={() => {
|
||||||
))}
|
setExercisePickerTarget({ sIdx, iIdx })
|
||||||
</select>
|
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(it.variants) ? it.variants : []
|
||||||
const variantOpts = Array.isArray(picked?.variants) ? picked.variants : []
|
|
||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
className="form-input"
|
className="form-input"
|
||||||
|
|
@ -1266,6 +1331,42 @@ function TrainingPlanningPage() {
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,12 @@ async function request(endpoint, options = {}) {
|
||||||
headers['X-Auth-Token'] = token
|
headers['X-Auth-Token'] = token
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
const url = `${API_URL}${endpoint}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
headers
|
headers,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -34,6 +37,16 @@ async function request(endpoint, options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json()
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user