feat: implement exercise filter modal and enhance filtering logic
- 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:
parent
fd2009294b
commit
0d4ad9a2c8
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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' }}>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user