feat: enhance exercise filtering capabilities with new catalog rules
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 7s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 28s
Test Suite / pytest-backend (pull_request) Successful in 5s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 7s
Test Suite / playwright-tests (pull_request) Failing after 28s

- Introduced new filtering options for style directions, training types, and target groups in the exercise list.
- Implemented catalog rule picker components to manage inclusion and exclusion of exercise attributes.
- Updated utility functions to handle new catalog rules for improved filtering logic.
- Enhanced the ExercisesListPage and ExercisePickerModal to support the new filtering features, improving user experience.
This commit is contained in:
Lars 2026-05-06 21:20:19 +02:00
parent 518918a6e5
commit b9d27b59b0
7 changed files with 637 additions and 382 deletions

View File

@ -981,6 +981,30 @@ def list_exercises(
default=[],
description="Keiner dieser Fokusbereiche darf gesetzt sein („−“)",
),
style_direction_must_include_ids: list[int] = Query(
default=[],
description="Alle genannten Stilrichtungen müssen der Übung zugeordnet sein (UND)",
),
style_direction_must_exclude_ids: list[int] = Query(
default=[],
description="Keine dieser Stilrichtungen darf zugeordnet sein",
),
training_type_must_include_ids: list[int] = Query(
default=[],
description="Alle genannten Trainingsstile müssen zugeordnet sein (UND)",
),
training_type_must_exclude_ids: list[int] = Query(
default=[],
description="Keiner dieser Trainingsstile darf zugeordnet sein",
),
target_group_must_include_ids: list[int] = Query(
default=[],
description="Alle genannten Zielgruppen müssen zugeordnet sein (UND)",
),
target_group_must_exclude_ids: list[int] = Query(
default=[],
description="Keine dieser Zielgruppen darf zugeordnet sein",
),
include_archived: bool = Query(
default=False,
description="Archivierte einbeziehen; Standard false (außer Statusfilter enthält archived)",
@ -1110,32 +1134,77 @@ def list_exercises(
)
params.extend(sk_ids)
sd_ids = _merge_ids(style_direction_ids, style_direction_id)
if sd_ids:
ph = ",".join(["%s"] * len(sd_ids))
sd_or = _merge_ids(style_direction_ids, style_direction_id)
sd_inc = _dedupe_positive_ids(list(style_direction_must_include_ids))
sd_exc = _dedupe_positive_ids(list(style_direction_must_exclude_ids))
if sd_or:
ph = ",".join(["%s"] * len(sd_or))
where.append(
"EXISTS (SELECT 1 FROM exercise_style_directions esd "
f"WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))"
)
params.extend(sd_ids)
params.extend(sd_or)
for sid in sd_inc:
where.append(
"EXISTS (SELECT 1 FROM exercise_style_directions esd "
"WHERE esd.exercise_id = e.id AND esd.style_direction_id = %s)"
)
params.append(sid)
if sd_exc:
ph = ",".join(["%s"] * len(sd_exc))
where.append(
"NOT EXISTS (SELECT 1 FROM exercise_style_directions esd "
f"WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))"
)
params.extend(sd_exc)
tt_ids = _merge_ids(training_type_ids, training_type_id)
if tt_ids:
ph = ",".join(["%s"] * len(tt_ids))
tt_or = _merge_ids(training_type_ids, training_type_id)
tt_inc = _dedupe_positive_ids(list(training_type_must_include_ids))
tt_exc = _dedupe_positive_ids(list(training_type_must_exclude_ids))
if tt_or:
ph = ",".join(["%s"] * len(tt_or))
where.append(
"EXISTS (SELECT 1 FROM exercise_training_types ett "
f"WHERE ett.exercise_id = e.id AND ett.training_type_id IN ({ph}))"
)
params.extend(tt_ids)
params.extend(tt_or)
for tid in tt_inc:
where.append(
"EXISTS (SELECT 1 FROM exercise_training_types ett "
"WHERE ett.exercise_id = e.id AND ett.training_type_id = %s)"
)
params.append(tid)
if tt_exc:
ph = ",".join(["%s"] * len(tt_exc))
where.append(
"NOT EXISTS (SELECT 1 FROM exercise_training_types ett "
f"WHERE ett.exercise_id = e.id AND ett.training_type_id IN ({ph}))"
)
params.extend(tt_exc)
tg_ids = _merge_ids(target_group_ids, target_group_id)
if tg_ids:
ph = ",".join(["%s"] * len(tg_ids))
tg_or = _merge_ids(target_group_ids, target_group_id)
tg_inc = _dedupe_positive_ids(list(target_group_must_include_ids))
tg_exc = _dedupe_positive_ids(list(target_group_must_exclude_ids))
if tg_or:
ph = ",".join(["%s"] * len(tg_or))
where.append(
"EXISTS (SELECT 1 FROM exercise_target_groups etg "
f"WHERE etg.exercise_id = e.id AND etg.target_group_id IN ({ph}))"
)
params.extend(tg_ids)
params.extend(tg_or)
for gid in tg_inc:
where.append(
"EXISTS (SELECT 1 FROM exercise_target_groups etg "
"WHERE etg.exercise_id = e.id AND etg.target_group_id = %s)"
)
params.append(gid)
if tg_exc:
ph = ",".join(["%s"] * len(tg_exc))
where.append(
"NOT EXISTS (SELECT 1 FROM exercise_target_groups etg "
f"WHERE etg.exercise_id = e.id AND etg.target_group_id IN ({ph}))"
)
params.extend(tg_exc)
if skill_min_level is not None or skill_max_level is not None:
lo = skill_min_level if skill_min_level is not None else 1

View File

@ -2732,6 +2732,10 @@ a.analysis-split__nav-item {
.exercise-filters-modal-grid--two {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.exercise-filters-modal-grid--catalog {
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
gap: 10px 12px;
}
.exercise-filter-chips-row {
display: flex;
flex-wrap: wrap;
@ -2823,6 +2827,93 @@ a.analysis-split__nav-item {
font-weight: 600;
}
/* Übungsfilter: kompakte +/- Katalogregeln */
.catalog-rule-picker {
margin-bottom: 10px;
}
.catalog-rule-picker--disabled {
opacity: 0.55;
pointer-events: none;
}
.catalog-rule-picker__label {
margin-bottom: 0;
font-size: 13px;
}
.catalog-rule-picker__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
min-height: 0;
margin-bottom: 6px;
}
.catalog-rule-chip {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
padding: 2px 6px 2px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface2);
font-size: 12px;
line-height: 1.35;
}
.catalog-rule-chip__sign {
font-weight: 700;
font-size: 12px;
flex-shrink: 0;
opacity: 0.85;
}
.catalog-rule-chip__sign--require {
color: var(--accent-dark);
}
.catalog-rule-chip__sign--forbid {
color: var(--danger);
}
.catalog-rule-chip__text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 180px;
}
.catalog-rule-chip__x {
flex-shrink: 0;
margin: 0;
padding: 0 4px;
border: none;
background: transparent;
color: var(--text2);
font-size: 15px;
line-height: 1;
cursor: pointer;
border-radius: 4px;
}
.catalog-rule-chip__x:hover {
color: var(--text1);
background: var(--border);
}
.catalog-rule-picker__row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.catalog-rule-picker__select {
flex: 0 1 132px;
max-width: 160px;
min-width: 96px;
padding: 6px 8px;
font-size: 13px;
}
.catalog-rule-picker__sign-btn {
min-width: 32px;
padding-left: 10px;
padding-right: 10px;
font-weight: 700;
font-size: 14px;
}
/* Reifegradmodell-Admin: klare Schritte, responsives Raster */
.admin-matrix-alert {
border: 1px solid var(--danger);

View File

@ -0,0 +1,109 @@
import React, { useState } from 'react'
import { newCatalogRuleKey } from '../constants/exerciseListFilters'
/**
* Kompakte +/- Regeln für Katalogwerte (numerische IDs oder Slugs).
* Chips oben, schmales Dropdown, Schalter nur + und .
*/
export default function CatalogRulePicker({
label,
hint,
options = [],
rules = [],
rulesFieldName,
disabled = false,
placeholder = 'Auswählen …',
idKind = 'numeric',
onPatch,
}) {
const [pendingId, setPendingId] = useState('')
const labelFor = (id) => options.find((o) => String(o.id) === String(id))?.label ?? id
const addRule = (mode) => {
const raw = String(pendingId || '').trim()
if (!raw || disabled) return
if (idKind === 'numeric') {
const n = Number(raw)
if (!Number.isFinite(n) || n < 1) return
}
const dup = (rules || []).some((r) => String(r.id) === raw && r.mode === mode)
if (dup) return
onPatch({
[rulesFieldName]: [
...(rules || []),
{ key: newCatalogRuleKey(rulesFieldName), id: raw, mode },
],
})
setPendingId('')
}
const removeRule = (key) => {
onPatch({
[rulesFieldName]: (rules || []).filter((r) => r.key !== key),
})
}
return (
<div className={`catalog-rule-picker${disabled ? ' catalog-rule-picker--disabled' : ''}`}>
<label className="form-label catalog-rule-picker__label">{label}</label>
{hint ? (
<p className="muted catalog-rule-picker__hint" style={{ fontSize: '11px', marginTop: '2px', marginBottom: '6px' }}>
{hint}
</p>
) : null}
<div className="catalog-rule-picker__chips" aria-live="polite">
{(rules || []).map((r) => (
<span key={r.key} className="catalog-rule-chip">
<span className={`catalog-rule-chip__sign catalog-rule-chip__sign--${r.mode}`}>
{r.mode === 'forbid' ? '' : '+'}
</span>
<span className="catalog-rule-chip__text">{labelFor(r.id)}</span>
<button
type="button"
className="catalog-rule-chip__x"
aria-label={`${label}: Regel entfernen`}
onClick={() => removeRule(r.key)}
>
×
</button>
</span>
))}
</div>
<div className="catalog-rule-picker__row">
<select
className="form-input catalog-rule-picker__select"
value={pendingId}
disabled={disabled}
onChange={(e) => setPendingId(e.target.value)}
aria-label={label}
>
<option value="">{placeholder}</option>
{options.map((o) => (
<option key={o.id} value={String(o.id)}>
{o.label || o.id}
</option>
))}
</select>
<button
type="button"
className="btn btn-secondary btn-small catalog-rule-picker__sign-btn"
disabled={disabled || !pendingId}
title="Muss zutreffen"
onClick={() => addRule('require')}
>
+
</button>
<button
type="button"
className="btn btn-secondary btn-small catalog-rule-picker__sign-btn"
disabled={disabled || !pendingId}
title="Darf nicht zutreffen"
onClick={() => addRule('forbid')}
>
</button>
</div>
</div>
)
}

View File

@ -1,46 +1,18 @@
import React, { useMemo, useState } from 'react'
function newRuleKey() {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
return `fr-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
import React from 'react'
import CatalogRulePicker from './CatalogRulePicker'
/**
* Fokusbereiche mit + (muss haben) und (darf nicht haben); optional nur Übungen ohne Fokus-Zuordnung.
* Fokusbereiche inkl. nur ohne Zuordnung; Regeln über CatalogRulePicker (+/).
*/
export default function ExerciseFocusRulePicker({
focusOptions,
focusRules,
focusOnlyWithout,
excludeWithoutFocus,
legacyFocusAreaIds = [],
onPatch,
}) {
const [pendingId, setPendingId] = useState('')
const legacyWarning = useMemo(
() => Array.isArray(legacyFocusAreaIds) && legacyFocusAreaIds.length > 0,
[legacyFocusAreaIds]
)
const addRule = (mode) => {
const id = String(pendingId || '').trim()
if (!id || focusOnlyWithout) return
const dup = (focusRules || []).some(
(r) => String(r.focus_area_id) === id && r.mode === mode
)
if (dup) return
onPatch({
focus_rules: [...(focusRules || []), { key: newRuleKey(), focus_area_id: id, mode }],
})
setPendingId('')
}
const removeRule = (key) => {
onPatch({
focus_rules: (focusRules || []).filter((r) => r.key !== key),
})
}
const legacyWarning =
Array.isArray(legacyFocusAreaIds) && legacyFocusAreaIds.length > 0 && !focusOnlyWithout
const setFocusOnly = (on) => {
if (on) {
@ -50,7 +22,6 @@ export default function ExerciseFocusRulePicker({
focus_rules: [],
focus_area_ids: [],
})
setPendingId('')
return
}
onPatch({ focus_only_without: false })
@ -59,85 +30,33 @@ export default function ExerciseFocusRulePicker({
return (
<div className="exercise-focus-rule-picker">
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', marginBottom: '10px' }}>
<input
type="checkbox"
checked={!!focusOnlyWithout}
onChange={(e) => setFocusOnly(e.target.checked)}
/>
<span>Nur Übungen <strong>ohne</strong> Fokusbereich (keine Zuordnung)</span>
<input type="checkbox" checked={!!focusOnlyWithout} onChange={(e) => setFocusOnly(e.target.checked)} />
<span>
Nur Übungen <strong>ohne</strong> Fokusbereich (keine Zuordnung)
</span>
</label>
{!focusOnlyWithout ? (
<>
{legacyWarning ? (
<p className="muted" style={{ fontSize: '13px', marginTop: 0, marginBottom: '10px' }}>
Es sind noch ältere Fokusfilter (ODER-Liste) aktiv bitte über die Chips entfernen oder durch Regeln
ersetzen.
<p className="muted" style={{ fontSize: '12px', marginTop: 0, marginBottom: '8px' }}>
Ältere ODER-Fokusliste aktiv über die Chips auf der Übersicht entfernen.
</p>
) : null}
<label className="form-label">Fokusbereiche (+ mit / ohne)</label>
<p className="muted" style={{ fontSize: '12px', marginTop: 0, marginBottom: '8px' }}>
Mehrere +: alle müssen gesetzt sein (UND). Mehrere : keiner davon darf gesetzt sein.
Kombination z.B. + Karate und Fitness = hat Karate, aber nicht zusätzlich Fitness.
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', marginBottom: '10px' }}>
<select
className="form-input"
style={{ minWidth: '200px', flex: '1 1 180px' }}
value={pendingId}
onChange={(e) => setPendingId(e.target.value)}
aria-label="Fokusbereich wählen"
>
<option value="">Fokusbereich wählen </option>
{focusOptions.map((o) => (
<option key={o.id} value={String(o.id)}>
{o.label || o.id}
</option>
))}
</select>
<button type="button" className="btn btn-secondary btn-small" disabled={!pendingId} onClick={() => addRule('require')}>
+ Mit
</button>
<button type="button" className="btn btn-secondary btn-small" disabled={!pendingId} onClick={() => addRule('forbid')}>
Ohne
</button>
</div>
{(focusRules || []).length > 0 ? (
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 12px 0' }}>
{(focusRules || []).map((r) => {
const opt = focusOptions.find((o) => String(o.id) === String(r.focus_area_id))
const name = opt?.label || r.focus_area_id
return (
<li
key={r.key}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
padding: '6px 10px',
marginBottom: '6px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
fontSize: '13px',
}}
>
<span>
<strong>{r.mode === 'forbid' ? '' : '+'}</strong> {name}
</span>
<button type="button" className="btn btn-secondary btn-small" onClick={() => removeRule(r.key)}>
Entfernen
</button>
</li>
)
})}
</ul>
) : null}
<CatalogRulePicker
label="Fokusbereiche"
hint="+ alle erforderlich (UND). keine dieser Zuordnungen."
options={focusOptions}
rules={focusRules}
rulesFieldName="focus_rules"
idKind="numeric"
placeholder="Fokus …"
onPatch={onPatch}
/>
</>
) : (
<p className="muted" style={{ fontSize: '13px', marginTop: 0 }}>
Solange diese Option aktiv ist, sind Fokus-Regeln und die ODER-Fokusliste deaktiviert.
<p className="muted" style={{ fontSize: '12px', marginTop: 0 }}>
Fokus-Regeln sind deaktiviert.
</p>
)}
</div>

View File

@ -9,9 +9,12 @@ import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import {
INITIAL_EXERCISE_LIST_FILTERS,
mergeExerciseListPrefsFromApi,
splitMnCatalogRules,
splitScalarCatalogRules,
} from '../constants/exerciseListFilters'
import MultiSelectCombo from './MultiSelectCombo'
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
import CatalogRulePicker from './CatalogRulePicker'
const PAGE_SIZE = 100
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
@ -155,36 +158,44 @@ export default function ExercisePickerModal({
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 mustInc = []
const mustExc = []
for (const r of filters.focus_rules || []) {
const id = Number(r.focus_area_id)
if (!Number.isFinite(id) || id < 1) continue
if (r.mode === 'forbid') mustExc.push(id)
else mustInc.push(id)
}
const uniqNums = (arr) => [...new Set(arr)]
if (mustInc.length) q.focus_area_must_include_ids = uniqNums(mustInc)
if (mustExc.length) q.focus_area_must_exclude_ids = uniqNums(mustExc)
const fMn = splitMnCatalogRules(filters.focus_rules)
if (fMn.includeIds.length) q.focus_area_must_include_ids = fMn.includeIds
if (fMn.excludeIds.length) q.focus_area_must_exclude_ids = fMn.excludeIds
if (filters.focus_only_without) q.focus_only_without_focus_areas = true
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 sdMn = splitMnCatalogRules(filters.style_direction_rules)
if (sdMn.includeIds.length) q.style_direction_must_include_ids = sdMn.includeIds
if (sdMn.excludeIds.length) q.style_direction_must_exclude_ids = sdMn.excludeIds
const sdLegacy = ids(filters.style_direction_ids)
if (sdLegacy?.length) q.style_direction_ids = sdLegacy
const ttMn = splitMnCatalogRules(filters.training_type_rules)
if (ttMn.includeIds.length) q.training_type_must_include_ids = ttMn.includeIds
if (ttMn.excludeIds.length) q.training_type_must_exclude_ids = ttMn.excludeIds
const ttLegacy = ids(filters.training_type_ids)
if (ttLegacy?.length) q.training_type_ids = ttLegacy
const tgMn = splitMnCatalogRules(filters.target_group_rules)
if (tgMn.includeIds.length) q.target_group_must_include_ids = tgMn.includeIds
if (tgMn.excludeIds.length) q.target_group_must_exclude_ids = tgMn.excludeIds
const tgLegacy = ids(filters.target_group_ids)
if (tgLegacy?.length) q.target_group_ids = tgLegacy
const visMn = splitScalarCatalogRules(filters.visibility_rules)
if (visMn.includeVals.length) q.visibility_any = visMn.includeVals
if (visMn.excludeVals.length) q.visibility_exclude_any = visMn.excludeVals
const stMn = splitScalarCatalogRules(filters.status_rules)
if (stMn.includeVals.length) q.status_any = stMn.includeVals
if (stMn.excludeVals.length) q.status_exclude_any = stMn.excludeVals
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 (filters.visibility_exclude_any?.length)
q.visibility_exclude_any = [...filters.visibility_exclude_any]
if (filters.status_exclude_any?.length) q.status_exclude_any = [...filters.status_exclude_any]
if (filters.exclude_without_focus) q.exclude_without_focus = true
if (filters.include_archived) q.include_archived = true
if (debouncedSearch) q.search = debouncedSearch
@ -311,8 +322,8 @@ export default function ExercisePickerModal({
{filterOpen && (
<div style={{ marginTop: '0.35rem', fontSize: '13px', color: 'var(--text2)' }}>
<p style={{ margin: '0 0 12px 0' }}>
Zwischen den Bereichen gilt <strong>UND</strong>. Fokus: + mit / ohne kombinierbar (mehrere + =
alle gesetzt). Sonstige Mehrfachauswahl pro Feld mit <strong>ODER</strong>.
Felder gelten mit <strong>UND</strong>. Kataloge: mehrere + = alle zutreffend; schließt aus.
Sichtbarkeit/Status: mehrere + = eine davon (ODER); blendet aus.
</p>
<ExerciseFocusRulePicker
focusOptions={focusOptions}
@ -321,34 +332,34 @@ export default function ExercisePickerModal({
legacyFocusAreaIds={filters.focus_area_ids}
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
/>
<div className="exercise-filters-modal-grid" style={{ marginTop: 12 }}>
<div>
<label className="form-label">Stilrichtung</label>
<MultiSelectCombo
value={filters.style_direction_ids}
onChange={(v) => setFilters((f) => ({ ...f, style_direction_ids: v }))}
options={styleOptions}
placeholder="Stilrichtung …"
/>
</div>
<div>
<label className="form-label">Trainingsstil</label>
<MultiSelectCombo
value={filters.training_type_ids}
onChange={(v) => setFilters((f) => ({ ...f, training_type_ids: v }))}
options={trainingTypeOptions}
placeholder="Trainingsstil …"
/>
</div>
<div>
<label className="form-label">Zielgruppe</label>
<MultiSelectCombo
value={filters.target_group_ids}
onChange={(v) => setFilters((f) => ({ ...f, target_group_ids: v }))}
options={targetGroupOptions}
placeholder="Zielgruppe …"
/>
</div>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--catalog" style={{ marginTop: 12 }}>
<CatalogRulePicker
label="Stilrichtung"
hint="+ alle nötig (UND). verbietet Zuordnung."
options={styleOptions}
rules={filters.style_direction_rules}
rulesFieldName="style_direction_rules"
placeholder="Stil …"
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
/>
<CatalogRulePicker
label="Trainingsstil"
hint="+ alle nötig (UND). verbietet Zuordnung."
options={trainingTypeOptions}
rules={filters.training_type_rules}
rulesFieldName="training_type_rules"
placeholder="Trainingsstil …"
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
/>
<CatalogRulePicker
label="Zielgruppe"
hint="+ alle nötig (UND). verbietet Zuordnung."
options={targetGroupOptions}
rules={filters.target_group_rules}
rulesFieldName="target_group_rules"
placeholder="Gruppe …"
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
/>
</div>
<div style={{ marginTop: 12 }}>
<label className="form-label">Fähigkeit</label>
@ -387,46 +398,25 @@ export default function ExercisePickerModal({
</select>
</div>
</div>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two" style={{ marginTop: 12 }}>
<div>
<label className="form-label">Sichtbarkeit</label>
<MultiSelectCombo
value={filters.visibility_any}
onChange={(v) => setFilters((f) => ({ ...f, visibility_any: v }))}
options={visibilityOptions}
placeholder="Sichtbarkeit …"
/>
</div>
<div>
<label className="form-label">Status</label>
<MultiSelectCombo
value={filters.status_any}
onChange={(v) => setFilters((f) => ({ ...f, status_any: v }))}
options={statusOptions}
placeholder="Status …"
/>
</div>
</div>
<p style={{ margin: '12px 0 8px', fontWeight: 600, fontSize: '13px' }}>Ausblenden</p>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two">
<div>
<label className="form-label">Sichtbarkeit nicht</label>
<MultiSelectCombo
value={filters.visibility_exclude_any}
onChange={(v) => setFilters((f) => ({ ...f, visibility_exclude_any: v }))}
options={visibilityOptions}
placeholder="ausblenden …"
/>
</div>
<div>
<label className="form-label">Status nicht</label>
<MultiSelectCombo
value={filters.status_exclude_any}
onChange={(v) => setFilters((f) => ({ ...f, status_exclude_any: v }))}
options={statusOptions}
placeholder="ausblenden …"
/>
</div>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two exercise-filters-modal-grid--catalog" style={{ marginTop: 12 }}>
<CatalogRulePicker
label="Sichtbarkeit"
options={visibilityOptions}
rules={filters.visibility_rules}
rulesFieldName="visibility_rules"
idKind="string"
placeholder="Sichtbarkeit …"
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
/>
<CatalogRulePicker
label="Status"
options={statusOptions}
rules={filters.status_rules}
rulesFieldName="status_rules"
idKind="string"
placeholder="Status …"
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
/>
</div>
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
<label

View File

@ -1,47 +1,137 @@
/** Gemeinsame Default-Filter für Übungslisten (Übersicht + Auswahlmodal). */
export const INITIAL_EXERCISE_LIST_FILTERS = {
/** Legacy: ODER über mehrere Fokus-IDs (wird nur noch aus gespeicherten Presets oder Chips genutzt). */
focus_area_ids: [],
/** Regeln: mode require = muss Fokus haben, forbid = darf diesen Fokus nicht haben (UND über alle require). */
focus_rules: [],
/** Nur Übungen ohne einen Eintrag in exercise_focus_areas. */
focus_only_without: false,
style_direction_ids: [],
style_direction_rules: [],
training_type_ids: [],
training_type_rules: [],
target_group_ids: [],
target_group_rules: [],
skill_ids: [],
skill_min_level: '',
skill_max_level: '',
visibility_any: [],
status_any: [],
visibility_exclude_any: [],
visibility_rules: [],
status_any: [],
status_exclude_any: [],
status_rules: [],
exclude_without_focus: false,
include_archived: false,
}
export const CATALOG_RULE_FIELD_KEYS = [
'focus_rules',
'style_direction_rules',
'training_type_rules',
'target_group_rules',
'visibility_rules',
'status_rules',
]
const PREFS_KEYS = Object.keys(INITIAL_EXERCISE_LIST_FILTERS)
export function newCatalogRuleKey(prefix = 'r') {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return `${prefix}-${crypto.randomUUID()}`
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
/** Einheitliche Regel-Zeile: { key, id, mode }. Legacy: focus_area_id. */
export function normalizeCatalogRule(r, i, prefix = 'r') {
if (!r || typeof r !== 'object') return null
const id = String(r.id ?? r.focus_area_id ?? '').trim()
if (!id) return null
const mode = r.mode === 'forbid' ? 'forbid' : 'require'
return {
key: r.key || newCatalogRuleKey(prefix),
id,
mode,
}
}
export function splitMnCatalogRules(rules) {
const inc = []
const exc = []
for (const r of rules || []) {
const id = Number(r.id ?? r.focus_area_id)
if (!Number.isFinite(id) || id < 1) continue
if (r.mode === 'forbid') exc.push(id)
else inc.push(id)
}
return {
includeIds: [...new Set(inc)],
excludeIds: [...new Set(exc)],
}
}
/** Für visibility/status (einfaches Feld mit einem Wert pro Übung): + → OR (Liste), → Ausschlussliste. */
export function splitScalarCatalogRules(rules) {
const inc = []
const exc = []
for (const r of rules || []) {
let id = String(r.id ?? '').trim().toLowerCase()
if (!id) continue
if (r.mode === 'forbid') exc.push(id)
else inc.push(id)
}
return {
includeVals: [...new Set(inc)],
excludeVals: [...new Set(exc)],
}
}
/**
* Ruft aus dem Profilfeld exercise_list_prefs einen gültigen Filter-State ab.
*/
export function mergeExerciseListPrefsFromApi(raw) {
const out = { ...INITIAL_EXERCISE_LIST_FILTERS }
if (!raw || typeof raw !== 'object') return out
for (const key of CATALOG_RULE_FIELD_KEYS) {
if (!Array.isArray(raw[key])) continue
out[key] = raw[key].map((r, i) => normalizeCatalogRule(r, i, key)).filter(Boolean)
}
if (raw.focus_only_without !== undefined) out.focus_only_without = !!raw.focus_only_without
if (!out.visibility_rules.length) {
const vr = []
;(raw.visibility_any || []).forEach((id, i) => {
const n = normalizeCatalogRule({ id, mode: 'require', key: `lv-${i}` }, i, 'visibility_rules')
if (n) vr.push(n)
})
;(raw.visibility_exclude_any || []).forEach((id, i) => {
const n = normalizeCatalogRule({ id, mode: 'forbid', key: `lve-${i}` }, i, 'visibility_rules')
if (n) vr.push(n)
})
if (vr.length) out.visibility_rules = vr
}
if (!out.status_rules.length) {
const sr = []
;(raw.status_any || []).forEach((id, i) => {
const n = normalizeCatalogRule({ id, mode: 'require', key: `ls-${i}` }, i, 'status_rules')
if (n) sr.push(n)
})
;(raw.status_exclude_any || []).forEach((id, i) => {
const n = normalizeCatalogRule({ id, mode: 'forbid', key: `lse-${i}` }, i, 'status_rules')
if (n) sr.push(n)
})
if (sr.length) out.status_rules = sr
}
for (const k of PREFS_KEYS) {
if (CATALOG_RULE_FIELD_KEYS.includes(k)) continue
if (k === 'focus_only_without') continue
if (raw[k] === undefined) continue
if (k === 'focus_rules' && Array.isArray(raw[k])) {
out.focus_rules = raw[k]
.filter((r) => r && r.focus_area_id != null && (r.mode === 'require' || r.mode === 'forbid'))
.map((r, i) => ({
key: r.key || `imp-${i}-${r.focus_area_id}-${r.mode}`,
focus_area_id: String(r.focus_area_id),
mode: r.mode,
}))
continue
}
if (k === 'focus_only_without') {
out.focus_only_without = !!raw[k]
if (
k === 'visibility_any' ||
k === 'visibility_exclude_any' ||
k === 'status_any' ||
k === 'status_exclude_any'
) {
continue
}
if (k.endsWith('_ids') || k.endsWith('_any')) {

View File

@ -17,12 +17,15 @@ import { useAuth } from '../context/AuthContext'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import MultiSelectCombo from '../components/MultiSelectCombo'
import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker'
import CatalogRulePicker from '../components/CatalogRulePicker'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
import PageSectionNav from '../components/PageSectionNav'
import {
INITIAL_EXERCISE_LIST_FILTERS,
mergeExerciseListPrefsFromApi,
compactExerciseListPrefsPayload,
splitMnCatalogRules,
splitScalarCatalogRules,
} from '../constants/exerciseListFilters'
import { sanitizeExerciseRichText, coerceApiNameList } from '../utils/sanitizeHtml'
import { canUserRequestExerciseDelete } from '../utils/exercisePermissions'
@ -51,6 +54,22 @@ function statusLabel(s) {
return STATUS_LABELS[s] || s || '—'
}
function pushCatalogRuleFilterChips(chips, field, rules, options, topicLabel, setFilters) {
;(rules || []).forEach((r) => {
const rid = String(r.id ?? r.focus_area_id ?? '')
const opt = options.find((o) => String(o.id) === rid)
chips.push({
key: `${field}-${r.key}`,
label: `${topicLabel}: ${r.mode === 'forbid' ? '' : '+'} ${opt?.label ?? rid}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
[field]: (prev[field] || []).filter((x) => x.key !== r.key),
})),
})
})
}
function exerciseFocusNames(ex) {
const fromApi = coerceApiNameList(ex.focus_area_names)
if (fromApi.length) return fromApi
@ -222,19 +241,7 @@ function ExercisesListPage() {
const filterChips = useMemo(() => {
const chips = []
;(filters.focus_rules || []).forEach((r) => {
const opt = focusOptions.find((o) => String(o.id) === String(r.focus_area_id))
const verb = r.mode === 'forbid' ? 'Fokus ohne' : 'Fokus mit'
chips.push({
key: `fr-${r.key}`,
label: `${verb}: ${opt?.label ?? r.focus_area_id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
focus_rules: prev.focus_rules.filter((x) => x.key !== r.key),
})),
})
})
pushCatalogRuleFilterChips(chips, 'focus_rules', filters.focus_rules, focusOptions, 'Fokus', setFilters)
if (filters.focus_only_without) {
chips.push({
@ -256,11 +263,37 @@ function ExercisesListPage() {
})),
})
})
pushCatalogRuleFilterChips(
chips,
'style_direction_rules',
filters.style_direction_rules,
styleOptions,
'Stil',
setFilters
)
pushCatalogRuleFilterChips(
chips,
'training_type_rules',
filters.training_type_rules,
trainingTypeOptions,
'Trainingsstil',
setFilters
)
pushCatalogRuleFilterChips(
chips,
'target_group_rules',
filters.target_group_rules,
targetGroupOptions,
'Zielgruppe',
setFilters
)
;(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}`,
label: `Stil (ODER, älter): ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
@ -272,7 +305,7 @@ function ExercisesListPage() {
const opt = trainingTypeOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `tt-${id}`,
label: `Trainingsstil: ${opt?.label ?? id}`,
label: `Trainingsstil (ODER, älter): ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
@ -284,7 +317,7 @@ function ExercisesListPage() {
const opt = targetGroupOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `tg-${id}`,
label: `Zielgruppe: ${opt?.label ?? id}`,
label: `Zielgruppe (ODER, älter): ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
@ -292,6 +325,7 @@ function ExercisesListPage() {
})),
})
})
;(filters.skill_ids || []).forEach((id) => {
const opt = skillOptions.find((o) => String(o.id) === String(id))
chips.push({
@ -320,55 +354,15 @@ function ExercisesListPage() {
})
}
;(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)),
})),
})
})
;(filters.visibility_exclude_any || []).forEach((id) => {
const opt = visibilityOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `vex-${id}`,
label: `Sichtbarkeit ausblenden: ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
visibility_exclude_any: prev.visibility_exclude_any.filter((x) => String(x) !== String(id)),
})),
})
})
;(filters.status_exclude_any || []).forEach((id) => {
const opt = statusOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `sex-${id}`,
label: `Status ausblenden: ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
status_exclude_any: prev.status_exclude_any.filter((x) => String(x) !== String(id)),
})),
})
})
pushCatalogRuleFilterChips(
chips,
'visibility_rules',
filters.visibility_rules,
visibilityOptions,
'Sichtbarkeit',
setFilters
)
pushCatalogRuleFilterChips(chips, 'status_rules', filters.status_rules, statusOptions, 'Status', setFilters)
if (filters.exclude_without_focus) {
chips.push({
@ -395,6 +389,7 @@ function ExercisesListPage() {
skillOptions,
visibilityOptions,
statusOptions,
setFilters,
])
/** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */
@ -408,36 +403,44 @@ function ExercisesListPage() {
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 mustInc = []
const mustExc = []
for (const r of filters.focus_rules || []) {
const id = Number(r.focus_area_id)
if (!Number.isFinite(id) || id < 1) continue
if (r.mode === 'forbid') mustExc.push(id)
else mustInc.push(id)
}
const uniqNums = (arr) => [...new Set(arr)]
if (mustInc.length) q.focus_area_must_include_ids = uniqNums(mustInc)
if (mustExc.length) q.focus_area_must_exclude_ids = uniqNums(mustExc)
const fMn = splitMnCatalogRules(filters.focus_rules)
if (fMn.includeIds.length) q.focus_area_must_include_ids = fMn.includeIds
if (fMn.excludeIds.length) q.focus_area_must_exclude_ids = fMn.excludeIds
if (filters.focus_only_without) q.focus_only_without_focus_areas = true
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 sdMn = splitMnCatalogRules(filters.style_direction_rules)
if (sdMn.includeIds.length) q.style_direction_must_include_ids = sdMn.includeIds
if (sdMn.excludeIds.length) q.style_direction_must_exclude_ids = sdMn.excludeIds
const sdLegacy = ids(filters.style_direction_ids)
if (sdLegacy?.length) q.style_direction_ids = sdLegacy
const ttMn = splitMnCatalogRules(filters.training_type_rules)
if (ttMn.includeIds.length) q.training_type_must_include_ids = ttMn.includeIds
if (ttMn.excludeIds.length) q.training_type_must_exclude_ids = ttMn.excludeIds
const ttLegacy = ids(filters.training_type_ids)
if (ttLegacy?.length) q.training_type_ids = ttLegacy
const tgMn = splitMnCatalogRules(filters.target_group_rules)
if (tgMn.includeIds.length) q.target_group_must_include_ids = tgMn.includeIds
if (tgMn.excludeIds.length) q.target_group_must_exclude_ids = tgMn.excludeIds
const tgLegacy = ids(filters.target_group_ids)
if (tgLegacy?.length) q.target_group_ids = tgLegacy
const visMn = splitScalarCatalogRules(filters.visibility_rules)
if (visMn.includeVals.length) q.visibility_any = visMn.includeVals
if (visMn.excludeVals.length) q.visibility_exclude_any = visMn.excludeVals
const stMn = splitScalarCatalogRules(filters.status_rules)
if (stMn.includeVals.length) q.status_any = stMn.includeVals
if (stMn.excludeVals.length) q.status_exclude_any = stMn.excludeVals
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 (filters.visibility_exclude_any?.length)
q.visibility_exclude_any = [...filters.visibility_exclude_any]
if (filters.status_exclude_any?.length) q.status_exclude_any = [...filters.status_exclude_any]
if (filters.exclude_without_focus) q.exclude_without_focus = true
if (filters.include_archived) q.include_archived = true
if (debouncedSearch) q.search = debouncedSearch
@ -896,7 +899,8 @@ function ExercisesListPage() {
<p className="muted" style={{ marginTop: 0, marginBottom: '14px' }}>
Zwischen den Bereichen gilt <strong>UND</strong>. Fokusbereiche: mehrere + mit bedeuten alle müssen
gesetzt sein; ohne schließt Übungen aus, die diesen Fokus zusätzlich haben. Stilrichtung /
Trainingsstil / Zielgruppe: mehrere Werte pro Feld mit <strong>ODER</strong>.
Trainingsstil / Zielgruppe: mehrere + = alle zutreffend (UND); verbietet die Zuordnung. Unter
Freigabe: Sichtbarkeit / Status mit + = eine davon (ODER); blendet aus.
</p>
<section className="exercise-filter-section">
@ -908,34 +912,34 @@ function ExercisesListPage() {
legacyFocusAreaIds={filters.focus_area_ids}
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
<div className="exercise-filters-modal-grid" style={{ marginTop: '14px' }}>
<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 className="exercise-filters-modal-grid exercise-filters-modal-grid--catalog" style={{ marginTop: '12px' }}>
<CatalogRulePicker
label="Stilrichtung"
hint="+ alle nötig (UND). verbietet Zuordnung."
options={styleOptions}
rules={filters.style_direction_rules}
rulesFieldName="style_direction_rules"
placeholder="Stil …"
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
<CatalogRulePicker
label="Trainingsstil"
hint="+ alle nötig (UND). verbietet Zuordnung."
options={trainingTypeOptions}
rules={filters.training_type_rules}
rulesFieldName="training_type_rules"
placeholder="Trainingsstil …"
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
<CatalogRulePicker
label="Zielgruppe"
hint="+ alle nötig (UND). verbietet Zuordnung."
options={targetGroupOptions}
rules={filters.target_group_rules}
rulesFieldName="target_group_rules"
placeholder="Gruppe …"
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
</div>
</section>
@ -993,31 +997,11 @@ function ExercisesListPage() {
</section>
<section className="exercise-filter-section">
<h4 className="exercise-filter-section-title">Ausblenden</h4>
<h4 className="exercise-filter-section-title">Ausblenden / Liste</h4>
<p className="muted" style={{ marginTop: 0, marginBottom: '12px', fontSize: '13px' }}>
Negativlisten schließen Treffer aus (weitere Felder weiterhin mit UND verknüpft).
Sichtbarkeit und Status steuern Sie unter Freigabe mit + und . Hier nur globale Listen-Optionen.
</p>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two">
<div>
<label className="form-label">Sichtbarkeit nicht anzeigen</label>
<MultiSelectCombo
value={filters.visibility_exclude_any}
onChange={(v) => setFilters({ ...filters, visibility_exclude_any: v })}
options={visibilityOptions}
placeholder="z. B. Global ausblenden …"
/>
</div>
<div>
<label className="form-label">Status nicht anzeigen</label>
<MultiSelectCombo
value={filters.status_exclude_any}
onChange={(v) => setFilters({ ...filters, status_exclude_any: v })}
options={statusOptions}
placeholder="z. B. Entwurf ausblenden …"
/>
</div>
</div>
<div style={{ marginTop: '14px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ marginTop: '6px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
<label
style={{
display: 'flex',
@ -1054,25 +1038,28 @@ function ExercisesListPage() {
<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>
<p className="muted" style={{ marginTop: 0, marginBottom: '10px', fontSize: '12px' }}>
Pro Übung nur ein Wert: mehrere + bedeuten eine davon (ODER). blendet Werte aus.
</p>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two exercise-filters-modal-grid--catalog">
<CatalogRulePicker
label="Sichtbarkeit"
options={visibilityOptions}
rules={filters.visibility_rules}
rulesFieldName="visibility_rules"
idKind="string"
placeholder="Sichtbarkeit …"
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
<CatalogRulePicker
label="Status"
options={statusOptions}
rules={filters.status_rules}
rulesFieldName="status_rules"
idKind="string"
placeholder="Status …"
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
</div>
</section>
</div>