From 57a8957c93d7a873c72ebe3b7a72198c33f66858 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 14 May 2026 10:58:41 +0200 Subject: [PATCH] chore(version): update version and changelog for release 0.8.121 - Bumped APP_VERSION to 0.8.121 and updated the changelog to reflect new features. - Introduced the ExerciseListFilterModal and ExerciseListBulkModal components, enhancing the exercise list functionality. - Modularized the ExerciseListPage to improve code organization and maintainability. - Added Playwright tests for the filter dialog functionality, ensuring proper user interaction and visibility. --- backend/version.py | 16 +- docs/HANDOVER.md | 4 +- docs/architecture/UMSETZUNGSPLAN_ROADMAP.md | 6 +- .../exercises/ExerciseListBulkModal.jsx | 240 +++++ .../exercises/ExerciseListFilterModal.jsx | 236 +++++ .../exercises/ExerciseListSearchBar.jsx | 107 +++ frontend/src/pages/ExercisesListPage.jsx | 871 +++--------------- frontend/src/utils/exerciseListFilterChips.js | 188 ++++ frontend/src/utils/exerciseListQuery.js | 83 ++ tests/dev-smoke-test.spec.js | 17 + 10 files changed, 995 insertions(+), 773 deletions(-) create mode 100644 frontend/src/components/exercises/ExerciseListBulkModal.jsx create mode 100644 frontend/src/components/exercises/ExerciseListFilterModal.jsx create mode 100644 frontend/src/components/exercises/ExerciseListSearchBar.jsx create mode 100644 frontend/src/utils/exerciseListFilterChips.js create mode 100644 frontend/src/utils/exerciseListQuery.js diff --git a/backend/version.py b/backend/version.py index be158f1..5cda9fe 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.119" +APP_VERSION = "0.8.121" BUILD_DATE = "2026-05-12" DB_SCHEMA_VERSION = "20260514062" @@ -36,6 +36,20 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.121", + "date": "2026-05-13", + "changes": [ + "Frontend Phase 3 (Teil): Übungsliste — Suchleiste/Chips in ExerciseListSearchBar; API-Query-Bau und Filter-Chips in utils/exerciseListQuery.js bzw. exerciseListFilterChips.js.", + ], + }, + { + "version": "0.8.120", + "date": "2026-05-13", + "changes": [ + "Frontend: Übungsliste — Filter- und Massenänderungs-Dialoge in ExerciseListFilterModal / ExerciseListBulkModal ausgelagert; Playwright-Test 10 (Filter-Dialog).", + ], + }, { "version": "0.8.119", "date": "2026-05-13", diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 7879f36..e52a4ef 100644 --- a/docs/HANDOVER.md +++ b/docs/HANDOVER.md @@ -1,7 +1,7 @@ # Shinkan Jinkendo – Entwicklungsstand & Handover **Stand:** 2026-05-13 -**App-Version / DB-Schema:** App **0.8.119**, DB-Schema **`20260514062`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`) +**App-Version / DB-Schema:** App **0.8.120**, DB-Schema **`20260514062`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`) Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**. @@ -76,7 +76,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl - **036 / 037:** Bibliotheks-Rahmen, Slot-Inhalt als **`training_units`** mit **`framework_slot_id`**; **`POST /api/training-units/from-framework-slot`**. - **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`. -### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.119**) +### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.120**) - **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§ 10.2.1** IDs, **§ 10.4** Coaching-Stufen, **§ 10.6** Produkt-Backlog, **Anhang A** Abgleich). - **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4a–g** — u. a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung). diff --git a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md index ffc57fd..ba2f011 100644 --- a/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md +++ b/docs/architecture/UMSETZUNGSPLAN_ROADMAP.md @@ -7,7 +7,9 @@ - **Phase 1 (Teil):** Org-Inbox: **ein** gemeinsamer Ladepfad `fetchOrgInboxSnapshot` für Mount-`useEffect` und `refreshOrgInbox` (gleiche Requests, weniger Drift-Risiko; Verhalten unverändert). - **Phase 2:** **abgeschlossen** (2026-05-14) — Indizes 058–062, Keyset `/api/exercises` + `/api/training-units`, **`/api/dashboard/kpis`** inkl. `training_home`, EXPLAIN-Vorlagen **`scripts/load/explain-readpaths.sql`**. - **Offen Phase 1:** Inbox optional **TTL** / nur bei sichtbarem Widget. -- **Phase 3 (gestartet 2026-05-13):** Übungsliste — extrahierte Karte, **virtualisierter** Picker, **lazy** Progressions-Panel; Playwright **Test 9**; Grid `data-testid`. Weiter: God-Pages (Planung/Formular) zerteilen. Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**. +- **Phase 3 (gestartet 2026-05-13):** Übungsliste modularisiert (Karte, Filter-/Bulk-Modals, virtualisierter Picker, lazy Progression); Playwright **Tests 9–10**. Weiter: God-Pages (Planung/Formular). + +**Ziel:** Nach MVP eine **nachhaltige** Architektur für Wachstum, **Performance** (Server + schwache Clients) und **sichere Feature-Erweiterung**. **Leitdokumente:** [ZIELBILD_ARCHITEKTUR.md](./ZIELBILD_ARCHITEKTUR.md), [SCHULDEN_UND_REMEDIATION.md](./SCHULDEN_UND_REMEDIATION.md), [VERBINDLICHE_REGELN_SHINKAN.md](./VERBINDLICHE_REGELN_SHINKAN.md). --- @@ -80,7 +82,7 @@ | Virtualisierung für die längste produktive Liste | A1, S2 | | Schwere Imports auf `import()` umziehen (gezielt) | A4 | -**Teil umgesetzt (2026-05-13):** `ExercisesListPage` — Karten in `components/exercises/ExerciseListCard.jsx`; Tab „Progressionsgraphen“ lädt **`ExerciseProgressionGraphPanel`** per `React.lazy` + `Suspense`; **`ExercisePickerModal`** virtualisiert (`@tanstack/react-virtual`, Scroll-Container `data-testid="exercise-picker-scroll"`); Gitter `data-testid="exercises-list-grid"` + `content-visibility` in `app.css`; Playwright **Test 9**. Offen: Seite unter Soft-Limit (~500 Zeilen), vollständige Zerteilung `TrainingPlanningPage` / `ExerciseFormPage`. +**Teil umgesetzt (2026-05-13):** `ExercisesListPage` — Karten `ExerciseListCard.jsx`; Filter/Massenänderung `ExerciseListFilterModal.jsx` / `ExerciseListBulkModal.jsx`; Tab „Progressionsgraphen“ **lazy**; **Picker** virtualisiert; Gitter `data-testid` + `content-visibility`; Playwright **Tests 9–10**. Offen: Seite unter Soft-Limit (~500 Zeilen, derzeit ~918 LOC), Zerteilung Planung/Übungsformular. **Abnahme:** Referenz-Page unter Soft-Limit; Regel S1 für neue Änderungen durchsetzbar. diff --git a/frontend/src/components/exercises/ExerciseListBulkModal.jsx b/frontend/src/components/exercises/ExerciseListBulkModal.jsx new file mode 100644 index 0000000..0d1913a --- /dev/null +++ b/frontend/src/components/exercises/ExerciseListBulkModal.jsx @@ -0,0 +1,240 @@ +import React from 'react' +import { activeClubMemberships } from '../../utils/activeClub' +import MultiSelectCombo from '../MultiSelectCombo' + +/** + * Massenänderung für ausgewählte Übungen in der Liste. + */ +export default function ExerciseListBulkModal({ + open, + onClose, + onSubmit, + bulkSubmitting, + selectedCount, + bulkMaxIds, + user, + isPlatformAdmin, + statusOptions, + bulkVisibilityOptions, + focusOptions, + styleOptions, + trainingTypeOptions, + targetGroupOptions, + bulkVisibility, + setBulkVisibility, + bulkStatus, + setBulkStatus, + bulkClubSelect, + setBulkClubSelect, + bulkClubManual, + setBulkClubManual, + bulkPatchFocusAreas, + setBulkPatchFocusAreas, + bulkFocusAreaIds, + setBulkFocusAreaIds, + bulkPatchStyleDirections, + setBulkPatchStyleDirections, + bulkStyleDirectionIds, + setBulkStyleDirectionIds, + bulkPatchTrainingTypes, + setBulkPatchTrainingTypes, + bulkTrainingTypeIds, + setBulkTrainingTypeIds, + bulkPatchTargetGroups, + setBulkPatchTargetGroups, + bulkTargetGroupIds, + setBulkTargetGroupIds, +}) { + if (!open) return null + + return ( +
{ + if (e.target === e.currentTarget) onClose() + }} + > +
e.stopPropagation()} + > +
+

+ Massenänderung +

+ +
+
+

+ Es werden {selectedCount} Übung(en) aus der aktuellen Auswahl bearbeitet. Pro Durchlauf + höchstens {bulkMaxIds}. Ohne Berechtigung bleiben Einzelübungen unverändert (siehe Hinweis nach dem + Speichern). +

+

+ Unter „Zuordnung ersetzen“: die gewählte Liste ersetzt die bisherige Zuordnung bei allen betroffenen Übungen + vollständig (leere Auswahl = alle Zuordnungen dieser Kategorie entfernen). Die erste Auswahl gilt als + Primärzuordnung. +

+
+ + +
+ {bulkVisibility === 'club' ? ( +
+ + + {isPlatformAdmin ? ( + <> + + setBulkClubManual(e.target.value)} + /> + + ) : null} +
+ ) : null} +
+ + +
+ +
+

Zuordnung (optional)

+
+
+ + {bulkPatchFocusAreas ? ( + + ) : null} +
+
+ + {bulkPatchStyleDirections ? ( + + ) : null} +
+
+ + {bulkPatchTrainingTypes ? ( + + ) : null} +
+
+ + {bulkPatchTargetGroups ? ( + + ) : null} +
+
+
+
+
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/exercises/ExerciseListFilterModal.jsx b/frontend/src/components/exercises/ExerciseListFilterModal.jsx new file mode 100644 index 0000000..e9f0583 --- /dev/null +++ b/frontend/src/components/exercises/ExerciseListFilterModal.jsx @@ -0,0 +1,236 @@ +import React from 'react' +import { SKILL_LEVEL_OPTIONS } from '../../constants/skillLevels' +import MultiSelectCombo from '../MultiSelectCombo' +import ExerciseFocusRulePicker from '../ExerciseFocusRulePicker' +import CatalogRulePicker from '../CatalogRulePicker' + +const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) + +/** + * Filter-Dialog für die Übungsliste (gleiche Logik wie zuvor inline in ExercisesListPage). + */ +export default function ExerciseListFilterModal({ + open, + onClose, + filters, + setFilters, + focusOptions, + styleOptions, + trainingTypeOptions, + targetGroupOptions, + skillOptions, + visibilityOptions, + statusOptions, + savingExercisePrefs, + onSaveStandard, + onResetAll, +}) { + if (!open) return null + + return ( +
{ + if (e.target === e.currentTarget) onClose() + }} + > +
e.stopPropagation()} + > +
+

+ Übungen filtern +

+ +
+
+

+ Zwischen den Bereichen gilt UND. Fokusbereiche: mehrere „+ mit“ bedeuten alle müssen + gesetzt sein; „− ohne“ schließt Übungen aus, die diesen Fokus zusätzlich haben. Stilrichtung / + Trainingsstil / Zielgruppe: mehrere „+“ = alle zutreffend (UND); „−“ verbietet die Zuordnung. Unter + „Freigabe“: Sichtbarkeit / Status mit „+“ = eine davon (ODER); „−“ blendet aus. +

+ +
+

Zuordnung

+ setFilters((prev) => ({ ...prev, ...patch }))} + /> +
+ setFilters((prev) => ({ ...prev, ...patch }))} + /> + setFilters((prev) => ({ ...prev, ...patch }))} + /> + setFilters((prev) => ({ ...prev, ...patch }))} + /> +
+
+ +
+

Fähigkeit und zugehörige Stufe

+
+ + setFilters({ ...filters, skill_ids: v })} + options={skillOptions} + placeholder="Fähigkeit suchen …" + /> +

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

+
+ + + – + + +
+
+
+ +
+

Ausblenden / Liste

+

+ Sichtbarkeit und Status steuern Sie unter „Freigabe“ mit + und −. Hier nur globale Listen-Optionen. +

+
+ + +
+
+ +
+

Freigabe

+

+ Pro Übung nur ein Wert: mehrere „+“ bedeuten „eine davon“ (ODER). „−“ blendet Werte aus. +

+
+ setFilters((prev) => ({ ...prev, ...patch }))} + /> + setFilters((prev) => ({ ...prev, ...patch }))} + /> +
+
+
+
+ + + +
+
+
+ ) +} diff --git a/frontend/src/components/exercises/ExerciseListSearchBar.jsx b/frontend/src/components/exercises/ExerciseListSearchBar.jsx new file mode 100644 index 0000000..eaad9b9 --- /dev/null +++ b/frontend/src/components/exercises/ExerciseListSearchBar.jsx @@ -0,0 +1,107 @@ +import React from 'react' + +export default function ExerciseListSearchBar({ + searchTitleSuggestions, + searchInput, + onSearchInputChange, + aiSearchInput, + onAiSearchInputChange, + mineOnly, + onToggleMineOnly, + onOpenFilter, + filterChips, + onResetAllFilters, + exerciseCount, + allOnPageSelected, + onToggleSelectAllPage, +}) { + return ( +
+ + + {searchTitleSuggestions.map((t) => ( + + onSearchInputChange(e.target.value)} + autoComplete="on" + name="exercise-fulltext-search" + list="exercise-search-titles" + enterKeyHint="search" + /> + + onAiSearchInputChange(e.target.value)} + autoComplete="on" + name="exercise-ai-search" + list="exercise-search-titles" + enterKeyHint="search" + /> +
+
+ + + {filterChips.length > 0 ? ( + + ) : null} +
+
+ {filterChips.length > 0 ? ( +
+ {filterChips.map((c) => ( + + ))} +
+ ) : null} +

+ Trefferliste aktualisiert sich kurz nach Eingabe. Titel der aktuellen Liste erscheinen als Vorschläge (Pfeil im + Feld). Fachliche Filter über „Filter“ – zwischen Feldern UND, Auswahl mehrerer Werte je Feld mit ODER. + {exerciseCount > 0 ? ( + <> + {' '} + + + ) : null} +

+
+ ) +} diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx index 1cd50ad..f58ae55 100644 --- a/frontend/src/pages/ExercisesListPage.jsx +++ b/frontend/src/pages/ExercisesListPage.jsx @@ -3,18 +3,17 @@ import { Link } from 'react-router-dom' import api from '../utils/api' import { useAuth } from '../context/AuthContext' import { activeClubMemberships, getTenantClubDependencyKey } from '../utils/activeClub' -import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels' -import MultiSelectCombo from '../components/MultiSelectCombo' -import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker' -import CatalogRulePicker from '../components/CatalogRulePicker' import PageSectionNav from '../components/PageSectionNav' import ExerciseListCard from '../components/exercises/ExerciseListCard' +import ExerciseListFilterModal from '../components/exercises/ExerciseListFilterModal' +import ExerciseListBulkModal from '../components/exercises/ExerciseListBulkModal' +import ExerciseListSearchBar from '../components/exercises/ExerciseListSearchBar' +import { buildExerciseListFilterChips } from '../utils/exerciseListFilterChips' +import { applyDashboardExerciseListUrl, buildExerciseListQueryBase } from '../utils/exerciseListQuery' import { INITIAL_EXERCISE_LIST_FILTERS, mergeExerciseListPrefsFromApi, compactExerciseListPrefsPayload, - splitMnCatalogRules, - splitScalarCatalogRules, } from '../constants/exerciseListFilters' const ExerciseProgressionGraphPanel = lazy(() => import('../components/ExerciseProgressionGraphPanel')) @@ -25,54 +24,6 @@ const EXERCISES_PAGE_TABS = [ { id: 'list', label: 'Liste' }, { id: 'progression', label: 'Progressionsgraphen' }, ] -const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) - -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 levelOptionShort(levelStr) { - const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr)) - return o ? String(o.level) : String(levelStr) -} - -function applyDashboardExerciseListUrl(mergedFromPrefs) { - try { - const sp = new URLSearchParams(window.location.search) - const mine = sp.get('mine') === '1' || sp.get('created_by_me') === '1' - const statusDraft = sp.get('status') === 'draft' - - if (mine) { - const next = { ...INITIAL_EXERCISE_LIST_FILTERS } - if (statusDraft) { - next.status_rules = [{ key: 'url-dashboard-draft', id: 'draft', mode: 'require' }] - } - return next - } - - if (statusDraft) { - return { - ...mergedFromPrefs, - status_rules: [{ key: 'url-dashboard-draft', id: 'draft', mode: 'require' }], - } - } - return mergedFromPrefs - } catch { - return mergedFromPrefs - } -} function ExercisesListPage() { const { user, checkAuth } = useAuth() @@ -206,168 +157,33 @@ function ExercisesListPage() { [] ) - const filterChips = useMemo(() => { - const chips = [] - - if (mineOnly) { - chips.push({ - key: 'mine-only', - label: 'Nur von mir erstellt', - onRemove: () => setMineOnly(false), - }) - } - - pushCatalogRuleFilterChips(chips, 'focus_rules', filters.focus_rules, focusOptions, 'Fokus', setFilters) - - if (filters.focus_only_without) { - chips.push({ - key: 'focus-only-none', - label: 'Nur ohne Fokusbereich', - onRemove: () => setFilters((prev) => ({ ...prev, focus_only_without: false })), - }) - } - - ;(filters.focus_area_ids || []).forEach((id) => { - const opt = focusOptions.find((o) => String(o.id) === String(id)) - chips.push({ - key: `fa-${id}`, - label: `Fokus (ODER, älter): ${opt?.label ?? id}`, - onRemove: () => - setFilters((prev) => ({ - ...prev, - focus_area_ids: prev.focus_area_ids.filter((x) => String(x) !== String(id)), - })), - }) - }) - - pushCatalogRuleFilterChips( - chips, - 'style_direction_rules', - filters.style_direction_rules, + const filterChips = useMemo( + () => + buildExerciseListFilterChips({ + mineOnly, + setMineOnly, + filters, + setFilters, + focusOptions, + styleOptions, + trainingTypeOptions, + targetGroupOptions, + skillOptions, + visibilityOptions, + statusOptions, + }), + [ + mineOnly, + filters, + focusOptions, 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 (ODER, älter): ${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 (ODER, älter): ${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 (ODER, älter): ${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: '', - })), - }) - } - - pushCatalogRuleFilterChips( - chips, - 'visibility_rules', - filters.visibility_rules, + skillOptions, visibilityOptions, - 'Sichtbarkeit', - setFilters - ) - pushCatalogRuleFilterChips(chips, 'status_rules', filters.status_rules, statusOptions, 'Status', setFilters) - - if (filters.exclude_without_focus) { - chips.push({ - key: 'ex-no-focus', - label: 'Ohne Fokus ausblenden', - onRemove: () => setFilters((prev) => ({ ...prev, exclude_without_focus: false })), - }) - } - if (filters.include_archived) { - chips.push({ - key: 'inc-arch', - label: 'Archivierte anzeigen', - onRemove: () => setFilters((prev) => ({ ...prev, include_archived: false })), - }) - } - - return chips - }, [ - mineOnly, - filters, - focusOptions, - styleOptions, - trainingTypeOptions, - targetGroupOptions, - skillOptions, - visibilityOptions, - statusOptions, - setFilters, - ]) + statusOptions, + ] + ) /** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */ const searchTitleSuggestions = useMemo(() => { @@ -375,56 +191,10 @@ function ExercisesListPage() { return [...new Set(titles)].slice(0, 80) }, [exercises]) - const queryBase = useMemo(() => { - const q = {} - 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 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 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.exclude_without_focus) q.exclude_without_focus = true - if (filters.include_archived) q.include_archived = true - if (debouncedSearch) q.search = debouncedSearch - if (debouncedAiSearch) q.ai_search = debouncedAiSearch - if (mineOnly) q.created_by_me = true - return q - }, [filters, debouncedSearch, debouncedAiSearch, mineOnly]) + const queryBase = useMemo( + () => buildExerciseListQueryBase(filters, debouncedSearch, debouncedAiSearch, mineOnly), + [filters, debouncedSearch, debouncedAiSearch, mineOnly] + ) useEffect(() => { setSelectedIds(new Set()) @@ -772,93 +542,21 @@ function ExercisesListPage() { ) : ( <> -
- - - {searchTitleSuggestions.map((t) => ( - - setSearchInput(e.target.value)} - autoComplete="on" - name="exercise-fulltext-search" - list="exercise-search-titles" - enterKeyHint="search" - /> - - setAiSearchInput(e.target.value)} - autoComplete="on" - name="exercise-ai-search" - list="exercise-search-titles" - enterKeyHint="search" - /> -
-
- - - {filterChips.length > 0 ? ( - - ) : null} -
-
- {filterChips.length > 0 ? ( -
- {filterChips.map((c) => ( - - ))} -
- ) : null} -

- Trefferliste aktualisiert sich kurz nach Eingabe. Titel der aktuellen Liste erscheinen als Vorschläge (Pfeil im - Feld). Fachliche Filter über „Filter“ – zwischen Feldern UND, Auswahl mehrerer Werte je Feld mit ODER. - {exercises.length > 0 ? ( - <> - {' '} - - - ) : null} -

-
+ setMineOnly((v) => !v)} + onOpenFilter={() => setFilterModalOpen(true)} + filterChips={filterChips} + onResetAllFilters={resetAllFilters} + exerciseCount={exercises.length} + allOnPageSelected={allOnPageSelected} + onToggleSelectAllPage={toggleSelectAllPage} + /> {selectedIds.size > 0 ? (
@@ -877,426 +575,63 @@ function ExercisesListPage() {
) : null} - {filterModalOpen && ( -
{ - if (e.target === e.currentTarget) setFilterModalOpen(false) - }} - > -
e.stopPropagation()} - > -
-

- Übungen filtern -

- -
-
-

- Zwischen den Bereichen gilt UND. Fokusbereiche: mehrere „+ mit“ bedeuten alle müssen - gesetzt sein; „− ohne“ schließt Übungen aus, die diesen Fokus zusätzlich haben. Stilrichtung / - Trainingsstil / Zielgruppe: mehrere „+“ = alle zutreffend (UND); „−“ verbietet die Zuordnung. Unter - „Freigabe“: Sichtbarkeit / Status mit „+“ = eine davon (ODER); „−“ blendet aus. -

+ setFilterModalOpen(false)} + filters={filters} + setFilters={setFilters} + focusOptions={focusOptions} + styleOptions={styleOptions} + trainingTypeOptions={trainingTypeOptions} + targetGroupOptions={targetGroupOptions} + skillOptions={skillOptions} + visibilityOptions={visibilityOptions} + statusOptions={statusOptions} + savingExercisePrefs={savingExercisePrefs} + onSaveStandard={handleSaveExerciseFilterPrefs} + onResetAll={resetAllFilters} + /> -
-

Zuordnung

- setFilters((prev) => ({ ...prev, ...patch }))} - /> -
- setFilters((prev) => ({ ...prev, ...patch }))} - /> - setFilters((prev) => ({ ...prev, ...patch }))} - /> - setFilters((prev) => ({ ...prev, ...patch }))} - /> -
-
- -
-

Fähigkeit und zugehörige Stufe

-
- - setFilters({ ...filters, skill_ids: v })} - options={skillOptions} - placeholder="Fähigkeit suchen …" - /> -

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

-
- - - – - - -
-
-
- -
-

Ausblenden / Liste

-

- Sichtbarkeit und Status steuern Sie unter „Freigabe“ mit + und −. Hier nur globale Listen-Optionen. -

-
- - -
-
- -
-

Freigabe

-

- Pro Übung nur ein Wert: mehrere „+“ bedeuten „eine davon“ (ODER). „−“ blendet Werte aus. -

-
- setFilters((prev) => ({ ...prev, ...patch }))} - /> - setFilters((prev) => ({ ...prev, ...patch }))} - /> -
-
-
-
- - - -
-
-
- )} - - {bulkModalOpen ? ( -
{ - if (e.target === e.currentTarget) setBulkModalOpen(false) - }} - > -
e.stopPropagation()} - > -
-

- Massenänderung -

- -
-
-

- Es werden {selectedIds.size} Übung(en) aus der aktuellen Auswahl bearbeitet. Pro Durchlauf - höchstens {BULK_MAX_IDS}. Ohne Berechtigung bleiben Einzelübungen unverändert (siehe Hinweis nach dem - Speichern). -

-

- Unter „Zuordnung ersetzen“: die gewählte Liste ersetzt die bisherige Zuordnung bei allen betroffenen - Übungen vollständig (leere Auswahl = alle Zuordnungen dieser Kategorie entfernen). Die erste Auswahl gilt - als Primärzuordnung. -

-
- - -
- {bulkVisibility === 'club' ? ( -
- - - {isPlatformAdmin ? ( - <> - - setBulkClubManual(e.target.value)} - /> - - ) : null} -
- ) : null} -
- - -
- -
-

Zuordnung (optional)

-
-
- - {bulkPatchFocusAreas ? ( - - ) : null} -
-
- - {bulkPatchStyleDirections ? ( - - ) : null} -
-
- - {bulkPatchTrainingTypes ? ( - - ) : null} -
-
- - {bulkPatchTargetGroups ? ( - - ) : null} -
-
-
-
-
- - -
-
-
- ) : null} + setBulkModalOpen(false)} + onSubmit={handleBulkSubmit} + bulkSubmitting={bulkSubmitting} + selectedCount={selectedIds.size} + bulkMaxIds={BULK_MAX_IDS} + user={user} + isPlatformAdmin={isPlatformAdmin} + statusOptions={statusOptions} + bulkVisibilityOptions={bulkVisibilityOptions} + focusOptions={focusOptions} + styleOptions={styleOptions} + trainingTypeOptions={trainingTypeOptions} + targetGroupOptions={targetGroupOptions} + bulkVisibility={bulkVisibility} + setBulkVisibility={setBulkVisibility} + bulkStatus={bulkStatus} + setBulkStatus={setBulkStatus} + bulkClubSelect={bulkClubSelect} + setBulkClubSelect={setBulkClubSelect} + bulkClubManual={bulkClubManual} + setBulkClubManual={setBulkClubManual} + bulkPatchFocusAreas={bulkPatchFocusAreas} + setBulkPatchFocusAreas={setBulkPatchFocusAreas} + bulkFocusAreaIds={bulkFocusAreaIds} + setBulkFocusAreaIds={setBulkFocusAreaIds} + bulkPatchStyleDirections={bulkPatchStyleDirections} + setBulkPatchStyleDirections={setBulkPatchStyleDirections} + bulkStyleDirectionIds={bulkStyleDirectionIds} + setBulkStyleDirectionIds={setBulkStyleDirectionIds} + bulkPatchTrainingTypes={bulkPatchTrainingTypes} + setBulkPatchTrainingTypes={setBulkPatchTrainingTypes} + bulkTrainingTypeIds={bulkTrainingTypeIds} + setBulkTrainingTypeIds={setBulkTrainingTypeIds} + bulkPatchTargetGroups={bulkPatchTargetGroups} + setBulkPatchTargetGroups={setBulkPatchTargetGroups} + bulkTargetGroupIds={bulkTargetGroupIds} + setBulkTargetGroupIds={setBulkTargetGroupIds} + /> {listFetching && exercises.length === 0 ? (
diff --git a/frontend/src/utils/exerciseListFilterChips.js b/frontend/src/utils/exerciseListFilterChips.js new file mode 100644 index 0000000..a4293ac --- /dev/null +++ b/frontend/src/utils/exerciseListFilterChips.js @@ -0,0 +1,188 @@ +import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels' + +const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) + +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 levelOptionShort(levelStr) { + const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr)) + return o ? String(o.level) : String(levelStr) +} + +export function buildExerciseListFilterChips({ + mineOnly, + setMineOnly, + filters, + setFilters, + focusOptions, + styleOptions, + trainingTypeOptions, + targetGroupOptions, + skillOptions, + visibilityOptions, + statusOptions, +}) { + const chips = [] + + if (mineOnly) { + chips.push({ + key: 'mine-only', + label: 'Nur von mir erstellt', + onRemove: () => setMineOnly(false), + }) + } + + pushCatalogRuleFilterChips(chips, 'focus_rules', filters.focus_rules, focusOptions, 'Fokus', setFilters) + + if (filters.focus_only_without) { + chips.push({ + key: 'focus-only-none', + label: 'Nur ohne Fokusbereich', + onRemove: () => setFilters((prev) => ({ ...prev, focus_only_without: false })), + }) + } + + ;(filters.focus_area_ids || []).forEach((id) => { + const opt = focusOptions.find((o) => String(o.id) === String(id)) + chips.push({ + key: `fa-${id}`, + label: `Fokus (ODER, älter): ${opt?.label ?? id}`, + onRemove: () => + setFilters((prev) => ({ + ...prev, + focus_area_ids: prev.focus_area_ids.filter((x) => String(x) !== String(id)), + })), + }) + }) + + 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 (ODER, älter): ${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 (ODER, älter): ${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 (ODER, älter): ${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: '', + })), + }) + } + + 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({ + key: 'ex-no-focus', + label: 'Ohne Fokus ausblenden', + onRemove: () => setFilters((prev) => ({ ...prev, exclude_without_focus: false })), + }) + } + if (filters.include_archived) { + chips.push({ + key: 'inc-arch', + label: 'Archivierte anzeigen', + onRemove: () => setFilters((prev) => ({ ...prev, include_archived: false })), + }) + } + + return chips +} diff --git a/frontend/src/utils/exerciseListQuery.js b/frontend/src/utils/exerciseListQuery.js new file mode 100644 index 0000000..3564db6 --- /dev/null +++ b/frontend/src/utils/exerciseListQuery.js @@ -0,0 +1,83 @@ +import { + INITIAL_EXERCISE_LIST_FILTERS, + splitMnCatalogRules, + splitScalarCatalogRules, +} from '../constants/exerciseListFilters' + +/** Dashboard-Deep-Link: ?mine=1, optional ?status=draft — überschreibt gespeicherte Prefs-Kombination. */ +export function applyDashboardExerciseListUrl(mergedFromPrefs) { + try { + const sp = new URLSearchParams(window.location.search) + const mine = sp.get('mine') === '1' || sp.get('created_by_me') === '1' + const statusDraft = sp.get('status') === 'draft' + + if (mine) { + const next = { ...INITIAL_EXERCISE_LIST_FILTERS } + if (statusDraft) { + next.status_rules = [{ key: 'url-dashboard-draft', id: 'draft', mode: 'require' }] + } + return next + } + + if (statusDraft) { + return { + ...mergedFromPrefs, + status_rules: [{ key: 'url-dashboard-draft', id: 'draft', mode: 'require' }], + } + } + return mergedFromPrefs + } catch { + return mergedFromPrefs + } +} + +export function buildExerciseListQueryBase(filters, debouncedSearch, debouncedAiSearch, mineOnly) { + const q = {} + 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 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 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.exclude_without_focus) q.exclude_without_focus = true + if (filters.include_archived) q.include_archived = true + if (debouncedSearch) q.search = debouncedSearch + if (debouncedAiSearch) q.ai_search = debouncedAiSearch + if (mineOnly) q.created_by_me = true + return q +} diff --git a/tests/dev-smoke-test.spec.js b/tests/dev-smoke-test.spec.js index e498118..58a61b9 100644 --- a/tests/dev-smoke-test.spec.js +++ b/tests/dev-smoke-test.spec.js @@ -220,6 +220,23 @@ test('9. Übungsliste: nach Laden entweder Treffer-Gitter oder Leerhinweis', asy console.log('✓ Übungsliste: Endzustand sichtbar (Gitter oder leer)'); }); +test('10. Übungsliste: Filter-Dialog öffnet und schließt', async ({ page }) => { + await login(page); + await page.goto('/exercises', { waitUntil: 'networkidle' }); + const main = page.locator('.app-main'); + await expect(main.getByRole('heading', { level: 1, name: /Übungen/i })).toBeVisible({ + timeout: 15000, + }); + await expect(main.locator('.spinner')).toHaveCount(0, { timeout: 20000 }); + await main.getByRole('button', { name: /^Filter$/i }).click(); + const dlg = page.getByTestId('exercise-list-filter-modal'); + await expect(dlg).toBeVisible({ timeout: 10000 }); + await expect(dlg.getByRole('heading', { name: 'Übungen filtern' })).toBeVisible(); + await dlg.getByRole('button', { name: 'Schließen' }).click(); + await expect(dlg).toHaveCount(0); + console.log('✓ Übungsliste: Filter-Dialog Smoke'); +}); + test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => { await page.setViewportSize({ width: 1280, height: 800 }); await login(page);