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 ( +
+ 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). +