From 2c831d6cea8ee004521674a0848fd1412e97deb5 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 28 Apr 2026 08:58:17 +0200 Subject: [PATCH] refactor: enhance filtering logic and UI for ExercisesListPage - Removed the countActiveFilterGroups function and replaced it with a more comprehensive filterChips implementation to manage active filters. - Improved the rendering of active filters with dynamic chips that allow users to remove individual filters. - Updated the UI to reflect the new filtering logic, enhancing user experience and interaction with the filter options. - Adjusted the layout and styling for better visibility and usability of the filter components. --- frontend/src/app.css | 100 ++++++- frontend/src/pages/ExercisesListPage.jsx | 357 ++++++++++++++++------- 2 files changed, 343 insertions(+), 114 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index 7760c9f..4e8a194 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1586,16 +1586,98 @@ a.analysis-split__nav-item { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; } -.exercise-filter-level-row { - grid-column: 1 / -1; - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; +.exercise-filters-modal-grid--two { + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } -@media (max-width: 480px) { - .exercise-filter-level-row { - grid-template-columns: 1fr; - } +.exercise-filter-chips-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-top: 10px; +} +.exercise-filter-chip { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + padding: 5px 10px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface2); + font-size: 12px; + cursor: pointer; + font-family: inherit; + color: var(--text1); + text-align: left; +} +.exercise-filter-chip__text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: min(260px, 88vw); +} +.exercise-filter-chip__x { + flex-shrink: 0; + opacity: 0.65; + font-weight: 700; + line-height: 1; +} +.exercise-filter-section { + margin-bottom: 20px; +} +.exercise-filter-section--last { + margin-bottom: 0; +} +.exercise-filter-section-title { + margin: 0 0 10px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text3); + font-weight: 700; +} +.exercise-filter-skill-block { + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px; + background: var(--surface2); +} +.exercise-filter-skill-block > .form-label { + margin-bottom: 6px; +} +.exercise-filter-skill-hint { + margin: 10px 0 8px; + font-size: 12px; + color: var(--text3); + line-height: 1.35; +} +.exercise-filter-skill-levels-row { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 10px; +} +.exercise-filter-skill-level-field { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} +.exercise-filter-skill-level-caption { + font-size: 11px; + color: var(--text3); + font-weight: 600; +} +.exercise-filter-level-select { + width: 72px; + padding: 6px 8px; + font-size: 14px; +} +.exercise-filter-skill-dash { + padding-bottom: 8px; + color: var(--text3); + font-weight: 600; } /* Reifegradmodell-Admin: klare Schritte, responsives Raster */ diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx index 72aab3b..49eb660 100644 --- a/frontend/src/pages/ExercisesListPage.jsx +++ b/frontend/src/pages/ExercisesListPage.jsx @@ -19,17 +19,9 @@ const INITIAL_FILTERS = { status_any: [], } -function countActiveFilterGroups(f) { - let n = 0 - if (f.focus_area_ids?.length) n++ - if (f.style_direction_ids?.length) n++ - if (f.training_type_ids?.length) n++ - if (f.target_group_ids?.length) n++ - if (f.skill_ids?.length) n++ - if (f.skill_min_level || f.skill_max_level) n++ - if (f.visibility_any?.length) n++ - if (f.status_any?.length) n++ - return n +function levelOptionShort(levelStr) { + const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr)) + return o ? String(o.level) : String(levelStr) } function ExercisesListPage() { @@ -53,8 +45,6 @@ function ExercisesListPage() { const [filters, setFilters] = useState(() => ({ ...INITIAL_FILTERS })) const [filterModalOpen, setFilterModalOpen] = useState(false) - const activeFilterGroups = useMemo(() => countActiveFilterGroups(filters), [filters]) - useEffect(() => { const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400) return () => clearTimeout(t) @@ -116,6 +106,122 @@ function ExercisesListPage() { [] ) + const filterChips = useMemo(() => { + const chips = [] + + ;(filters.focus_area_ids || []).forEach((id) => { + const opt = focusOptions.find((o) => String(o.id) === String(id)) + chips.push({ + key: `fa-${id}`, + label: `Fokus: ${opt?.label ?? id}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + focus_area_ids: prev.focus_area_ids.filter((x) => String(x) !== String(id)), + })), + }) + }) + ;(filters.style_direction_ids || []).forEach((id) => { + const opt = styleOptions.find((o) => String(o.id) === String(id)) + chips.push({ + key: `sd-${id}`, + label: `Stil: ${opt?.label ?? id}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + style_direction_ids: prev.style_direction_ids.filter((x) => String(x) !== String(id)), + })), + }) + }) + ;(filters.training_type_ids || []).forEach((id) => { + const opt = trainingTypeOptions.find((o) => String(o.id) === String(id)) + chips.push({ + key: `tt-${id}`, + label: `Trainingsstil: ${opt?.label ?? id}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + training_type_ids: prev.training_type_ids.filter((x) => String(x) !== String(id)), + })), + }) + }) + ;(filters.target_group_ids || []).forEach((id) => { + const opt = targetGroupOptions.find((o) => String(o.id) === String(id)) + chips.push({ + key: `tg-${id}`, + label: `Zielgruppe: ${opt?.label ?? id}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + target_group_ids: prev.target_group_ids.filter((x) => String(x) !== String(id)), + })), + }) + }) + ;(filters.skill_ids || []).forEach((id) => { + const opt = skillOptions.find((o) => String(o.id) === String(id)) + chips.push({ + key: `sk-${id}`, + label: `Fähigkeit: ${opt?.label ?? id}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + skill_ids: prev.skill_ids.filter((x) => String(x) !== String(id)), + })), + }) + }) + + if (filters.skill_min_level || filters.skill_max_level) { + const a = filters.skill_min_level ? levelOptionShort(filters.skill_min_level) : '…' + const b = filters.skill_max_level ? levelOptionShort(filters.skill_max_level) : '…' + chips.push({ + key: 'skill-levels', + label: `Stufe ${a}–${b}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + skill_min_level: '', + skill_max_level: '', + })), + }) + } + + ;(filters.visibility_any || []).forEach((id) => { + const opt = visibilityOptions.find((o) => String(o.id) === String(id)) + chips.push({ + key: `vis-${id}`, + label: `Sichtbarkeit: ${opt?.label ?? id}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + visibility_any: prev.visibility_any.filter((x) => String(x) !== String(id)), + })), + }) + }) + ;(filters.status_any || []).forEach((id) => { + const opt = statusOptions.find((o) => String(o.id) === String(id)) + chips.push({ + key: `st-${id}`, + label: `Status: ${opt?.label ?? id}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + status_any: prev.status_any.filter((x) => String(x) !== String(id)), + })), + }) + }) + + return chips + }, [ + filters, + focusOptions, + styleOptions, + trainingTypeOptions, + targetGroupOptions, + skillOptions, + visibilityOptions, + statusOptions, + ]) + const queryBase = useMemo(() => { const q = {} const n = (v) => (v === '' || v == null ? undefined : Number(v)) @@ -278,18 +384,37 @@ function ExercisesListPage() {
- {activeFilterGroups > 0 ? ( + {filterChips.length > 0 ? ( ) : null}
+ {filterChips.length > 0 ? ( +
+ {filterChips.map((c) => ( + + ))} +
+ ) : null}

Vereins-/Trainerfilter folgen später. Fachliche Filter über „Filter“ – zwischen Feldern UND, Auswahl mehrerer Werte je Feld mit ODER.

@@ -324,47 +449,55 @@ function ExercisesListPage() {

- Zwischen den Bereichen gilt UND. Innerhalb eines Bereichs werden mehrere Einträge mit{' '} - ODER verknüpft (die Übung muss mindestens eine gewählte Zuordnung erfüllen). + Zwischen den Bereichen gilt UND. Innerhalb eines Feldes werden mehrere Einträge mit{' '} + ODER verknüpft.

-
-
- - setFilters({ ...filters, focus_area_ids: v })} - options={focusOptions} - placeholder="Fokus suchen oder „▼ Alle“ …" - /> + +
+

Zuordnung

+
+
+ + setFilters({ ...filters, focus_area_ids: v })} + options={focusOptions} + placeholder="Fokus suchen oder „▼ Alle“ …" + /> +
+
+ + setFilters({ ...filters, style_direction_ids: v })} + options={styleOptions} + placeholder="Stilrichtung suchen …" + /> +
+
+ + setFilters({ ...filters, training_type_ids: v })} + options={trainingTypeOptions} + placeholder="Trainingsstil suchen …" + /> +
+
+ + setFilters({ ...filters, target_group_ids: v })} + options={targetGroupOptions} + placeholder="Zielgruppe suchen …" + /> +
-
- - setFilters({ ...filters, style_direction_ids: v })} - options={styleOptions} - placeholder="Stilrichtung suchen …" - /> -
-
- - setFilters({ ...filters, training_type_ids: v })} - options={trainingTypeOptions} - placeholder="Trainingsstil suchen …" - /> -
-
- - setFilters({ ...filters, target_group_ids: v })} - options={targetGroupOptions} - placeholder="Zielgruppe suchen …" - /> -
-
+
+ +
+

Fähigkeit und zugehörige Stufe

+
-
-
-
- - -
-
- - +

+ Die Stufen filtern nach dem Niveau der Zuordnung Übung ↔ Fähigkeit (von–bis). +

+
+ + + – + +
-
- - setFilters({ ...filters, visibility_any: v })} - options={visibilityOptions} - placeholder="Sichtbarkeit wählen …" - /> +
+ +
+

Freigabe

+
+
+ + setFilters({ ...filters, visibility_any: v })} + options={visibilityOptions} + placeholder="Sichtbarkeit wählen …" + /> +
+
+ + setFilters({ ...filters, status_any: v })} + options={statusOptions} + placeholder="Status wählen …" + /> +
-
- - setFilters({ ...filters, status_any: v })} - options={statusOptions} - placeholder="Status wählen …" - /> -
-
+