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;
}
.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 */
.admin-matrix-alert {
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 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)
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() {
const [exercises, setExercises] = useState([])
const [catalogs, setCatalogs] = useState({
@ -26,17 +50,10 @@ function ExercisesListPage() {
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: [],
})
const [filters, setFilters] = useState(() => ({ ...INITIAL_FILTERS }))
const [filterModalOpen, setFilterModalOpen] = useState(false)
const activeFilterGroups = useMemo(() => countActiveFilterGroups(filters), [filters])
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
@ -48,6 +65,15 @@ function ExercisesListPage() {
return () => clearTimeout(t)
}, [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(
() =>
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]
)
const levelFilterOptions = useMemo(
() => LEVEL_FILTER_OPTS.map((o) => ({ id: o.level, label: o.label })),
[]
)
const visibilityOptions = useMemo(
() => [
{ id: 'private', label: 'Privat' },
@ -204,6 +226,8 @@ function ExercisesListPage() {
}
}
const resetAllFilters = useCallback(() => setFilters({ ...INITIAL_FILTERS }), [])
if (!catalogsReady || loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
@ -231,122 +255,188 @@ function ExercisesListPage() {
</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).
<div className="card exercise-search-bar" style={{ marginBottom: '12px' }}>
<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"
style={{ marginBottom: '10px' }}
/>
<label className="form-label">Ergänzende Suche / KI-Vorbereitung (Beta)</label>
<input
type="search"
className="form-input"
placeholder="zweiter Begriff — zusätzliche Volltextsuche (ODER)"
value={aiSearchInput}
onChange={(e) => setAiSearchInput(e.target.value)}
autoComplete="off"
/>
<div className="exercise-search-bar__actions">
<button type="button" className="btn btn-secondary exercise-filter-trigger" onClick={() => setFilterModalOpen(true)}>
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>
<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>
{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>
<label className="form-label">Fokus</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</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</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</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</label>
<MultiSelectCombo
value={filters.skill_ids}
onChange={(v) => setFilters({ ...filters, skill_ids: v })}
options={skillOptions}
placeholder="Fähigkeit suchen …"
/>
</div>
<div className="exercise-filter-level-row">
<div>
<label className="form-label">Fähigkeit Stufe von</label>
<select
className="form-input"
value={filters.skill_min_level}
onChange={(e) => setFilters({ ...filters, skill_min_level: e.target.value })}
>
<option value="">egal</option>
{LEVEL_FILTER_OPTS.map((o) => (
<option key={o.value} value={String(o.level)}>
{o.label}
</option>
))}
</select>
</div>
<div>
<label className="form-label">bis</label>
<select
className="form-input"
value={filters.skill_max_level}
onChange={(e) => setFilters({ ...filters, skill_max_level: e.target.value })}
>
<option value="">egal</option>
{LEVEL_FILTER_OPTS.map((o) => (
<option key={`m-${o.value}`} value={String(o.level)}>
{o.label}
</option>
))}
</select>
</div>
</div>
<div>
<label className="form-label">Sichtbarkeit</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</label>
<MultiSelectCombo
value={filters.status_any}
onChange={(v) => setFilters({ ...filters, status_any: v })}
options={statusOptions}
placeholder="Status wählen …"
/>
</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 ? (
<div className="card">
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>