- Updated ExercisePeekModal to support exercise variants, improving user experience when selecting exercises. - Enhanced ExercisePickerModal with multi-select functionality, allowing users to select multiple exercises at once. - Introduced drag-and-drop support for reordering training unit sections in TrainingUnitSectionsEditor, enhancing organization and usability. - Improved TrainingFrameworkProgramEditPage and TrainingPlanningPage to manage exercise selection and section movement across slots more effectively. - Added new CSS styles in app.css for better visual feedback during drag-and-drop actions and improved layout consistency.
543 lines
20 KiB
JavaScript
543 lines
20 KiB
JavaScript
/**
|
|
* Ü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,
|
|
multiSelect = false,
|
|
onSelectExercises = null,
|
|
}) {
|
|
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)
|
|
const [multiPicked, setMultiPicked] = useState([])
|
|
|
|
const toggleMultiPick = (ex) => {
|
|
setMultiPicked((prev) =>
|
|
prev.some((p) => p.id === ex.id) ? prev.filter((p) => p.id !== ex.id) : [...prev, ex]
|
|
)
|
|
}
|
|
|
|
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)
|
|
setMultiPicked([])
|
|
}
|
|
}, [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">
|
|
{multiSelect ? 'Übungen auswählen' : 'Ü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) => {
|
|
const picked = multiPicked.some((p) => p.id === ex.id)
|
|
const rowInner = (
|
|
<>
|
|
<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>
|
|
)}
|
|
</>
|
|
)
|
|
if (multiSelect) {
|
|
return (
|
|
<li key={ex.id}>
|
|
<label
|
|
className="tu-ex-picker-multi-row"
|
|
style={{
|
|
display: 'flex',
|
|
gap: '10px',
|
|
alignItems: 'flex-start',
|
|
width: '100%',
|
|
textAlign: 'left',
|
|
padding: '10px 12px',
|
|
marginBottom: 8,
|
|
borderRadius: '8px',
|
|
border: picked ? '2px solid var(--accent)' : '1px solid var(--border)',
|
|
background: 'var(--surface2)',
|
|
cursor: 'pointer',
|
|
boxSizing: 'border-box',
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={picked}
|
|
onChange={() => toggleMultiPick(ex)}
|
|
style={{ marginTop: '0.35rem', flexShrink: 0 }}
|
|
aria-label={ex.title ? `Auswahl: ${ex.title}` : 'Auswahl'}
|
|
/>
|
|
<div style={{ flex: 1, minWidth: 0 }}>{rowInner}</div>
|
|
</label>
|
|
</li>
|
|
)
|
|
}
|
|
return (
|
|
<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',
|
|
}}
|
|
>
|
|
{rowInner}
|
|
</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>
|
|
)}
|
|
{multiSelect && typeof onSelectExercises === 'function' ? (
|
|
<div
|
|
className="exercise-picker-multi-footer"
|
|
style={{
|
|
position: 'sticky',
|
|
bottom: 0,
|
|
marginTop: 16,
|
|
paddingTop: 12,
|
|
borderTop: '1px solid var(--border)',
|
|
background: 'var(--surface)',
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
gap: '10px',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
}}
|
|
>
|
|
<span style={{ fontSize: '0.92rem', color: 'var(--text2)' }}>
|
|
{multiPicked.length} ausgewählt
|
|
</span>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
onClick={() => setMultiPicked([])}
|
|
disabled={!multiPicked.length}
|
|
>
|
|
Auswahl leeren
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
disabled={!multiPicked.length}
|
|
onClick={() => {
|
|
onSelectExercises([...multiPicked])
|
|
onClose()
|
|
}}
|
|
>
|
|
Übernehmen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|