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
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:
parent
930a786315
commit
57a8957c93
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
240
frontend/src/components/exercises/ExerciseListBulkModal.jsx
Normal file
240
frontend/src/components/exercises/ExerciseListBulkModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
236
frontend/src/components/exercises/ExerciseListFilterModal.jsx
Normal file
236
frontend/src/components/exercises/ExerciseListFilterModal.jsx
Normal 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 (von–bis).
|
||||
</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>
|
||||
)
|
||||
}
|
||||
107
frontend/src/components/exercises/ExerciseListSearchBar.jsx
Normal file
107
frontend/src/components/exercises/ExerciseListSearchBar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (von–bis).
|
||||
</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' }}>
|
||||
|
|
|
|||
188
frontend/src/utils/exerciseListFilterChips.js
Normal file
188
frontend/src/utils/exerciseListFilterChips.js
Normal 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
|
||||
}
|
||||
83
frontend/src/utils/exerciseListQuery.js
Normal file
83
frontend/src/utils/exerciseListQuery.js
Normal 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
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user