shinkan-jinkendo/frontend/src/components/ExercisePickerModal.jsx
Lars 2bfe67879f
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 1m8s
feat: enhance exercise selection and training unit management
- 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.
2026-05-05 14:42:46 +02:00

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>
)
}