diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 78c3f9a..70b6642 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -432,15 +432,54 @@ def assign_exercise_relations(cur, conn, exercise_id: int, data: dict): # 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") def list_exercises( - focus_area: Optional[int] = Query(default=None), - visibility: Optional[str] = Query(default=None), - status: Optional[str] = Query(default=None), - skill_id: Optional[int] = Query(default=None), - style_direction_id: Optional[int] = Query(default=None), - training_type_id: Optional[int] = Query(default=None), - target_group_id: Optional[int] = Query(default=None), + focus_area_ids: list[int] = Query(default=[], description="ODER: mind. einer dieser Fokusbereiche"), + focus_area: Optional[int] = Query(default=None, description="Einzel-ID (Legacy), wird mit focus_area_ids kombiniert"), + visibility_any: list[str] = Query(default=[], description="ODER: eine dieser Sichtbarkeiten"), + visibility: Optional[str] = Query(default=None, description="Einzel (Legacy)"), + status_any: list[str] = Query(default=[], description="ODER: einer dieser Statuswerte"), + status: Optional[str] = Query(default=None, description="Einzel (Legacy)"), + 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_max_level: Optional[int] = Query(default=None, ge=1, le=5), 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)") params.append(profile_id) - if visibility: - where.append("e.visibility = %s") - params.append(visibility) + vis_list = _merge_str_any(visibility_any, visibility) + if vis_list: + ph = ",".join(["%s"] * len(vis_list)) + where.append(f"e.visibility IN ({ph})") + params.extend(vis_list) - if status: - where.append("e.status = %s") - params.append(status) + st_list = _merge_str_any(status_any, status) + if st_list: + ph = ",".join(["%s"] * len(st_list)) + where.append(f"e.status IN ({ph})") + params.extend(st_list) - # Focus Area Filter (M:N Join) - if focus_area: - where.append("EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id = %s)") - params.append(focus_area) + fa_ids = _merge_ids(focus_area_ids, focus_area) + if fa_ids: + ph = ",".join(["%s"] * len(fa_ids)) + 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) - if skill_id: - where.append("EXISTS (SELECT 1 FROM exercise_skills es WHERE es.exercise_id = e.id AND es.skill_id = %s)") - params.append(skill_id) + sk_ids = _merge_ids(skill_ids, skill_id) + if sk_ids: + ph = ",".join(["%s"] * len(sk_ids)) + 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( "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( "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( "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: lo = skill_min_level if skill_min_level is not None else 1 diff --git a/frontend/src/app.css b/frontend/src/app.css index 8b1ee08..5b59d53 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -2360,8 +2360,8 @@ a.analysis-split__nav-item { .exercise-filters-compact { display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 8px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; margin-bottom: 12px; } .exercise-filters-compact .form-label { @@ -2373,6 +2373,98 @@ a.analysis-split__nav-item { 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 { border: 1px solid var(--border); border-radius: 8px; diff --git a/frontend/src/components/MultiSelectCombo.jsx b/frontend/src/components/MultiSelectCombo.jsx new file mode 100644 index 0000000..310dda4 --- /dev/null +++ b/frontend/src/components/MultiSelectCombo.jsx @@ -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 ( +
+
+ {value.map((id, idx) => ( + + ))} +
+
+ { + setQuery(e.target.value) + setOpen(true) + setBrowseAll(false) + }} + onFocus={() => setOpen(true)} + onKeyDown={onKeyDown} + autoComplete="off" + aria-autocomplete="list" + aria-expanded={open} + /> + +
+ {open && ( + + )} +
+ ) +} diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx index e9c3834..a47052f 100644 --- a/frontend/src/pages/ExercisesListPage.jsx +++ b/frontend/src/pages/ExercisesListPage.jsx @@ -3,6 +3,7 @@ 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) @@ -26,15 +27,15 @@ function ExercisesListPage() { const [debouncedSearch, setDebouncedSearch] = useState('') const [debouncedAiSearch, setDebouncedAiSearch] = useState('') const [filters, setFilters] = useState({ - focus_area: '', - style_direction_id: '', - training_type_id: '', - target_group_id: '', - skill_id: '', + focus_area_ids: [], + style_direction_ids: [], + training_type_ids: [], + target_group_ids: [], + skill_ids: [], skill_min_level: '', skill_max_level: '', - visibility: '', - status: '', + visibility_any: [], + status_any: [], }) useEffect(() => { @@ -96,15 +97,22 @@ function ExercisesListPage() { const queryBase = useMemo(() => { const q = {} const n = (v) => (v === '' || v == null ? undefined : Number(v)) - if (filters.focus_area) q.focus_area = n(filters.focus_area) - if (filters.style_direction_id) q.style_direction_id = n(filters.style_direction_id) - if (filters.training_type_id) q.training_type_id = n(filters.training_type_id) - if (filters.target_group_id) q.target_group_id = n(filters.target_group_id) - if (filters.skill_id) q.skill_id = n(filters.skill_id) + const ids = (arr) => + Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined + 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 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) q.visibility = filters.visibility - if (filters.status) q.status = filters.status + if (filters.visibility_any?.length) q.visibility_any = [...filters.visibility_any] + if (filters.status_any?.length) q.status_any = [...filters.status_any] if (debouncedSearch) q.search = debouncedSearch if (debouncedAiSearch) q.ai_search = debouncedAiSearch return q @@ -224,6 +232,10 @@ function ExercisesListPage() {
+

+ Filter untereinander werden mit UND kombiniert. Mehrere Einträge in einem Feld mit{' '} + ODER (die Übung muss mindestens eine der gewählten Zuordnungen erfüllen). +

- - setFilters({ ...filters, focus_area: v })} + + setFilters({ ...filters, focus_area_ids: v })} options={focusOptions} - allLabel="Alle Fokusbereiche" - filterPlaceholder="Fokus filtern…" + placeholder="Fokus suchen oder „▼ Alle“ …" />
- - setFilters({ ...filters, style_direction_id: v })} + + setFilters({ ...filters, style_direction_ids: v })} options={styleOptions} - allLabel="Alle Stilrichtungen" - filterPlaceholder="Stilrichtung filtern…" + placeholder="Stilrichtung suchen …" />
- - setFilters({ ...filters, training_type_id: v })} + + setFilters({ ...filters, training_type_ids: v })} options={trainingTypeOptions} - allLabel="Alle Trainingsstile" - filterPlaceholder="Trainingsstil filtern…" + placeholder="Trainingsstil suchen …" />
- - setFilters({ ...filters, target_group_id: v })} + + setFilters({ ...filters, target_group_ids: v })} options={targetGroupOptions} - allLabel="Alle Zielgruppen" - filterPlaceholder="Zielgruppe filtern…" + placeholder="Zielgruppe suchen …" />
- - setFilters({ ...filters, skill_id: v })} + + setFilters({ ...filters, skill_ids: v })} options={skillOptions} - allLabel="Alle Fähigkeiten" - filterPlaceholder="Fähigkeit filtern…" + placeholder="Fähigkeit suchen …" />
@@ -321,23 +328,21 @@ function ExercisesListPage() { />
- - setFilters({ ...filters, visibility: v })} + + setFilters({ ...filters, visibility_any: v })} options={visibilityOptions} - allLabel="Alle" - filterPlaceholder="Filtern…" + placeholder="Sichtbarkeit wählen …" />
- - setFilters({ ...filters, status: v })} + + setFilters({ ...filters, status_any: v })} options={statusOptions} - allLabel="Alle" - filterPlaceholder="Filtern…" + placeholder="Status wählen …" />
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 8307923..fcc465d 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -213,9 +213,17 @@ export async function deleteMethod(id) { export async function listExercises(filters = {}) { const q = new URLSearchParams() Object.entries(filters).forEach(([k, v]) => { - if (v !== undefined && v !== null && String(v).trim() !== '') { - q.set(k, String(v)) + if (v === undefined || v === null) return + 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() return request(`/api/exercises${query ? '?' + query : ''}`)