refactor: enhance filtering logic and UI for ExercisesListPage
- Removed the countActiveFilterGroups function and replaced it with a more comprehensive filterChips implementation to manage active filters. - Improved the rendering of active filters with dynamic chips that allow users to remove individual filters. - Updated the UI to reflect the new filtering logic, enhancing user experience and interaction with the filter options. - Adjusted the layout and styling for better visibility and usability of the filter components.
This commit is contained in:
parent
0d4ad9a2c8
commit
2c831d6cea
|
|
@ -1586,16 +1586,98 @@ a.analysis-split__nav-item {
|
|||
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;
|
||||
.exercise-filters-modal-grid--two {
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.exercise-filter-level-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.exercise-filter-chips-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.exercise-filter-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface2);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
color: var(--text1);
|
||||
text-align: left;
|
||||
}
|
||||
.exercise-filter-chip__text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: min(260px, 88vw);
|
||||
}
|
||||
.exercise-filter-chip__x {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.65;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.exercise-filter-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.exercise-filter-section--last {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.exercise-filter-section-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text3);
|
||||
font-weight: 700;
|
||||
}
|
||||
.exercise-filter-skill-block {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
background: var(--surface2);
|
||||
}
|
||||
.exercise-filter-skill-block > .form-label {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.exercise-filter-skill-hint {
|
||||
margin: 10px 0 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.exercise-filter-skill-levels-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.exercise-filter-skill-level-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.exercise-filter-skill-level-caption {
|
||||
font-size: 11px;
|
||||
color: var(--text3);
|
||||
font-weight: 600;
|
||||
}
|
||||
.exercise-filter-level-select {
|
||||
width: 72px;
|
||||
padding: 6px 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.exercise-filter-skill-dash {
|
||||
padding-bottom: 8px;
|
||||
color: var(--text3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Reifegradmodell-Admin: klare Schritte, responsives Raster */
|
||||
|
|
|
|||
|
|
@ -19,17 +19,9 @@ const INITIAL_FILTERS = {
|
|||
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 levelOptionShort(levelStr) {
|
||||
const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr))
|
||||
return o ? String(o.level) : String(levelStr)
|
||||
}
|
||||
|
||||
function ExercisesListPage() {
|
||||
|
|
@ -53,8 +45,6 @@ function ExercisesListPage() {
|
|||
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)
|
||||
return () => clearTimeout(t)
|
||||
|
|
@ -116,6 +106,122 @@ function ExercisesListPage() {
|
|||
[]
|
||||
)
|
||||
|
||||
const filterChips = useMemo(() => {
|
||||
const chips = []
|
||||
|
||||
;(filters.focus_area_ids || []).forEach((id) => {
|
||||
const opt = focusOptions.find((o) => String(o.id) === String(id))
|
||||
chips.push({
|
||||
key: `fa-${id}`,
|
||||
label: `Fokus: ${opt?.label ?? id}`,
|
||||
onRemove: () =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
focus_area_ids: prev.focus_area_ids.filter((x) => String(x) !== String(id)),
|
||||
})),
|
||||
})
|
||||
})
|
||||
;(filters.style_direction_ids || []).forEach((id) => {
|
||||
const opt = styleOptions.find((o) => String(o.id) === String(id))
|
||||
chips.push({
|
||||
key: `sd-${id}`,
|
||||
label: `Stil: ${opt?.label ?? id}`,
|
||||
onRemove: () =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
style_direction_ids: prev.style_direction_ids.filter((x) => String(x) !== String(id)),
|
||||
})),
|
||||
})
|
||||
})
|
||||
;(filters.training_type_ids || []).forEach((id) => {
|
||||
const opt = trainingTypeOptions.find((o) => String(o.id) === String(id))
|
||||
chips.push({
|
||||
key: `tt-${id}`,
|
||||
label: `Trainingsstil: ${opt?.label ?? id}`,
|
||||
onRemove: () =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
training_type_ids: prev.training_type_ids.filter((x) => String(x) !== String(id)),
|
||||
})),
|
||||
})
|
||||
})
|
||||
;(filters.target_group_ids || []).forEach((id) => {
|
||||
const opt = targetGroupOptions.find((o) => String(o.id) === String(id))
|
||||
chips.push({
|
||||
key: `tg-${id}`,
|
||||
label: `Zielgruppe: ${opt?.label ?? id}`,
|
||||
onRemove: () =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
target_group_ids: prev.target_group_ids.filter((x) => String(x) !== String(id)),
|
||||
})),
|
||||
})
|
||||
})
|
||||
;(filters.skill_ids || []).forEach((id) => {
|
||||
const opt = skillOptions.find((o) => String(o.id) === String(id))
|
||||
chips.push({
|
||||
key: `sk-${id}`,
|
||||
label: `Fähigkeit: ${opt?.label ?? id}`,
|
||||
onRemove: () =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
skill_ids: prev.skill_ids.filter((x) => String(x) !== String(id)),
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
if (filters.skill_min_level || filters.skill_max_level) {
|
||||
const a = filters.skill_min_level ? levelOptionShort(filters.skill_min_level) : '…'
|
||||
const b = filters.skill_max_level ? levelOptionShort(filters.skill_max_level) : '…'
|
||||
chips.push({
|
||||
key: 'skill-levels',
|
||||
label: `Stufe ${a}–${b}`,
|
||||
onRemove: () =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
skill_min_level: '',
|
||||
skill_max_level: '',
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
;(filters.visibility_any || []).forEach((id) => {
|
||||
const opt = visibilityOptions.find((o) => String(o.id) === String(id))
|
||||
chips.push({
|
||||
key: `vis-${id}`,
|
||||
label: `Sichtbarkeit: ${opt?.label ?? id}`,
|
||||
onRemove: () =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
visibility_any: prev.visibility_any.filter((x) => String(x) !== String(id)),
|
||||
})),
|
||||
})
|
||||
})
|
||||
;(filters.status_any || []).forEach((id) => {
|
||||
const opt = statusOptions.find((o) => String(o.id) === String(id))
|
||||
chips.push({
|
||||
key: `st-${id}`,
|
||||
label: `Status: ${opt?.label ?? id}`,
|
||||
onRemove: () =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
status_any: prev.status_any.filter((x) => String(x) !== String(id)),
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
return chips
|
||||
}, [
|
||||
filters,
|
||||
focusOptions,
|
||||
styleOptions,
|
||||
trainingTypeOptions,
|
||||
targetGroupOptions,
|
||||
skillOptions,
|
||||
visibilityOptions,
|
||||
statusOptions,
|
||||
])
|
||||
|
||||
const queryBase = useMemo(() => {
|
||||
const q = {}
|
||||
const n = (v) => (v === '' || v == null ? undefined : Number(v))
|
||||
|
|
@ -278,18 +384,37 @@ function ExercisesListPage() {
|
|||
<div className="exercise-search-bar__actions">
|
||||
<button type="button" className="btn btn-secondary exercise-filter-trigger" onClick={() => setFilterModalOpen(true)}>
|
||||
Filter
|
||||
{activeFilterGroups > 0 ? (
|
||||
{filterChips.length > 0 ? (
|
||||
<span className="exercise-filter-badge" aria-hidden>
|
||||
{activeFilterGroups}
|
||||
{filterChips.length}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{activeFilterGroups > 0 ? (
|
||||
{filterChips.length > 0 ? (
|
||||
<button type="button" className="btn" onClick={resetAllFilters}>
|
||||
Filter löschen
|
||||
Alle entfernen
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{filterChips.length > 0 ? (
|
||||
<div className="exercise-filter-chips-row" role="list" aria-label="Aktive Filter">
|
||||
{filterChips.map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className="exercise-filter-chip"
|
||||
title="Filter entfernen"
|
||||
onClick={() => c.onRemove()}
|
||||
>
|
||||
<span className="exercise-filter-chip__text">{c.label}</span>
|
||||
<span className="exercise-filter-chip__x" aria-hidden>
|
||||
×
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<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>
|
||||
|
|
@ -324,47 +449,55 @@ function ExercisesListPage() {
|
|||
</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).
|
||||
Zwischen den Bereichen gilt <strong>UND</strong>. Innerhalb eines Feldes werden mehrere Einträge mit{' '}
|
||||
<strong>ODER</strong> verknüpft.
|
||||
</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“ …"
|
||||
/>
|
||||
|
||||
<section className="exercise-filter-section">
|
||||
<h4 className="exercise-filter-section-title">Zuordnung</h4>
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section className="exercise-filter-section">
|
||||
<h4 className="exercise-filter-section-title">Fähigkeit und zugehörige Stufe</h4>
|
||||
<div className="exercise-filter-skill-block">
|
||||
<label className="form-label">Fähigkeit</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.skill_ids}
|
||||
|
|
@ -372,58 +505,72 @@ function ExercisesListPage() {
|
|||
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>
|
||||
<p className="exercise-filter-skill-hint">
|
||||
Die Stufen filtern nach dem Niveau der Zuordnung Übung ↔ Fähigkeit (von–bis).
|
||||
</p>
|
||||
<div className="exercise-filter-skill-levels-row">
|
||||
<label className="exercise-filter-skill-level-field">
|
||||
<span className="exercise-filter-skill-level-caption">von</span>
|
||||
<select
|
||||
className="form-input exercise-filter-level-select"
|
||||
title="Mindest-Stufe"
|
||||
value={filters.skill_min_level}
|
||||
onChange={(e) => setFilters({ ...filters, skill_min_level: e.target.value })}
|
||||
>
|
||||
<option value="">–</option>
|
||||
{LEVEL_FILTER_OPTS.map((o) => (
|
||||
<option key={o.value} value={String(o.level)} title={o.label}>
|
||||
{o.level}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className="exercise-filter-skill-dash" aria-hidden>
|
||||
–
|
||||
</span>
|
||||
<label className="exercise-filter-skill-level-field">
|
||||
<span className="exercise-filter-skill-level-caption">bis</span>
|
||||
<select
|
||||
className="form-input exercise-filter-level-select"
|
||||
title="Höchst-Stufe"
|
||||
value={filters.skill_max_level}
|
||||
onChange={(e) => setFilters({ ...filters, skill_max_level: e.target.value })}
|
||||
>
|
||||
<option value="">–</option>
|
||||
{LEVEL_FILTER_OPTS.map((o) => (
|
||||
<option key={`m-${o.value}`} value={String(o.level)} title={o.label}>
|
||||
{o.level}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</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 …"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="exercise-filter-section exercise-filter-section--last">
|
||||
<h4 className="exercise-filter-section-title">Freigabe</h4>
|
||||
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two">
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
<div className="exercise-filter-modal__footer">
|
||||
<button type="button" className="btn" onClick={resetAllFilters}>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user