feat: implement exercise filter modal and enhance filtering logic
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 5s
Test Suite / playwright-tests (push) Failing after 2m3s

- Added a new exercise filter modal with improved layout and styling for better user experience.
- Introduced initial filter state and logic to count active filter groups, enhancing the filtering capabilities on the ExercisesListPage.
- Updated CSS styles to support the new modal and improve overall UI consistency.
- Implemented keyboard accessibility for closing the filter modal, enhancing usability.
This commit is contained in:
Lars 2026-04-28 08:21:55 +02:00
parent fd2009294b
commit 0d4ad9a2c8
2 changed files with 281 additions and 130 deletions

View File

@ -1537,6 +1537,67 @@ a.analysis-split__nav-item {
overscroll-behavior: contain; overscroll-behavior: contain;
} }
.exercise-filter-modal.admin-modal-sheet {
max-width: min(920px, calc(100vw - 16px));
}
.exercise-filter-modal .admin-modal-sheet__body.exercise-filter-modal__scroll {
flex: 1;
min-height: 0;
}
.exercise-filter-modal__footer {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
align-items: center;
padding: 12px 16px;
padding-bottom: max(12px, env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--border);
flex-shrink: 0;
background: var(--surface);
}
.exercise-search-bar__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 12px;
}
.exercise-filter-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
}
.exercise-filter-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
background: var(--accent);
color: #fff;
}
.exercise-filters-modal-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px;
}
.exercise-filter-level-row {
grid-column: 1 / -1;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 480px) {
.exercise-filter-level-row {
grid-template-columns: 1fr;
}
}
/* Reifegradmodell-Admin: klare Schritte, responsives Raster */ /* Reifegradmodell-Admin: klare Schritte, responsives Raster */
.admin-matrix-alert { .admin-matrix-alert {
border: 1px solid var(--danger); border: 1px solid var(--danger);

View File

@ -1,13 +1,37 @@
import React, { useState, useEffect, useMemo } from 'react' import React, { useState, useEffect, useMemo, 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 { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels' import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import SearchableSelect from '../components/SearchableSelect'
import MultiSelectCombo from '../components/MultiSelectCombo' import MultiSelectCombo from '../components/MultiSelectCombo'
const PAGE_SIZE = 100 const PAGE_SIZE = 100
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) 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: [],
}
function countActiveFilterGroups(f) {
let n = 0
if (f.focus_area_ids?.length) n++
if (f.style_direction_ids?.length) n++
if (f.training_type_ids?.length) n++
if (f.target_group_ids?.length) n++
if (f.skill_ids?.length) n++
if (f.skill_min_level || f.skill_max_level) n++
if (f.visibility_any?.length) n++
if (f.status_any?.length) n++
return n
}
function ExercisesListPage() { function ExercisesListPage() {
const [exercises, setExercises] = useState([]) const [exercises, setExercises] = useState([])
const [catalogs, setCatalogs] = useState({ const [catalogs, setCatalogs] = useState({
@ -26,17 +50,10 @@ function ExercisesListPage() {
const [aiSearchInput, setAiSearchInput] = useState('') const [aiSearchInput, setAiSearchInput] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('')
const [debouncedAiSearch, setDebouncedAiSearch] = useState('') const [debouncedAiSearch, setDebouncedAiSearch] = useState('')
const [filters, setFilters] = useState({ const [filters, setFilters] = useState(() => ({ ...INITIAL_FILTERS }))
focus_area_ids: [], const [filterModalOpen, setFilterModalOpen] = useState(false)
style_direction_ids: [],
training_type_ids: [], const activeFilterGroups = useMemo(() => countActiveFilterGroups(filters), [filters])
target_group_ids: [],
skill_ids: [],
skill_min_level: '',
skill_max_level: '',
visibility_any: [],
status_any: [],
})
useEffect(() => { useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400) const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
@ -48,6 +65,15 @@ function ExercisesListPage() {
return () => clearTimeout(t) return () => clearTimeout(t)
}, [aiSearchInput]) }, [aiSearchInput])
useEffect(() => {
if (!filterModalOpen) return
const onKey = (e) => {
if (e.key === 'Escape') setFilterModalOpen(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [filterModalOpen])
const focusOptions = useMemo( const focusOptions = useMemo(
() => () =>
catalogs.focusAreas.map((fa) => ({ catalogs.focusAreas.map((fa) => ({
@ -72,10 +98,6 @@ function ExercisesListPage() {
() => catalogs.skills.map((s) => ({ id: s.id, label: s.name || String(s.id) })), () => catalogs.skills.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
[catalogs.skills] [catalogs.skills]
) )
const levelFilterOptions = useMemo(
() => LEVEL_FILTER_OPTS.map((o) => ({ id: o.level, label: o.label })),
[]
)
const visibilityOptions = useMemo( const visibilityOptions = useMemo(
() => [ () => [
{ id: 'private', label: 'Privat' }, { id: 'private', label: 'Privat' },
@ -204,6 +226,8 @@ function ExercisesListPage() {
} }
} }
const resetAllFilters = useCallback(() => setFilters({ ...INITIAL_FILTERS }), [])
if (!catalogsReady || loading) { if (!catalogsReady || loading) {
return ( return (
<div style={{ padding: '2rem', textAlign: 'center' }}> <div style={{ padding: '2rem', textAlign: 'center' }}>
@ -231,12 +255,7 @@ function ExercisesListPage() {
</Link> </Link>
</div> </div>
<div className="card exercise-filters-compact" style={{ marginBottom: '12px' }}> <div className="card exercise-search-bar" 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> <label className="form-label">Volltextsuche (Titel, Ziel, )</label>
<input <input
type="search" type="search"
@ -245,25 +264,72 @@ function ExercisesListPage() {
value={searchInput} value={searchInput}
onChange={(e) => setSearchInput(e.target.value)} onChange={(e) => setSearchInput(e.target.value)}
autoComplete="off" autoComplete="off"
style={{ marginBottom: '10px' }}
/> />
</div>
<div style={{ gridColumn: '1 / -1' }}>
<label className="form-label">Ergänzende Suche / KI-Vorbereitung (Beta)</label> <label className="form-label">Ergänzende Suche / KI-Vorbereitung (Beta)</label>
<input <input
type="search" type="search"
className="form-input" className="form-input"
placeholder="zweiter Begriff — aktuell zusätzliche Volltextsuche (ODER); später KI" placeholder="zweiter Begriff — zusätzliche Volltextsuche (ODER)"
value={aiSearchInput} value={aiSearchInput}
onChange={(e) => setAiSearchInput(e.target.value)} onChange={(e) => setAiSearchInput(e.target.value)}
autoComplete="off" autoComplete="off"
/> />
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '6px' }}> <div className="exercise-search-bar__actions">
Standardfilter aus Verein und Trainerrolle folgen später; die KI-Nutzung der zweiten Zeile <button type="button" className="btn btn-secondary exercise-filter-trigger" onClick={() => setFilterModalOpen(true)}>
kommt mit einem eigenen Endpunkt. Filter
{activeFilterGroups > 0 ? (
<span className="exercise-filter-badge" aria-hidden>
{activeFilterGroups}
</span>
) : null}
</button>
{activeFilterGroups > 0 ? (
<button type="button" className="btn" onClick={resetAllFilters}>
Filter löschen
</button>
) : null}
</div>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '10px', marginBottom: 0 }}>
Vereins-/Trainerfilter folgen später. Fachliche Filter über Filter zwischen Feldern UND, Auswahl mehrerer Werte je Feld mit ODER.
</p> </p>
</div> </div>
{filterModalOpen && (
<div
className="admin-modal-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) setFilterModalOpen(false)
}}
>
<div
className="admin-modal-sheet exercise-filter-modal"
role="dialog"
aria-modal="true"
aria-labelledby="exercise-filter-modal-title"
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 id="exercise-filter-modal-title" className="admin-modal-sheet__title">
Übungen filtern
</h3>
<button
type="button"
className="btn btn-secondary admin-modal-sheet__close"
onClick={() => setFilterModalOpen(false)}
>
Schließen
</button>
</div>
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
<p style={{ fontSize: '13px', color: 'var(--text2)', marginTop: 0, marginBottom: '14px' }}>
Zwischen den Bereichen gilt <strong>UND</strong>. Innerhalb eines Bereichs werden mehrere Einträge mit{' '}
<strong>ODER</strong> verknüpft (die Übung muss mindestens eine gewählte Zuordnung erfüllen).
</p>
<div className="exercise-filters-modal-grid">
<div> <div>
<label className="form-label">Fokus (mehrere möglich)</label> <label className="form-label">Fokus</label>
<MultiSelectCombo <MultiSelectCombo
value={filters.focus_area_ids} value={filters.focus_area_ids}
onChange={(v) => setFilters({ ...filters, focus_area_ids: v })} onChange={(v) => setFilters({ ...filters, focus_area_ids: v })}
@ -272,7 +338,7 @@ function ExercisesListPage() {
/> />
</div> </div>
<div> <div>
<label className="form-label">Stilrichtung (mehrere möglich)</label> <label className="form-label">Stilrichtung</label>
<MultiSelectCombo <MultiSelectCombo
value={filters.style_direction_ids} value={filters.style_direction_ids}
onChange={(v) => setFilters({ ...filters, style_direction_ids: v })} onChange={(v) => setFilters({ ...filters, style_direction_ids: v })}
@ -281,7 +347,7 @@ function ExercisesListPage() {
/> />
</div> </div>
<div> <div>
<label className="form-label">Trainingsstil (mehrere möglich)</label> <label className="form-label">Trainingsstil</label>
<MultiSelectCombo <MultiSelectCombo
value={filters.training_type_ids} value={filters.training_type_ids}
onChange={(v) => setFilters({ ...filters, training_type_ids: v })} onChange={(v) => setFilters({ ...filters, training_type_ids: v })}
@ -290,7 +356,7 @@ function ExercisesListPage() {
/> />
</div> </div>
<div> <div>
<label className="form-label">Zielgruppe (mehrere möglich)</label> <label className="form-label">Zielgruppe</label>
<MultiSelectCombo <MultiSelectCombo
value={filters.target_group_ids} value={filters.target_group_ids}
onChange={(v) => setFilters({ ...filters, target_group_ids: v })} onChange={(v) => setFilters({ ...filters, target_group_ids: v })}
@ -299,7 +365,7 @@ function ExercisesListPage() {
/> />
</div> </div>
<div> <div>
<label className="form-label">Fähigkeit (mehrere möglich)</label> <label className="form-label">Fähigkeit</label>
<MultiSelectCombo <MultiSelectCombo
value={filters.skill_ids} value={filters.skill_ids}
onChange={(v) => setFilters({ ...filters, skill_ids: v })} onChange={(v) => setFilters({ ...filters, skill_ids: v })}
@ -307,28 +373,40 @@ function ExercisesListPage() {
placeholder="Fähigkeit suchen …" placeholder="Fähigkeit suchen …"
/> />
</div> </div>
<div className="exercise-filter-level-row">
<div> <div>
<label className="form-label">Fähigkeit Stufe von</label> <label className="form-label">Fähigkeit Stufe von</label>
<SearchableSelect <select
className="form-input"
value={filters.skill_min_level} value={filters.skill_min_level}
onChange={(v) => setFilters({ ...filters, skill_min_level: v })} onChange={(e) => setFilters({ ...filters, skill_min_level: e.target.value })}
options={levelFilterOptions} >
allLabel="egal (min)" <option value="">egal</option>
filterPlaceholder="Stufe suchen…" {LEVEL_FILTER_OPTS.map((o) => (
/> <option key={o.value} value={String(o.level)}>
{o.label}
</option>
))}
</select>
</div> </div>
<div> <div>
<label className="form-label">Fähigkeit Stufe bis</label> <label className="form-label">bis</label>
<SearchableSelect <select
className="form-input"
value={filters.skill_max_level} value={filters.skill_max_level}
onChange={(v) => setFilters({ ...filters, skill_max_level: v })} onChange={(e) => setFilters({ ...filters, skill_max_level: e.target.value })}
options={levelFilterOptions} >
allLabel="egal (max)" <option value="">egal</option>
filterPlaceholder="Stufe suchen…" {LEVEL_FILTER_OPTS.map((o) => (
/> <option key={`m-${o.value}`} value={String(o.level)}>
{o.label}
</option>
))}
</select>
</div>
</div> </div>
<div> <div>
<label className="form-label">Sichtbarkeit (mehrere möglich)</label> <label className="form-label">Sichtbarkeit</label>
<MultiSelectCombo <MultiSelectCombo
value={filters.visibility_any} value={filters.visibility_any}
onChange={(v) => setFilters({ ...filters, visibility_any: v })} onChange={(v) => setFilters({ ...filters, visibility_any: v })}
@ -337,7 +415,7 @@ function ExercisesListPage() {
/> />
</div> </div>
<div> <div>
<label className="form-label">Status (mehrere möglich)</label> <label className="form-label">Status</label>
<MultiSelectCombo <MultiSelectCombo
value={filters.status_any} value={filters.status_any}
onChange={(v) => setFilters({ ...filters, status_any: v })} onChange={(v) => setFilters({ ...filters, status_any: v })}
@ -346,6 +424,18 @@ function ExercisesListPage() {
/> />
</div> </div>
</div> </div>
</div>
<div className="exercise-filter-modal__footer">
<button type="button" className="btn" onClick={resetAllFilters}>
Alle Filter zurücksetzen
</button>
<button type="button" className="btn btn-primary" onClick={() => setFilterModalOpen(false)}>
Fertig
</button>
</div>
</div>
</div>
)}
{exercises.length === 0 ? ( {exercises.length === 0 ? (
<div className="card"> <div className="card">