feat: enhance exercise filtering and UI components
- Introduced multi-select functionality for filtering exercises by focus areas, style directions, training types, target groups, and skills, allowing users to select multiple options. - Updated the ExercisesListPage to utilize the new multi-select component, improving the user experience for filtering exercises. - Enhanced backend filtering logic to support new array-based query parameters, ensuring efficient handling of multiple filter criteria. - Adjusted CSS styles for better layout and usability of the exercise filters.
This commit is contained in:
parent
025b161d2f
commit
fd2009294b
|
|
@ -432,15 +432,54 @@ def assign_exercise_relations(cur, conn, exercise_id: int, data: dict):
|
||||||
# Endpoints
|
# Endpoints
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
def _merge_ids(multi: list[int], single: Optional[int]) -> list[int]:
|
||||||
|
"""Liste aus wiederholten Query-Parametern plus optional einem Legacy-Einzelfilter (ohne Duplikate)."""
|
||||||
|
seen: set[int] = set()
|
||||||
|
out: list[int] = []
|
||||||
|
for x in list(multi or []):
|
||||||
|
xi = int(x)
|
||||||
|
if xi not in seen:
|
||||||
|
seen.add(xi)
|
||||||
|
out.append(xi)
|
||||||
|
if single is not None:
|
||||||
|
xi = int(single)
|
||||||
|
if xi not in seen:
|
||||||
|
out.append(xi)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_str_any(multi: list[str], single: Optional[str]) -> list[str]:
|
||||||
|
seen = set()
|
||||||
|
out = []
|
||||||
|
for x in list(multi or []):
|
||||||
|
s = str(x).strip()
|
||||||
|
if not s or s in seen:
|
||||||
|
continue
|
||||||
|
seen.add(s)
|
||||||
|
out.append(s)
|
||||||
|
if single is not None and str(single).strip():
|
||||||
|
s = str(single).strip()
|
||||||
|
if s not in seen:
|
||||||
|
out.append(s)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.get("/exercises")
|
@router.get("/exercises")
|
||||||
def list_exercises(
|
def list_exercises(
|
||||||
focus_area: Optional[int] = Query(default=None),
|
focus_area_ids: list[int] = Query(default=[], description="ODER: mind. einer dieser Fokusbereiche"),
|
||||||
visibility: Optional[str] = Query(default=None),
|
focus_area: Optional[int] = Query(default=None, description="Einzel-ID (Legacy), wird mit focus_area_ids kombiniert"),
|
||||||
status: Optional[str] = Query(default=None),
|
visibility_any: list[str] = Query(default=[], description="ODER: eine dieser Sichtbarkeiten"),
|
||||||
skill_id: Optional[int] = Query(default=None),
|
visibility: Optional[str] = Query(default=None, description="Einzel (Legacy)"),
|
||||||
style_direction_id: Optional[int] = Query(default=None),
|
status_any: list[str] = Query(default=[], description="ODER: einer dieser Statuswerte"),
|
||||||
training_type_id: Optional[int] = Query(default=None),
|
status: Optional[str] = Query(default=None, description="Einzel (Legacy)"),
|
||||||
target_group_id: Optional[int] = Query(default=None),
|
skill_ids: list[int] = Query(default=[], description="ODER: mind. eine dieser Fähigkeiten"),
|
||||||
|
skill_id: Optional[int] = Query(default=None, description="Einzel (Legacy)"),
|
||||||
|
style_direction_ids: list[int] = Query(default=[], description="ODER: mind. eine Stilrichtung"),
|
||||||
|
style_direction_id: Optional[int] = Query(default=None, description="Einzel (Legacy)"),
|
||||||
|
training_type_ids: list[int] = Query(default=[], description="ODER: mind. ein Trainingsstil"),
|
||||||
|
training_type_id: Optional[int] = Query(default=None, description="Einzel (Legacy)"),
|
||||||
|
target_group_ids: list[int] = Query(default=[], description="ODER: mind. eine Zielgruppe"),
|
||||||
|
target_group_id: Optional[int] = Query(default=None, description="Einzel (Legacy)"),
|
||||||
skill_min_level: Optional[int] = Query(default=None, ge=1, le=5),
|
skill_min_level: Optional[int] = Query(default=None, ge=1, le=5),
|
||||||
skill_max_level: Optional[int] = Query(default=None, ge=1, le=5),
|
skill_max_level: Optional[int] = Query(default=None, ge=1, le=5),
|
||||||
search: Optional[str] = Query(default=None),
|
search: Optional[str] = Query(default=None),
|
||||||
|
|
@ -469,44 +508,60 @@ def list_exercises(
|
||||||
where.append("(e.visibility = 'official' OR e.visibility = 'club' OR e.created_by = %s)")
|
where.append("(e.visibility = 'official' OR e.visibility = 'club' OR e.created_by = %s)")
|
||||||
params.append(profile_id)
|
params.append(profile_id)
|
||||||
|
|
||||||
if visibility:
|
vis_list = _merge_str_any(visibility_any, visibility)
|
||||||
where.append("e.visibility = %s")
|
if vis_list:
|
||||||
params.append(visibility)
|
ph = ",".join(["%s"] * len(vis_list))
|
||||||
|
where.append(f"e.visibility IN ({ph})")
|
||||||
|
params.extend(vis_list)
|
||||||
|
|
||||||
if status:
|
st_list = _merge_str_any(status_any, status)
|
||||||
where.append("e.status = %s")
|
if st_list:
|
||||||
params.append(status)
|
ph = ",".join(["%s"] * len(st_list))
|
||||||
|
where.append(f"e.status IN ({ph})")
|
||||||
|
params.extend(st_list)
|
||||||
|
|
||||||
# Focus Area Filter (M:N Join)
|
fa_ids = _merge_ids(focus_area_ids, focus_area)
|
||||||
if focus_area:
|
if fa_ids:
|
||||||
where.append("EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id = %s)")
|
ph = ",".join(["%s"] * len(fa_ids))
|
||||||
params.append(focus_area)
|
where.append(
|
||||||
|
f"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))"
|
||||||
|
)
|
||||||
|
params.extend(fa_ids)
|
||||||
|
|
||||||
# Skill Filter (M:N Join)
|
sk_ids = _merge_ids(skill_ids, skill_id)
|
||||||
if skill_id:
|
if sk_ids:
|
||||||
where.append("EXISTS (SELECT 1 FROM exercise_skills es WHERE es.exercise_id = e.id AND es.skill_id = %s)")
|
ph = ",".join(["%s"] * len(sk_ids))
|
||||||
params.append(skill_id)
|
where.append(
|
||||||
|
f"EXISTS (SELECT 1 FROM exercise_skills es WHERE es.exercise_id = e.id AND es.skill_id IN ({ph}))"
|
||||||
|
)
|
||||||
|
params.extend(sk_ids)
|
||||||
|
|
||||||
if style_direction_id:
|
sd_ids = _merge_ids(style_direction_ids, style_direction_id)
|
||||||
|
if sd_ids:
|
||||||
|
ph = ",".join(["%s"] * len(sd_ids))
|
||||||
where.append(
|
where.append(
|
||||||
"EXISTS (SELECT 1 FROM exercise_style_directions esd "
|
"EXISTS (SELECT 1 FROM exercise_style_directions esd "
|
||||||
"WHERE esd.exercise_id = e.id AND esd.style_direction_id = %s)"
|
f"WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))"
|
||||||
)
|
)
|
||||||
params.append(style_direction_id)
|
params.extend(sd_ids)
|
||||||
|
|
||||||
if training_type_id:
|
tt_ids = _merge_ids(training_type_ids, training_type_id)
|
||||||
|
if tt_ids:
|
||||||
|
ph = ",".join(["%s"] * len(tt_ids))
|
||||||
where.append(
|
where.append(
|
||||||
"EXISTS (SELECT 1 FROM exercise_training_types ett "
|
"EXISTS (SELECT 1 FROM exercise_training_types ett "
|
||||||
"WHERE ett.exercise_id = e.id AND ett.training_type_id = %s)"
|
f"WHERE ett.exercise_id = e.id AND ett.training_type_id IN ({ph}))"
|
||||||
)
|
)
|
||||||
params.append(training_type_id)
|
params.extend(tt_ids)
|
||||||
|
|
||||||
if target_group_id:
|
tg_ids = _merge_ids(target_group_ids, target_group_id)
|
||||||
|
if tg_ids:
|
||||||
|
ph = ",".join(["%s"] * len(tg_ids))
|
||||||
where.append(
|
where.append(
|
||||||
"EXISTS (SELECT 1 FROM exercise_target_groups etg "
|
"EXISTS (SELECT 1 FROM exercise_target_groups etg "
|
||||||
"WHERE etg.exercise_id = e.id AND etg.target_group_id = %s)"
|
f"WHERE etg.exercise_id = e.id AND etg.target_group_id IN ({ph}))"
|
||||||
)
|
)
|
||||||
params.append(target_group_id)
|
params.extend(tg_ids)
|
||||||
|
|
||||||
if skill_min_level is not None or skill_max_level is not None:
|
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
|
lo = skill_min_level if skill_min_level is not None else 1
|
||||||
|
|
|
||||||
|
|
@ -2360,8 +2360,8 @@ a.analysis-split__nav-item {
|
||||||
|
|
||||||
.exercise-filters-compact {
|
.exercise-filters-compact {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
.exercise-filters-compact .form-label {
|
.exercise-filters-compact .form-label {
|
||||||
|
|
@ -2373,6 +2373,98 @@ a.analysis-split__nav-item {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.multiselect-combo {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.multiselect-combo__chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.multiselect-combo__chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--accent-light);
|
||||||
|
color: var(--accent-dark);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.multiselect-combo__chip span:first-child {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.multiselect-combo__chip-x {
|
||||||
|
opacity: 0.8;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.multiselect-combo__field {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.multiselect-combo__input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.multiselect-combo__browse {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.multiselect-combo__list {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 100%;
|
||||||
|
z-index: 30;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
padding: 4px 0;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
list-style: none;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
.multiselect-combo__empty {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
.multiselect-combo__opt {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text1);
|
||||||
|
}
|
||||||
|
.multiselect-combo__opt:hover,
|
||||||
|
.multiselect-combo__opt--hi {
|
||||||
|
background: var(--surface2);
|
||||||
|
}
|
||||||
|
.multiselect-combo__opt:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
.multi-assoc-block {
|
.multi-assoc-block {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|
|
||||||
183
frontend/src/components/MultiSelectCombo.jsx
Normal file
183
frontend/src/components/MultiSelectCombo.jsx
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
import React, { useMemo, useState, useRef, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
function normId(id) {
|
||||||
|
return String(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mehrfachauswahl mit Typahead-Vorschlägen und optionaler Vollliste (Dropdown).
|
||||||
|
* Auswahl mehrerer Einträge wird als ODER interpretiert (Aufrufer/API).
|
||||||
|
*/
|
||||||
|
export default function MultiSelectCombo({
|
||||||
|
value = [],
|
||||||
|
onChange,
|
||||||
|
options = [],
|
||||||
|
placeholder = 'Tippen und Eintrag wählen…',
|
||||||
|
browseLabel = '▼ Alle',
|
||||||
|
emptyHint = 'Keine Treffer',
|
||||||
|
idKey = 'id',
|
||||||
|
labelKey = 'label',
|
||||||
|
className = '',
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [browseAll, setBrowseAll] = useState(false)
|
||||||
|
const [highlight, setHighlight] = useState(0)
|
||||||
|
const rootRef = useRef(null)
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
return options.map((o) => ({
|
||||||
|
id: o[idKey],
|
||||||
|
label: typeof o[labelKey] === 'function' ? o[labelKey](o) : String(o[labelKey] ?? ''),
|
||||||
|
}))
|
||||||
|
}, [options, idKey, labelKey])
|
||||||
|
|
||||||
|
const selectedSet = useMemo(() => new Set(value.map(normId)), [value])
|
||||||
|
|
||||||
|
const selectedLabels = useMemo(() => {
|
||||||
|
return value.map((id) => {
|
||||||
|
const r = rows.find((x) => normId(x.id) === normId(id))
|
||||||
|
return r ? r.label : `#${id}`
|
||||||
|
})
|
||||||
|
}, [value, rows])
|
||||||
|
|
||||||
|
const suggestions = useMemo(() => {
|
||||||
|
const avail = rows.filter((r) => !selectedSet.has(normId(r.id)))
|
||||||
|
if (browseAll || !query.trim()) return avail
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
return avail.filter((r) => r.label.toLowerCase().includes(q) || normId(r.id).includes(q))
|
||||||
|
}, [rows, selectedSet, query, browseAll])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHighlight(0)
|
||||||
|
}, [suggestions.length, query, browseAll])
|
||||||
|
|
||||||
|
const addId = useCallback(
|
||||||
|
(id) => {
|
||||||
|
const sid = normId(id)
|
||||||
|
if (selectedSet.has(sid)) return
|
||||||
|
onChange([...value, id])
|
||||||
|
setQuery('')
|
||||||
|
setBrowseAll(false)
|
||||||
|
},
|
||||||
|
[value, onChange, selectedSet]
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeAt = useCallback(
|
||||||
|
(idx) => {
|
||||||
|
const next = value.filter((_, i) => i !== idx)
|
||||||
|
onChange(next)
|
||||||
|
},
|
||||||
|
[value, onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onDoc = (e) => {
|
||||||
|
if (!rootRef.current?.contains(e.target)) {
|
||||||
|
setOpen(false)
|
||||||
|
setBrowseAll(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDoc)
|
||||||
|
return () => document.removeEventListener('mousedown', onDoc)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onKeyDown = (e) => {
|
||||||
|
if (!open && (e.key === 'ArrowDown' || e.key === 'Enter')) {
|
||||||
|
setOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!open) return
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setOpen(false)
|
||||||
|
setBrowseAll(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
setHighlight((h) => Math.min(h + 1, Math.max(0, suggestions.length - 1)))
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
setHighlight((h) => Math.max(0, h - 1))
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter' && suggestions[highlight]) {
|
||||||
|
e.preventDefault()
|
||||||
|
addId(suggestions[highlight].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`multiselect-combo ${className}`} ref={rootRef}>
|
||||||
|
<div className="multiselect-combo__chips">
|
||||||
|
{value.map((id, idx) => (
|
||||||
|
<button
|
||||||
|
key={`${normId(id)}-${idx}`}
|
||||||
|
type="button"
|
||||||
|
className="multiselect-combo__chip"
|
||||||
|
onClick={() => removeAt(idx)}
|
||||||
|
title="Entfernen"
|
||||||
|
>
|
||||||
|
<span>{selectedLabels[idx]}</span>
|
||||||
|
<span className="multiselect-combo__chip-x" aria-hidden>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="multiselect-combo__field">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input multiselect-combo__input"
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value)
|
||||||
|
setOpen(true)
|
||||||
|
setBrowseAll(false)
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
autoComplete="off"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-expanded={open}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn multiselect-combo__browse"
|
||||||
|
title="Alle Einträge anzeigen"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(true)
|
||||||
|
setBrowseAll(true)
|
||||||
|
setQuery('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{browseLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<ul className="multiselect-combo__list" role="listbox">
|
||||||
|
{suggestions.length === 0 ? (
|
||||||
|
<li className="multiselect-combo__empty">{emptyHint}</li>
|
||||||
|
) : (
|
||||||
|
suggestions.map((r, i) => (
|
||||||
|
<li key={normId(r.id)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === highlight}
|
||||||
|
className={`multiselect-combo__opt${i === highlight ? ' multiselect-combo__opt--hi' : ''}`}
|
||||||
|
onMouseEnter={() => setHighlight(i)}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => addId(r.id)}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ 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 SearchableSelect from '../components/SearchableSelect'
|
||||||
|
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)
|
||||||
|
|
@ -26,15 +27,15 @@ function ExercisesListPage() {
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||||
const [debouncedAiSearch, setDebouncedAiSearch] = useState('')
|
const [debouncedAiSearch, setDebouncedAiSearch] = useState('')
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
focus_area: '',
|
focus_area_ids: [],
|
||||||
style_direction_id: '',
|
style_direction_ids: [],
|
||||||
training_type_id: '',
|
training_type_ids: [],
|
||||||
target_group_id: '',
|
target_group_ids: [],
|
||||||
skill_id: '',
|
skill_ids: [],
|
||||||
skill_min_level: '',
|
skill_min_level: '',
|
||||||
skill_max_level: '',
|
skill_max_level: '',
|
||||||
visibility: '',
|
visibility_any: [],
|
||||||
status: '',
|
status_any: [],
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -96,15 +97,22 @@ function ExercisesListPage() {
|
||||||
const queryBase = useMemo(() => {
|
const queryBase = useMemo(() => {
|
||||||
const q = {}
|
const q = {}
|
||||||
const n = (v) => (v === '' || v == null ? undefined : Number(v))
|
const n = (v) => (v === '' || v == null ? undefined : Number(v))
|
||||||
if (filters.focus_area) q.focus_area = n(filters.focus_area)
|
const ids = (arr) =>
|
||||||
if (filters.style_direction_id) q.style_direction_id = n(filters.style_direction_id)
|
Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined
|
||||||
if (filters.training_type_id) q.training_type_id = n(filters.training_type_id)
|
const fa = ids(filters.focus_area_ids)
|
||||||
if (filters.target_group_id) q.target_group_id = n(filters.target_group_id)
|
if (fa?.length) q.focus_area_ids = fa
|
||||||
if (filters.skill_id) q.skill_id = n(filters.skill_id)
|
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 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_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.skill_max_level) q.skill_max_level = n(filters.skill_max_level)
|
||||||
if (filters.visibility) q.visibility = filters.visibility
|
if (filters.visibility_any?.length) q.visibility_any = [...filters.visibility_any]
|
||||||
if (filters.status) q.status = filters.status
|
if (filters.status_any?.length) q.status_any = [...filters.status_any]
|
||||||
if (debouncedSearch) q.search = debouncedSearch
|
if (debouncedSearch) q.search = debouncedSearch
|
||||||
if (debouncedAiSearch) q.ai_search = debouncedAiSearch
|
if (debouncedAiSearch) q.ai_search = debouncedAiSearch
|
||||||
return q
|
return q
|
||||||
|
|
@ -224,6 +232,10 @@ function ExercisesListPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card exercise-filters-compact" style={{ marginBottom: '12px' }}>
|
<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).
|
||||||
|
</p>
|
||||||
<div style={{ gridColumn: '1 / -1' }}>
|
<div style={{ gridColumn: '1 / -1' }}>
|
||||||
<label className="form-label">Volltextsuche (Titel, Ziel, …)</label>
|
<label className="form-label">Volltextsuche (Titel, Ziel, …)</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -251,53 +263,48 @@ function ExercisesListPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">Fokus</label>
|
<label className="form-label">Fokus (mehrere möglich)</label>
|
||||||
<SearchableSelect
|
<MultiSelectCombo
|
||||||
value={filters.focus_area}
|
value={filters.focus_area_ids}
|
||||||
onChange={(v) => setFilters({ ...filters, focus_area: v })}
|
onChange={(v) => setFilters({ ...filters, focus_area_ids: v })}
|
||||||
options={focusOptions}
|
options={focusOptions}
|
||||||
allLabel="Alle Fokusbereiche"
|
placeholder="Fokus suchen oder „▼ Alle“ …"
|
||||||
filterPlaceholder="Fokus filtern…"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">Stilrichtung</label>
|
<label className="form-label">Stilrichtung (mehrere möglich)</label>
|
||||||
<SearchableSelect
|
<MultiSelectCombo
|
||||||
value={filters.style_direction_id}
|
value={filters.style_direction_ids}
|
||||||
onChange={(v) => setFilters({ ...filters, style_direction_id: v })}
|
onChange={(v) => setFilters({ ...filters, style_direction_ids: v })}
|
||||||
options={styleOptions}
|
options={styleOptions}
|
||||||
allLabel="Alle Stilrichtungen"
|
placeholder="Stilrichtung suchen …"
|
||||||
filterPlaceholder="Stilrichtung filtern…"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">Trainingsstil</label>
|
<label className="form-label">Trainingsstil (mehrere möglich)</label>
|
||||||
<SearchableSelect
|
<MultiSelectCombo
|
||||||
value={filters.training_type_id}
|
value={filters.training_type_ids}
|
||||||
onChange={(v) => setFilters({ ...filters, training_type_id: v })}
|
onChange={(v) => setFilters({ ...filters, training_type_ids: v })}
|
||||||
options={trainingTypeOptions}
|
options={trainingTypeOptions}
|
||||||
allLabel="Alle Trainingsstile"
|
placeholder="Trainingsstil suchen …"
|
||||||
filterPlaceholder="Trainingsstil filtern…"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">Zielgruppe</label>
|
<label className="form-label">Zielgruppe (mehrere möglich)</label>
|
||||||
<SearchableSelect
|
<MultiSelectCombo
|
||||||
value={filters.target_group_id}
|
value={filters.target_group_ids}
|
||||||
onChange={(v) => setFilters({ ...filters, target_group_id: v })}
|
onChange={(v) => setFilters({ ...filters, target_group_ids: v })}
|
||||||
options={targetGroupOptions}
|
options={targetGroupOptions}
|
||||||
allLabel="Alle Zielgruppen"
|
placeholder="Zielgruppe suchen …"
|
||||||
filterPlaceholder="Zielgruppe filtern…"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">Fähigkeit</label>
|
<label className="form-label">Fähigkeit (mehrere möglich)</label>
|
||||||
<SearchableSelect
|
<MultiSelectCombo
|
||||||
value={filters.skill_id}
|
value={filters.skill_ids}
|
||||||
onChange={(v) => setFilters({ ...filters, skill_id: v })}
|
onChange={(v) => setFilters({ ...filters, skill_ids: v })}
|
||||||
options={skillOptions}
|
options={skillOptions}
|
||||||
allLabel="Alle Fähigkeiten"
|
placeholder="Fähigkeit suchen …"
|
||||||
filterPlaceholder="Fähigkeit filtern…"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -321,23 +328,21 @@ function ExercisesListPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">Sichtbarkeit</label>
|
<label className="form-label">Sichtbarkeit (mehrere möglich)</label>
|
||||||
<SearchableSelect
|
<MultiSelectCombo
|
||||||
value={filters.visibility}
|
value={filters.visibility_any}
|
||||||
onChange={(v) => setFilters({ ...filters, visibility: v })}
|
onChange={(v) => setFilters({ ...filters, visibility_any: v })}
|
||||||
options={visibilityOptions}
|
options={visibilityOptions}
|
||||||
allLabel="Alle"
|
placeholder="Sichtbarkeit wählen …"
|
||||||
filterPlaceholder="Filtern…"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">Status</label>
|
<label className="form-label">Status (mehrere möglich)</label>
|
||||||
<SearchableSelect
|
<MultiSelectCombo
|
||||||
value={filters.status}
|
value={filters.status_any}
|
||||||
onChange={(v) => setFilters({ ...filters, status: v })}
|
onChange={(v) => setFilters({ ...filters, status_any: v })}
|
||||||
options={statusOptions}
|
options={statusOptions}
|
||||||
allLabel="Alle"
|
placeholder="Status wählen …"
|
||||||
filterPlaceholder="Filtern…"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -213,10 +213,18 @@ export async function deleteMethod(id) {
|
||||||
export async function listExercises(filters = {}) {
|
export async function listExercises(filters = {}) {
|
||||||
const q = new URLSearchParams()
|
const q = new URLSearchParams()
|
||||||
Object.entries(filters).forEach(([k, v]) => {
|
Object.entries(filters).forEach(([k, v]) => {
|
||||||
if (v !== undefined && v !== null && String(v).trim() !== '') {
|
if (v === undefined || v === null) return
|
||||||
q.set(k, String(v))
|
if (Array.isArray(v)) {
|
||||||
|
if (v.length === 0) return
|
||||||
|
v.forEach((item) => {
|
||||||
|
if (item !== '' && item !== undefined && item !== null && String(item).trim() !== '') {
|
||||||
|
q.append(k, String(item))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (String(v).trim() !== '') q.set(k, String(v))
|
||||||
|
})
|
||||||
const query = q.toString()
|
const query = q.toString()
|
||||||
return request(`/api/exercises${query ? '?' + query : ''}`)
|
return request(`/api/exercises${query ? '?' + query : ''}`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user