chore(version): update version and changelog for release 0.8.121
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m1s

- 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.
This commit is contained in:
Lars 2026-05-14 10:58:41 +02:00
parent 930a786315
commit 57a8957c93
10 changed files with 995 additions and 773 deletions

View File

@ -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",

View File

@ -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 **4ag** — u.a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung).

View File

@ -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 058062, 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 910**. 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 910**. 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.

View File

@ -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 (
<div
className="admin-modal-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
<div
className="admin-modal-sheet exercise-filter-modal"
data-testid="exercise-list-bulk-modal"
role="dialog"
aria-modal="true"
aria-labelledby="exercise-bulk-modal-title"
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 id="exercise-bulk-modal-title" className="admin-modal-sheet__title">
Massenänderung
</h3>
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
Schließen
</button>
</div>
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
<p className="muted" style={{ marginTop: 0 }}>
Es werden <strong>{selectedCount}</strong> Übung(en) aus der aktuellen Auswahl bearbeitet. Pro Durchlauf
höchstens {bulkMaxIds}. Ohne Berechtigung bleiben Einzelübungen unverändert (siehe Hinweis nach dem
Speichern).
</p>
<p className="form-sub" style={{ marginTop: 0, marginBottom: '14px' }}>
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.
</p>
<div className="form-row">
<label className="form-label">Sichtbarkeit</label>
<select className="form-input" value={bulkVisibility} onChange={(e) => setBulkVisibility(e.target.value)}>
{bulkVisibilityOptions.map((o) => (
<option key={o.id === '' ? '_unchanged' : o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
{bulkVisibility === 'club' ? (
<div className="form-row">
<label className="form-label">Verein zuordnen</label>
<select className="form-input" value={bulkClubSelect} onChange={(e) => setBulkClubSelect(e.target.value)}>
<option value="">Aktiver Verein (Vereins-Umschalter / Header)</option>
{activeClubMemberships(user?.clubs).map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || `#${c.id}`}
</option>
))}
</select>
{isPlatformAdmin ? (
<>
<label className="form-label" style={{ marginTop: '10px' }}>
Oder Vereins-ID (Plattform-Admin)
</label>
<input
type="number"
min={1}
className="form-input"
placeholder="Leer = wie Dropdown / aktiver Verein"
value={bulkClubManual}
onChange={(e) => setBulkClubManual(e.target.value)}
/>
</>
) : null}
</div>
) : null}
<div className="form-row">
<label className="form-label">Status</label>
<select className="form-input" value={bulkStatus} onChange={(e) => setBulkStatus(e.target.value)}>
<option value=""> nicht ändern </option>
{statusOptions.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
<section className="exercise-filter-section" style={{ marginTop: '8px', paddingTop: '12px' }}>
<h4 className="exercise-filter-section-title">Zuordnung (optional)</h4>
<div className="exercise-filters-modal-grid">
<div>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={bulkPatchFocusAreas}
onChange={(e) => {
const on = e.target.checked
setBulkPatchFocusAreas(on)
if (!on) setBulkFocusAreaIds([])
}}
/>
Fokusbereiche ersetzen
</label>
{bulkPatchFocusAreas ? (
<MultiSelectCombo
value={bulkFocusAreaIds}
onChange={setBulkFocusAreaIds}
options={focusOptions}
placeholder="Fokusbereiche wählen …"
/>
) : null}
</div>
<div>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={bulkPatchStyleDirections}
onChange={(e) => {
const on = e.target.checked
setBulkPatchStyleDirections(on)
if (!on) setBulkStyleDirectionIds([])
}}
/>
Stilrichtungen ersetzen
</label>
{bulkPatchStyleDirections ? (
<MultiSelectCombo
value={bulkStyleDirectionIds}
onChange={setBulkStyleDirectionIds}
options={styleOptions}
placeholder="Stilrichtungen wählen …"
/>
) : null}
</div>
<div>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={bulkPatchTrainingTypes}
onChange={(e) => {
const on = e.target.checked
setBulkPatchTrainingTypes(on)
if (!on) setBulkTrainingTypeIds([])
}}
/>
Trainingsstile ersetzen
</label>
{bulkPatchTrainingTypes ? (
<MultiSelectCombo
value={bulkTrainingTypeIds}
onChange={setBulkTrainingTypeIds}
options={trainingTypeOptions}
placeholder="Trainingsstile wählen …"
/>
) : null}
</div>
<div>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={bulkPatchTargetGroups}
onChange={(e) => {
const on = e.target.checked
setBulkPatchTargetGroups(on)
if (!on) setBulkTargetGroupIds([])
}}
/>
Zielgruppen ersetzen
</label>
{bulkPatchTargetGroups ? (
<MultiSelectCombo
value={bulkTargetGroupIds}
onChange={setBulkTargetGroupIds}
options={targetGroupOptions}
placeholder="Zielgruppen wählen …"
/>
) : null}
</div>
</div>
</section>
</div>
<div className="exercise-filter-modal__footer">
<button type="button" className="btn" disabled={bulkSubmitting} onClick={onClose}>
Abbrechen
</button>
<button type="button" className="btn btn-primary" disabled={bulkSubmitting} onClick={onSubmit}>
{bulkSubmitting ? 'Speichern…' : 'Anwenden'}
</button>
</div>
</div>
</div>
)
}

View File

@ -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 (
<div
className="admin-modal-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
<div
className="admin-modal-sheet exercise-filter-modal"
data-testid="exercise-list-filter-modal"
role="dialog"
aria-modal="true"
aria-labelledby="exercise-filter-modal-title"
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 id="exercise-filter-modal-title" className="admin-modal-sheet__title">
Übungen filtern
</h3>
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
Schließen
</button>
</div>
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
<p className="muted" style={{ marginTop: 0, marginBottom: '14px' }}>
Zwischen den Bereichen gilt <strong>UND</strong>. 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.
</p>
<section className="exercise-filter-section">
<h4 className="exercise-filter-section-title">Zuordnung</h4>
<ExerciseFocusRulePicker
focusOptions={focusOptions}
focusRules={filters.focus_rules}
focusOnlyWithout={filters.focus_only_without}
legacyFocusAreaIds={filters.focus_area_ids}
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--catalog" style={{ marginTop: '12px' }}>
<CatalogRulePicker
label="Stilrichtung"
hint="+ alle nötig (UND). verbietet Zuordnung."
options={styleOptions}
rules={filters.style_direction_rules}
rulesFieldName="style_direction_rules"
placeholder="Stil …"
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
<CatalogRulePicker
label="Trainingsstil"
hint="+ alle nötig (UND). verbietet Zuordnung."
options={trainingTypeOptions}
rules={filters.training_type_rules}
rulesFieldName="training_type_rules"
placeholder="Trainingsstil …"
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
<CatalogRulePicker
label="Zielgruppe"
hint="+ alle nötig (UND). verbietet Zuordnung."
options={targetGroupOptions}
rules={filters.target_group_rules}
rulesFieldName="target_group_rules"
placeholder="Gruppe …"
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
</div>
</section>
<section className="exercise-filter-section">
<h4 className="exercise-filter-section-title">Fähigkeit und zugehörige Stufe</h4>
<div className="exercise-filter-skill-block">
<label className="form-label">Fähigkeit</label>
<MultiSelectCombo
value={filters.skill_ids}
onChange={(v) => setFilters({ ...filters, skill_ids: v })}
options={skillOptions}
placeholder="Fähigkeit suchen …"
/>
<p className="exercise-filter-skill-hint">
Die Stufen filtern nach dem Niveau der Zuordnung Übung Fähigkeit (vonbis).
</p>
<div className="exercise-filter-skill-levels-row">
<label className="exercise-filter-skill-level-field">
<span className="exercise-filter-skill-level-caption">von</span>
<select
className="form-input exercise-filter-level-select"
title="Mindest-Stufe"
value={filters.skill_min_level}
onChange={(e) => setFilters({ ...filters, skill_min_level: e.target.value })}
>
<option value=""></option>
{LEVEL_FILTER_OPTS.map((o) => (
<option key={o.value} value={String(o.level)} title={o.label}>
{o.level}
</option>
))}
</select>
</label>
<span className="exercise-filter-skill-dash" aria-hidden>
</span>
<label className="exercise-filter-skill-level-field">
<span className="exercise-filter-skill-level-caption">bis</span>
<select
className="form-input exercise-filter-level-select"
title="Höchst-Stufe"
value={filters.skill_max_level}
onChange={(e) => setFilters({ ...filters, skill_max_level: e.target.value })}
>
<option value=""></option>
{LEVEL_FILTER_OPTS.map((o) => (
<option key={`m-${o.value}`} value={String(o.level)} title={o.label}>
{o.level}
</option>
))}
</select>
</label>
</div>
</div>
</section>
<section className="exercise-filter-section">
<h4 className="exercise-filter-section-title">Ausblenden / Liste</h4>
<p className="muted" style={{ marginTop: 0, marginBottom: '12px', fontSize: '13px' }}>
Sichtbarkeit und Status steuern Sie unter Freigabe mit + und . Hier nur globale Listen-Optionen.
</p>
<div style={{ marginTop: '6px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
cursor: filters.focus_only_without ? 'not-allowed' : 'pointer',
opacity: filters.focus_only_without ? 0.55 : 1,
}}
>
<input
type="checkbox"
disabled={!!filters.focus_only_without}
checked={!!filters.exclude_without_focus}
onChange={(e) =>
setFilters((prev) => ({
...prev,
exclude_without_focus: e.target.checked,
...(e.target.checked ? { focus_only_without: false } : {}),
}))
}
/>
<span>Übungen ohne Fokusbereich ausblenden</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!filters.include_archived}
onChange={(e) => setFilters({ ...filters, include_archived: e.target.checked })}
/>
<span>Archivierte Übungen einblenden (ohne Haken werden sie standardmäßig ausgeblendet)</span>
</label>
</div>
</section>
<section className="exercise-filter-section exercise-filter-section--last">
<h4 className="exercise-filter-section-title">Freigabe</h4>
<p className="muted" style={{ marginTop: 0, marginBottom: '10px', fontSize: '12px' }}>
Pro Übung nur ein Wert: mehrere + bedeuten eine davon (ODER). blendet Werte aus.
</p>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two exercise-filters-modal-grid--catalog">
<CatalogRulePicker
label="Sichtbarkeit"
options={visibilityOptions}
rules={filters.visibility_rules}
rulesFieldName="visibility_rules"
idKind="string"
placeholder="Sichtbarkeit …"
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
<CatalogRulePicker
label="Status"
options={statusOptions}
rules={filters.status_rules}
rulesFieldName="status_rules"
idKind="string"
placeholder="Status …"
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
</div>
</section>
</div>
<div className="exercise-filter-modal__footer">
<button type="button" className="btn btn-secondary" disabled={savingExercisePrefs} onClick={onSaveStandard}>
{savingExercisePrefs ? 'Speichern…' : 'Als Standard speichern'}
</button>
<button type="button" className="btn" onClick={onResetAll}>
Alle Filter zurücksetzen
</button>
<button type="button" className="btn btn-primary" onClick={onClose}>
Fertig
</button>
</div>
</div>
</div>
)
}

View File

@ -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 (
<div className="card exercise-search-bar">
<label className="form-label">Volltextsuche (Titel, Ziel, )</label>
<datalist id="exercise-search-titles">
{searchTitleSuggestions.map((t) => (
<option key={t} value={t} />
))}
</datalist>
<input
type="search"
className="form-input exercise-search-bar__primary"
placeholder="Suchbegriffe…"
value={searchInput}
onChange={(e) => onSearchInputChange(e.target.value)}
autoComplete="on"
name="exercise-fulltext-search"
list="exercise-search-titles"
enterKeyHint="search"
/>
<label className="form-label">Ergänzende Suche / KI-Vorbereitung (Beta)</label>
<input
type="search"
className="form-input"
placeholder="zweiter Begriff — zusätzliche Volltextsuche (ODER)"
value={aiSearchInput}
onChange={(e) => onAiSearchInputChange(e.target.value)}
autoComplete="on"
name="exercise-ai-search"
list="exercise-search-titles"
enterKeyHint="search"
/>
<div className="exercise-search-bar__actions exercise-search-bar__actions--split">
<div className="exercise-search-bar__actions-main">
<button
type="button"
className={'btn btn-secondary exercise-mine-toggle' + (mineOnly ? ' exercise-mine-toggle--active' : '')}
onClick={onToggleMineOnly}
title="Nur Übungen, die mit deinem Profil als Ersteller gespeichert sind"
>
Meine Übungen
</button>
<button type="button" className="btn btn-secondary exercise-filter-trigger" onClick={onOpenFilter}>
Filter
{filterChips.length > 0 ? (
<span className="exercise-filter-badge" aria-hidden>
{filterChips.length}
</span>
) : null}
</button>
{filterChips.length > 0 ? (
<button type="button" className="btn" onClick={onResetAllFilters}>
Alle entfernen
</button>
) : null}
</div>
</div>
{filterChips.length > 0 ? (
<div className="exercise-filter-chips-row" role="list" aria-label="Aktive Filter">
{filterChips.map((c) => (
<button
key={c.key}
type="button"
role="listitem"
className="exercise-filter-chip"
title="Filter entfernen"
onClick={() => c.onRemove()}
>
<span className="exercise-filter-chip__text">{c.label}</span>
<span className="exercise-filter-chip__x" aria-hidden>
×
</span>
</button>
))}
</div>
) : null}
<p className="exercise-search-hint">
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 ? (
<>
{' '}
<button type="button" className="btn btn-secondary btn-small" onClick={onToggleSelectAllPage}>
{allOnPageSelected ? 'Auswahl auf dieser Seite aufheben' : 'Alle auf dieser Seite auswählen'}
</button>
</>
) : null}
</p>
</div>
)
}

View File

@ -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() {
</Suspense>
) : (
<>
<div className="card exercise-search-bar">
<label className="form-label">Volltextsuche (Titel, Ziel, )</label>
<datalist id="exercise-search-titles">
{searchTitleSuggestions.map((t) => (
<option key={t} value={t} />
))}
</datalist>
<input
type="search"
className="form-input exercise-search-bar__primary"
placeholder="Suchbegriffe…"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
autoComplete="on"
name="exercise-fulltext-search"
list="exercise-search-titles"
enterKeyHint="search"
/>
<label className="form-label">Ergänzende Suche / KI-Vorbereitung (Beta)</label>
<input
type="search"
className="form-input"
placeholder="zweiter Begriff — zusätzliche Volltextsuche (ODER)"
value={aiSearchInput}
onChange={(e) => setAiSearchInput(e.target.value)}
autoComplete="on"
name="exercise-ai-search"
list="exercise-search-titles"
enterKeyHint="search"
/>
<div className="exercise-search-bar__actions exercise-search-bar__actions--split">
<div className="exercise-search-bar__actions-main">
<button
type="button"
className={'btn btn-secondary exercise-mine-toggle' + (mineOnly ? ' exercise-mine-toggle--active' : '')}
onClick={() => setMineOnly((v) => !v)}
title="Nur Übungen, die mit deinem Profil als Ersteller gespeichert sind"
>
Meine Übungen
</button>
<button type="button" className="btn btn-secondary exercise-filter-trigger" onClick={() => setFilterModalOpen(true)}>
Filter
{filterChips.length > 0 ? (
<span className="exercise-filter-badge" aria-hidden>
{filterChips.length}
</span>
) : null}
</button>
{filterChips.length > 0 ? (
<button type="button" className="btn" onClick={resetAllFilters}>
Alle entfernen
</button>
) : null}
</div>
</div>
{filterChips.length > 0 ? (
<div className="exercise-filter-chips-row" role="list" aria-label="Aktive Filter">
{filterChips.map((c) => (
<button
key={c.key}
type="button"
role="listitem"
className="exercise-filter-chip"
title="Filter entfernen"
onClick={() => c.onRemove()}
>
<span className="exercise-filter-chip__text">{c.label}</span>
<span className="exercise-filter-chip__x" aria-hidden>
×
</span>
</button>
))}
</div>
) : null}
<p className="exercise-search-hint">
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 ? (
<>
{' '}
<button type="button" className="btn btn-secondary btn-small" onClick={toggleSelectAllPage}>
{allOnPageSelected ? 'Auswahl auf dieser Seite aufheben' : 'Alle auf dieser Seite auswählen'}
</button>
</>
) : null}
</p>
</div>
<ExerciseListSearchBar
searchTitleSuggestions={searchTitleSuggestions}
searchInput={searchInput}
onSearchInputChange={setSearchInput}
aiSearchInput={aiSearchInput}
onAiSearchInputChange={setAiSearchInput}
mineOnly={mineOnly}
onToggleMineOnly={() => setMineOnly((v) => !v)}
onOpenFilter={() => setFilterModalOpen(true)}
filterChips={filterChips}
onResetAllFilters={resetAllFilters}
exerciseCount={exercises.length}
allOnPageSelected={allOnPageSelected}
onToggleSelectAllPage={toggleSelectAllPage}
/>
{selectedIds.size > 0 ? (
<div className="card exercise-bulk-toolbar">
@ -877,426 +575,63 @@ function ExercisesListPage() {
</div>
) : null}
{filterModalOpen && (
<div
className="admin-modal-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) setFilterModalOpen(false)
}}
>
<div
className="admin-modal-sheet exercise-filter-modal"
role="dialog"
aria-modal="true"
aria-labelledby="exercise-filter-modal-title"
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 id="exercise-filter-modal-title" className="admin-modal-sheet__title">
Übungen filtern
</h3>
<button
type="button"
className="btn btn-secondary admin-modal-sheet__close"
onClick={() => setFilterModalOpen(false)}
>
Schließen
</button>
</div>
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
<p className="muted" style={{ marginTop: 0, marginBottom: '14px' }}>
Zwischen den Bereichen gilt <strong>UND</strong>. 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.
</p>
<ExerciseListFilterModal
open={filterModalOpen}
onClose={() => 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}
/>
<section className="exercise-filter-section">
<h4 className="exercise-filter-section-title">Zuordnung</h4>
<ExerciseFocusRulePicker
focusOptions={focusOptions}
focusRules={filters.focus_rules}
focusOnlyWithout={filters.focus_only_without}
legacyFocusAreaIds={filters.focus_area_ids}
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--catalog" style={{ marginTop: '12px' }}>
<CatalogRulePicker
label="Stilrichtung"
hint="+ alle nötig (UND). verbietet Zuordnung."
options={styleOptions}
rules={filters.style_direction_rules}
rulesFieldName="style_direction_rules"
placeholder="Stil …"
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
<CatalogRulePicker
label="Trainingsstil"
hint="+ alle nötig (UND). verbietet Zuordnung."
options={trainingTypeOptions}
rules={filters.training_type_rules}
rulesFieldName="training_type_rules"
placeholder="Trainingsstil …"
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
<CatalogRulePicker
label="Zielgruppe"
hint="+ alle nötig (UND). verbietet Zuordnung."
options={targetGroupOptions}
rules={filters.target_group_rules}
rulesFieldName="target_group_rules"
placeholder="Gruppe …"
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
</div>
</section>
<section className="exercise-filter-section">
<h4 className="exercise-filter-section-title">Fähigkeit und zugehörige Stufe</h4>
<div className="exercise-filter-skill-block">
<label className="form-label">Fähigkeit</label>
<MultiSelectCombo
value={filters.skill_ids}
onChange={(v) => setFilters({ ...filters, skill_ids: v })}
options={skillOptions}
placeholder="Fähigkeit suchen …"
/>
<p className="exercise-filter-skill-hint">
Die Stufen filtern nach dem Niveau der Zuordnung Übung Fähigkeit (vonbis).
</p>
<div className="exercise-filter-skill-levels-row">
<label className="exercise-filter-skill-level-field">
<span className="exercise-filter-skill-level-caption">von</span>
<select
className="form-input exercise-filter-level-select"
title="Mindest-Stufe"
value={filters.skill_min_level}
onChange={(e) => setFilters({ ...filters, skill_min_level: e.target.value })}
>
<option value=""></option>
{LEVEL_FILTER_OPTS.map((o) => (
<option key={o.value} value={String(o.level)} title={o.label}>
{o.level}
</option>
))}
</select>
</label>
<span className="exercise-filter-skill-dash" aria-hidden>
</span>
<label className="exercise-filter-skill-level-field">
<span className="exercise-filter-skill-level-caption">bis</span>
<select
className="form-input exercise-filter-level-select"
title="Höchst-Stufe"
value={filters.skill_max_level}
onChange={(e) => setFilters({ ...filters, skill_max_level: e.target.value })}
>
<option value=""></option>
{LEVEL_FILTER_OPTS.map((o) => (
<option key={`m-${o.value}`} value={String(o.level)} title={o.label}>
{o.level}
</option>
))}
</select>
</label>
</div>
</div>
</section>
<section className="exercise-filter-section">
<h4 className="exercise-filter-section-title">Ausblenden / Liste</h4>
<p className="muted" style={{ marginTop: 0, marginBottom: '12px', fontSize: '13px' }}>
Sichtbarkeit und Status steuern Sie unter Freigabe mit + und . Hier nur globale Listen-Optionen.
</p>
<div style={{ marginTop: '6px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
cursor: filters.focus_only_without ? 'not-allowed' : 'pointer',
opacity: filters.focus_only_without ? 0.55 : 1,
}}
>
<input
type="checkbox"
disabled={!!filters.focus_only_without}
checked={!!filters.exclude_without_focus}
onChange={(e) =>
setFilters((prev) => ({
...prev,
exclude_without_focus: e.target.checked,
...(e.target.checked ? { focus_only_without: false } : {}),
}))
}
/>
<span>Übungen ohne Fokusbereich ausblenden</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!filters.include_archived}
onChange={(e) => setFilters({ ...filters, include_archived: e.target.checked })}
/>
<span>Archivierte Übungen einblenden (ohne Haken werden sie standardmäßig ausgeblendet)</span>
</label>
</div>
</section>
<section className="exercise-filter-section exercise-filter-section--last">
<h4 className="exercise-filter-section-title">Freigabe</h4>
<p className="muted" style={{ marginTop: 0, marginBottom: '10px', fontSize: '12px' }}>
Pro Übung nur ein Wert: mehrere + bedeuten eine davon (ODER). blendet Werte aus.
</p>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two exercise-filters-modal-grid--catalog">
<CatalogRulePicker
label="Sichtbarkeit"
options={visibilityOptions}
rules={filters.visibility_rules}
rulesFieldName="visibility_rules"
idKind="string"
placeholder="Sichtbarkeit …"
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
<CatalogRulePicker
label="Status"
options={statusOptions}
rules={filters.status_rules}
rulesFieldName="status_rules"
idKind="string"
placeholder="Status …"
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
</div>
</section>
</div>
<div className="exercise-filter-modal__footer">
<button type="button" className="btn btn-secondary" disabled={savingExercisePrefs} onClick={handleSaveExerciseFilterPrefs}>
{savingExercisePrefs ? 'Speichern…' : 'Als Standard speichern'}
</button>
<button type="button" className="btn" onClick={resetAllFilters}>
Alle Filter zurücksetzen
</button>
<button type="button" className="btn btn-primary" onClick={() => setFilterModalOpen(false)}>
Fertig
</button>
</div>
</div>
</div>
)}
{bulkModalOpen ? (
<div
className="admin-modal-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) setBulkModalOpen(false)
}}
>
<div
className="admin-modal-sheet exercise-filter-modal"
role="dialog"
aria-modal="true"
aria-labelledby="exercise-bulk-modal-title"
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 id="exercise-bulk-modal-title" className="admin-modal-sheet__title">
Massenänderung
</h3>
<button
type="button"
className="btn btn-secondary admin-modal-sheet__close"
onClick={() => setBulkModalOpen(false)}
>
Schließen
</button>
</div>
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
<p className="muted" style={{ marginTop: 0 }}>
Es werden <strong>{selectedIds.size}</strong> Ü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).
</p>
<p className="form-sub" style={{ marginTop: 0, marginBottom: '14px' }}>
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.
</p>
<div className="form-row">
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
value={bulkVisibility}
onChange={(e) => setBulkVisibility(e.target.value)}
>
{bulkVisibilityOptions.map((o) => (
<option key={o.id === '' ? '_unchanged' : o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
{bulkVisibility === 'club' ? (
<div className="form-row">
<label className="form-label">Verein zuordnen</label>
<select
className="form-input"
value={bulkClubSelect}
onChange={(e) => setBulkClubSelect(e.target.value)}
>
<option value="">Aktiver Verein (Vereins-Umschalter / Header)</option>
{activeClubMemberships(user?.clubs).map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || `#${c.id}`}
</option>
))}
</select>
{isPlatformAdmin ? (
<>
<label className="form-label" style={{ marginTop: '10px' }}>
Oder Vereins-ID (Plattform-Admin)
</label>
<input
type="number"
min={1}
className="form-input"
placeholder="Leer = wie Dropdown / aktiver Verein"
value={bulkClubManual}
onChange={(e) => setBulkClubManual(e.target.value)}
/>
</>
) : null}
</div>
) : null}
<div className="form-row">
<label className="form-label">Status</label>
<select
className="form-input"
value={bulkStatus}
onChange={(e) => setBulkStatus(e.target.value)}
>
<option value=""> nicht ändern </option>
{statusOptions.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
<section className="exercise-filter-section" style={{ marginTop: '8px', paddingTop: '12px' }}>
<h4 className="exercise-filter-section-title">Zuordnung (optional)</h4>
<div className="exercise-filters-modal-grid">
<div>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={bulkPatchFocusAreas}
onChange={(e) => {
const on = e.target.checked
setBulkPatchFocusAreas(on)
if (!on) setBulkFocusAreaIds([])
}}
/>
Fokusbereiche ersetzen
</label>
{bulkPatchFocusAreas ? (
<MultiSelectCombo
value={bulkFocusAreaIds}
onChange={setBulkFocusAreaIds}
options={focusOptions}
placeholder="Fokusbereiche wählen …"
/>
) : null}
</div>
<div>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={bulkPatchStyleDirections}
onChange={(e) => {
const on = e.target.checked
setBulkPatchStyleDirections(on)
if (!on) setBulkStyleDirectionIds([])
}}
/>
Stilrichtungen ersetzen
</label>
{bulkPatchStyleDirections ? (
<MultiSelectCombo
value={bulkStyleDirectionIds}
onChange={setBulkStyleDirectionIds}
options={styleOptions}
placeholder="Stilrichtungen wählen …"
/>
) : null}
</div>
<div>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={bulkPatchTrainingTypes}
onChange={(e) => {
const on = e.target.checked
setBulkPatchTrainingTypes(on)
if (!on) setBulkTrainingTypeIds([])
}}
/>
Trainingsstile ersetzen
</label>
{bulkPatchTrainingTypes ? (
<MultiSelectCombo
value={bulkTrainingTypeIds}
onChange={setBulkTrainingTypeIds}
options={trainingTypeOptions}
placeholder="Trainingsstile wählen …"
/>
) : null}
</div>
<div>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={bulkPatchTargetGroups}
onChange={(e) => {
const on = e.target.checked
setBulkPatchTargetGroups(on)
if (!on) setBulkTargetGroupIds([])
}}
/>
Zielgruppen ersetzen
</label>
{bulkPatchTargetGroups ? (
<MultiSelectCombo
value={bulkTargetGroupIds}
onChange={setBulkTargetGroupIds}
options={targetGroupOptions}
placeholder="Zielgruppen wählen …"
/>
) : null}
</div>
</div>
</section>
</div>
<div className="exercise-filter-modal__footer">
<button
type="button"
className="btn"
disabled={bulkSubmitting}
onClick={() => setBulkModalOpen(false)}
>
Abbrechen
</button>
<button type="button" className="btn btn-primary" disabled={bulkSubmitting} onClick={handleBulkSubmit}>
{bulkSubmitting ? 'Speichern…' : 'Anwenden'}
</button>
</div>
</div>
</div>
) : null}
<ExerciseListBulkModal
open={bulkModalOpen}
onClose={() => 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 ? (
<div className="card empty-state" style={{ padding: '2rem 1rem' }}>

View File

@ -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
}

View File

@ -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
}

View File

@ -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);