- Introduced multi-select functionality for filtering exercises by focus areas, style directions, training types, target groups, and skills, allowing users to select multiple options. - Updated the ExercisesListPage to utilize the new multi-select component, improving the user experience for filtering exercises. - Enhanced backend filtering logic to support new array-based query parameters, ensuring efficient handling of multiple filter criteria. - Adjusted CSS styles for better layout and usability of the exercise filters.
432 lines
15 KiB
JavaScript
432 lines
15 KiB
JavaScript
import React, { useState, useEffect, useMemo } from 'react'
|
|
import { Link } from 'react-router-dom'
|
|
import api from '../utils/api'
|
|
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
|
import SearchableSelect from '../components/SearchableSelect'
|
|
import MultiSelectCombo from '../components/MultiSelectCombo'
|
|
|
|
const PAGE_SIZE = 100
|
|
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
|
|
|
|
function ExercisesListPage() {
|
|
const [exercises, setExercises] = useState([])
|
|
const [catalogs, setCatalogs] = useState({
|
|
focusAreas: [],
|
|
styleDirections: [],
|
|
trainingTypes: [],
|
|
targetGroups: [],
|
|
skills: [],
|
|
})
|
|
const [catalogsReady, setCatalogsReady] = useState(false)
|
|
const [loading, setLoading] = useState(true)
|
|
const [loadingMore, setLoadingMore] = useState(false)
|
|
const [offset, setOffset] = useState(0)
|
|
const [hasMore, setHasMore] = useState(false)
|
|
const [searchInput, setSearchInput] = useState('')
|
|
const [aiSearchInput, setAiSearchInput] = useState('')
|
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
|
const [debouncedAiSearch, setDebouncedAiSearch] = useState('')
|
|
const [filters, setFilters] = useState({
|
|
focus_area_ids: [],
|
|
style_direction_ids: [],
|
|
training_type_ids: [],
|
|
target_group_ids: [],
|
|
skill_ids: [],
|
|
skill_min_level: '',
|
|
skill_max_level: '',
|
|
visibility_any: [],
|
|
status_any: [],
|
|
})
|
|
|
|
useEffect(() => {
|
|
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
|
|
return () => clearTimeout(t)
|
|
}, [searchInput])
|
|
|
|
useEffect(() => {
|
|
const t = setTimeout(() => setDebouncedAiSearch(aiSearchInput.trim()), 400)
|
|
return () => clearTimeout(t)
|
|
}, [aiSearchInput])
|
|
|
|
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 levelFilterOptions = useMemo(
|
|
() => LEVEL_FILTER_OPTS.map((o) => ({ id: o.level, label: o.label })),
|
|
[]
|
|
)
|
|
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 (debouncedAiSearch) q.ai_search = debouncedAiSearch
|
|
return q
|
|
}, [filters, debouncedSearch, debouncedAiSearch])
|
|
|
|
useEffect(() => {
|
|
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 (err) {
|
|
if (!cancelled) {
|
|
console.error(err)
|
|
alert('Kataloge konnten nicht geladen werden: ' + err.message)
|
|
setCatalogsReady(true)
|
|
}
|
|
}
|
|
})()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!catalogsReady) return
|
|
let cancelled = false
|
|
const run = async () => {
|
|
setLoading(true)
|
|
setOffset(0)
|
|
try {
|
|
const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset: 0 })
|
|
if (cancelled) return
|
|
setExercises(batch)
|
|
setHasMore(batch.length === PAGE_SIZE)
|
|
setOffset(batch.length)
|
|
} catch (err) {
|
|
if (!cancelled) {
|
|
console.error('Failed to load data:', err)
|
|
alert('Fehler beim Laden: ' + err.message)
|
|
}
|
|
} finally {
|
|
if (!cancelled) setLoading(false)
|
|
}
|
|
}
|
|
run()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [queryBase, catalogsReady])
|
|
|
|
const loadMore = async () => {
|
|
if (loadingMore || !hasMore) return
|
|
setLoadingMore(true)
|
|
try {
|
|
const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset })
|
|
setExercises((prev) => [...prev, ...batch])
|
|
setHasMore(batch.length === PAGE_SIZE)
|
|
setOffset((o) => o + batch.length)
|
|
} catch (err) {
|
|
alert('Fehler: ' + err.message)
|
|
} finally {
|
|
setLoadingMore(false)
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (exercise) => {
|
|
if (!confirm(`Übung "${exercise.title}" wirklich löschen?`)) return
|
|
try {
|
|
await api.deleteExercise(exercise.id)
|
|
setExercises((prev) => prev.filter((e) => e.id !== exercise.id))
|
|
} catch (err) {
|
|
alert('Fehler beim Löschen: ' + err.message)
|
|
}
|
|
}
|
|
|
|
if (!catalogsReady || loading) {
|
|
return (
|
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
|
<div className="spinner"></div>
|
|
<p>Laden...</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div style={{ padding: '12px', maxWidth: '1200px', margin: '0 auto' }}>
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: '12px',
|
|
flexWrap: 'wrap',
|
|
gap: '8px',
|
|
}}
|
|
>
|
|
<h1 style={{ fontSize: '1.35rem' }}>Übungen</h1>
|
|
<Link to="/exercises/new" className="btn btn-primary">
|
|
+ Neu
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="card exercise-filters-compact" style={{ marginBottom: '12px' }}>
|
|
<p style={{ gridColumn: '1 / -1', fontSize: '12px', color: 'var(--text3)', margin: '0 0 4px' }}>
|
|
Filter untereinander werden mit <strong>UND</strong> kombiniert. Mehrere Einträge in einem Feld mit{' '}
|
|
<strong>ODER</strong> (die Übung muss mindestens eine der gewählten Zuordnungen erfüllen).
|
|
</p>
|
|
<div style={{ gridColumn: '1 / -1' }}>
|
|
<label className="form-label">Volltextsuche (Titel, Ziel, …)</label>
|
|
<input
|
|
type="search"
|
|
className="form-input"
|
|
placeholder="Suchbegriffe…"
|
|
value={searchInput}
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
<div style={{ gridColumn: '1 / -1' }}>
|
|
<label className="form-label">Ergänzende Suche / KI-Vorbereitung (Beta)</label>
|
|
<input
|
|
type="search"
|
|
className="form-input"
|
|
placeholder="zweiter Begriff — aktuell zusätzliche Volltextsuche (ODER); später KI"
|
|
value={aiSearchInput}
|
|
onChange={(e) => setAiSearchInput(e.target.value)}
|
|
autoComplete="off"
|
|
/>
|
|
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '6px' }}>
|
|
Standardfilter aus Verein und Trainerrolle folgen später; die KI-Nutzung der zweiten Zeile
|
|
kommt mit einem eigenen Endpunkt.
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<label className="form-label">Fokus (mehrere möglich)</label>
|
|
<MultiSelectCombo
|
|
value={filters.focus_area_ids}
|
|
onChange={(v) => setFilters({ ...filters, focus_area_ids: v })}
|
|
options={focusOptions}
|
|
placeholder="Fokus suchen oder „▼ Alle“ …"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="form-label">Stilrichtung (mehrere möglich)</label>
|
|
<MultiSelectCombo
|
|
value={filters.style_direction_ids}
|
|
onChange={(v) => setFilters({ ...filters, style_direction_ids: v })}
|
|
options={styleOptions}
|
|
placeholder="Stilrichtung suchen …"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="form-label">Trainingsstil (mehrere möglich)</label>
|
|
<MultiSelectCombo
|
|
value={filters.training_type_ids}
|
|
onChange={(v) => setFilters({ ...filters, training_type_ids: v })}
|
|
options={trainingTypeOptions}
|
|
placeholder="Trainingsstil suchen …"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="form-label">Zielgruppe (mehrere möglich)</label>
|
|
<MultiSelectCombo
|
|
value={filters.target_group_ids}
|
|
onChange={(v) => setFilters({ ...filters, target_group_ids: v })}
|
|
options={targetGroupOptions}
|
|
placeholder="Zielgruppe suchen …"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="form-label">Fähigkeit (mehrere möglich)</label>
|
|
<MultiSelectCombo
|
|
value={filters.skill_ids}
|
|
onChange={(v) => setFilters({ ...filters, skill_ids: v })}
|
|
options={skillOptions}
|
|
placeholder="Fähigkeit suchen …"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="form-label">Fähigkeit Stufe von</label>
|
|
<SearchableSelect
|
|
value={filters.skill_min_level}
|
|
onChange={(v) => setFilters({ ...filters, skill_min_level: v })}
|
|
options={levelFilterOptions}
|
|
allLabel="egal (min)"
|
|
filterPlaceholder="Stufe suchen…"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="form-label">Fähigkeit Stufe bis</label>
|
|
<SearchableSelect
|
|
value={filters.skill_max_level}
|
|
onChange={(v) => setFilters({ ...filters, skill_max_level: v })}
|
|
options={levelFilterOptions}
|
|
allLabel="egal (max)"
|
|
filterPlaceholder="Stufe suchen…"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="form-label">Sichtbarkeit (mehrere möglich)</label>
|
|
<MultiSelectCombo
|
|
value={filters.visibility_any}
|
|
onChange={(v) => setFilters({ ...filters, visibility_any: v })}
|
|
options={visibilityOptions}
|
|
placeholder="Sichtbarkeit wählen …"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="form-label">Status (mehrere möglich)</label>
|
|
<MultiSelectCombo
|
|
value={filters.status_any}
|
|
onChange={(v) => setFilters({ ...filters, status_any: v })}
|
|
options={statusOptions}
|
|
placeholder="Status wählen …"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{exercises.length === 0 ? (
|
|
<div className="card">
|
|
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
|
Keine Übungen gefunden.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '10px' }}>
|
|
{exercises.length} angezeigt
|
|
{hasMore ? ' · es gibt weitere Einträge' : ''}
|
|
</p>
|
|
<div
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
|
gap: '12px',
|
|
}}
|
|
>
|
|
{exercises.map((exercise) => (
|
|
<div key={exercise.id} className="card exercise-card">
|
|
<div className="exercise-card__body">
|
|
<h3 style={{ marginBottom: '8px', fontSize: '1.05rem', lineHeight: 1.3 }}>
|
|
<Link
|
|
to={`/exercises/${exercise.id}`}
|
|
style={{ color: 'inherit', textDecoration: 'none' }}
|
|
>
|
|
{exercise.title}
|
|
</Link>
|
|
</h3>
|
|
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginBottom: '8px' }}>
|
|
{exercise.focus_area && (
|
|
<span className="exercise-tag exercise-tag--accent">{exercise.focus_area}</span>
|
|
)}
|
|
<span className="exercise-tag">{exercise.visibility}</span>
|
|
<span className="exercise-tag">{exercise.status}</span>
|
|
</div>
|
|
{exercise.summary && (
|
|
<p style={{ color: 'var(--text2)', fontSize: '13px', lineHeight: 1.4 }}>
|
|
{exercise.summary.length > 160
|
|
? `${exercise.summary.slice(0, 160)}…`
|
|
: exercise.summary}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="exercise-card__actions">
|
|
<Link to={`/exercises/${exercise.id}`} className="btn btn-secondary">
|
|
Ansehen
|
|
</Link>
|
|
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-secondary">
|
|
Bearbeiten
|
|
</Link>
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
style={{
|
|
background: 'var(--danger)',
|
|
color: 'white',
|
|
border: 'none',
|
|
}}
|
|
onClick={() => handleDelete(exercise)}
|
|
>
|
|
Löschen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{hasMore && (
|
|
<div style={{ textAlign: 'center', marginTop: '16px' }}>
|
|
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
|
|
{loadingMore ? 'Laden…' : 'Mehr laden'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default ExercisesListPage
|