diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py
index 04bd1f6..8ef0bef 100644
--- a/backend/routers/exercises.py
+++ b/backend/routers/exercises.py
@@ -981,6 +981,30 @@ def list_exercises(
default=[],
description="Keiner dieser Fokusbereiche darf gesetzt sein („−“)",
),
+ style_direction_must_include_ids: list[int] = Query(
+ default=[],
+ description="Alle genannten Stilrichtungen müssen der Übung zugeordnet sein (UND)",
+ ),
+ style_direction_must_exclude_ids: list[int] = Query(
+ default=[],
+ description="Keine dieser Stilrichtungen darf zugeordnet sein",
+ ),
+ training_type_must_include_ids: list[int] = Query(
+ default=[],
+ description="Alle genannten Trainingsstile müssen zugeordnet sein (UND)",
+ ),
+ training_type_must_exclude_ids: list[int] = Query(
+ default=[],
+ description="Keiner dieser Trainingsstile darf zugeordnet sein",
+ ),
+ target_group_must_include_ids: list[int] = Query(
+ default=[],
+ description="Alle genannten Zielgruppen müssen zugeordnet sein (UND)",
+ ),
+ target_group_must_exclude_ids: list[int] = Query(
+ default=[],
+ description="Keine dieser Zielgruppen darf zugeordnet sein",
+ ),
include_archived: bool = Query(
default=False,
description="Archivierte einbeziehen; Standard false (außer Statusfilter enthält archived)",
@@ -1110,32 +1134,77 @@ def list_exercises(
)
params.extend(sk_ids)
- sd_ids = _merge_ids(style_direction_ids, style_direction_id)
- if sd_ids:
- ph = ",".join(["%s"] * len(sd_ids))
+ sd_or = _merge_ids(style_direction_ids, style_direction_id)
+ sd_inc = _dedupe_positive_ids(list(style_direction_must_include_ids))
+ sd_exc = _dedupe_positive_ids(list(style_direction_must_exclude_ids))
+ if sd_or:
+ ph = ",".join(["%s"] * len(sd_or))
where.append(
"EXISTS (SELECT 1 FROM exercise_style_directions esd "
f"WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))"
)
- params.extend(sd_ids)
+ params.extend(sd_or)
+ for sid in sd_inc:
+ where.append(
+ "EXISTS (SELECT 1 FROM exercise_style_directions esd "
+ "WHERE esd.exercise_id = e.id AND esd.style_direction_id = %s)"
+ )
+ params.append(sid)
+ if sd_exc:
+ ph = ",".join(["%s"] * len(sd_exc))
+ where.append(
+ "NOT EXISTS (SELECT 1 FROM exercise_style_directions esd "
+ f"WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))"
+ )
+ params.extend(sd_exc)
- tt_ids = _merge_ids(training_type_ids, training_type_id)
- if tt_ids:
- ph = ",".join(["%s"] * len(tt_ids))
+ tt_or = _merge_ids(training_type_ids, training_type_id)
+ tt_inc = _dedupe_positive_ids(list(training_type_must_include_ids))
+ tt_exc = _dedupe_positive_ids(list(training_type_must_exclude_ids))
+ if tt_or:
+ ph = ",".join(["%s"] * len(tt_or))
where.append(
"EXISTS (SELECT 1 FROM exercise_training_types ett "
f"WHERE ett.exercise_id = e.id AND ett.training_type_id IN ({ph}))"
)
- params.extend(tt_ids)
+ params.extend(tt_or)
+ for tid in tt_inc:
+ where.append(
+ "EXISTS (SELECT 1 FROM exercise_training_types ett "
+ "WHERE ett.exercise_id = e.id AND ett.training_type_id = %s)"
+ )
+ params.append(tid)
+ if tt_exc:
+ ph = ",".join(["%s"] * len(tt_exc))
+ where.append(
+ "NOT EXISTS (SELECT 1 FROM exercise_training_types ett "
+ f"WHERE ett.exercise_id = e.id AND ett.training_type_id IN ({ph}))"
+ )
+ params.extend(tt_exc)
- tg_ids = _merge_ids(target_group_ids, target_group_id)
- if tg_ids:
- ph = ",".join(["%s"] * len(tg_ids))
+ tg_or = _merge_ids(target_group_ids, target_group_id)
+ tg_inc = _dedupe_positive_ids(list(target_group_must_include_ids))
+ tg_exc = _dedupe_positive_ids(list(target_group_must_exclude_ids))
+ if tg_or:
+ ph = ",".join(["%s"] * len(tg_or))
where.append(
"EXISTS (SELECT 1 FROM exercise_target_groups etg "
f"WHERE etg.exercise_id = e.id AND etg.target_group_id IN ({ph}))"
)
- params.extend(tg_ids)
+ params.extend(tg_or)
+ for gid in tg_inc:
+ where.append(
+ "EXISTS (SELECT 1 FROM exercise_target_groups etg "
+ "WHERE etg.exercise_id = e.id AND etg.target_group_id = %s)"
+ )
+ params.append(gid)
+ if tg_exc:
+ ph = ",".join(["%s"] * len(tg_exc))
+ where.append(
+ "NOT EXISTS (SELECT 1 FROM exercise_target_groups etg "
+ f"WHERE etg.exercise_id = e.id AND etg.target_group_id IN ({ph}))"
+ )
+ params.extend(tg_exc)
if skill_min_level is not None or skill_max_level is not None:
lo = skill_min_level if skill_min_level is not None else 1
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 058430f..b9f372a 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -2732,6 +2732,10 @@ a.analysis-split__nav-item {
.exercise-filters-modal-grid--two {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
+.exercise-filters-modal-grid--catalog {
+ grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
+ gap: 10px 12px;
+}
.exercise-filter-chips-row {
display: flex;
flex-wrap: wrap;
@@ -2823,6 +2827,93 @@ a.analysis-split__nav-item {
font-weight: 600;
}
+/* Übungsfilter: kompakte +/- Katalogregeln */
+.catalog-rule-picker {
+ margin-bottom: 10px;
+}
+.catalog-rule-picker--disabled {
+ opacity: 0.55;
+ pointer-events: none;
+}
+.catalog-rule-picker__label {
+ margin-bottom: 0;
+ font-size: 13px;
+}
+.catalog-rule-picker__chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+ min-height: 0;
+ margin-bottom: 6px;
+}
+.catalog-rule-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ max-width: 100%;
+ padding: 2px 6px 2px 8px;
+ border-radius: 999px;
+ border: 1px solid var(--border);
+ background: var(--surface2);
+ font-size: 12px;
+ line-height: 1.35;
+}
+.catalog-rule-chip__sign {
+ font-weight: 700;
+ font-size: 12px;
+ flex-shrink: 0;
+ opacity: 0.85;
+}
+.catalog-rule-chip__sign--require {
+ color: var(--accent-dark);
+}
+.catalog-rule-chip__sign--forbid {
+ color: var(--danger);
+}
+.catalog-rule-chip__text {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 180px;
+}
+.catalog-rule-chip__x {
+ flex-shrink: 0;
+ margin: 0;
+ padding: 0 4px;
+ border: none;
+ background: transparent;
+ color: var(--text2);
+ font-size: 15px;
+ line-height: 1;
+ cursor: pointer;
+ border-radius: 4px;
+}
+.catalog-rule-chip__x:hover {
+ color: var(--text1);
+ background: var(--border);
+}
+.catalog-rule-picker__row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+}
+.catalog-rule-picker__select {
+ flex: 0 1 132px;
+ max-width: 160px;
+ min-width: 96px;
+ padding: 6px 8px;
+ font-size: 13px;
+}
+.catalog-rule-picker__sign-btn {
+ min-width: 32px;
+ padding-left: 10px;
+ padding-right: 10px;
+ font-weight: 700;
+ font-size: 14px;
+}
+
/* Reifegradmodell-Admin: klare Schritte, responsives Raster */
.admin-matrix-alert {
border: 1px solid var(--danger);
diff --git a/frontend/src/components/CatalogRulePicker.jsx b/frontend/src/components/CatalogRulePicker.jsx
new file mode 100644
index 0000000..4a8d40a
--- /dev/null
+++ b/frontend/src/components/CatalogRulePicker.jsx
@@ -0,0 +1,109 @@
+import React, { useState } from 'react'
+import { newCatalogRuleKey } from '../constants/exerciseListFilters'
+
+/**
+ * Kompakte +/- Regeln für Katalogwerte (numerische IDs oder Slugs).
+ * Chips oben, schmales Dropdown, Schalter nur „+“ und „−“.
+ */
+export default function CatalogRulePicker({
+ label,
+ hint,
+ options = [],
+ rules = [],
+ rulesFieldName,
+ disabled = false,
+ placeholder = 'Auswählen …',
+ idKind = 'numeric',
+ onPatch,
+}) {
+ const [pendingId, setPendingId] = useState('')
+
+ const labelFor = (id) => options.find((o) => String(o.id) === String(id))?.label ?? id
+
+ const addRule = (mode) => {
+ const raw = String(pendingId || '').trim()
+ if (!raw || disabled) return
+ if (idKind === 'numeric') {
+ const n = Number(raw)
+ if (!Number.isFinite(n) || n < 1) return
+ }
+ const dup = (rules || []).some((r) => String(r.id) === raw && r.mode === mode)
+ if (dup) return
+ onPatch({
+ [rulesFieldName]: [
+ ...(rules || []),
+ { key: newCatalogRuleKey(rulesFieldName), id: raw, mode },
+ ],
+ })
+ setPendingId('')
+ }
+
+ const removeRule = (key) => {
+ onPatch({
+ [rulesFieldName]: (rules || []).filter((r) => r.key !== key),
+ })
+ }
+
+ return (
+
+
{label}
+ {hint ? (
+
+ {hint}
+
+ ) : null}
+
+ {(rules || []).map((r) => (
+
+
+ {r.mode === 'forbid' ? '−' : '+'}
+
+ {labelFor(r.id)}
+ removeRule(r.key)}
+ >
+ ×
+
+
+ ))}
+
+
+ setPendingId(e.target.value)}
+ aria-label={label}
+ >
+ {placeholder}
+ {options.map((o) => (
+
+ {o.label || o.id}
+
+ ))}
+
+ addRule('require')}
+ >
+ +
+
+ addRule('forbid')}
+ >
+ −
+
+
+
+ )
+}
diff --git a/frontend/src/components/ExerciseFocusRulePicker.jsx b/frontend/src/components/ExerciseFocusRulePicker.jsx
index 8146f55..a96e556 100644
--- a/frontend/src/components/ExerciseFocusRulePicker.jsx
+++ b/frontend/src/components/ExerciseFocusRulePicker.jsx
@@ -1,46 +1,18 @@
-import React, { useMemo, useState } from 'react'
-
-function newRuleKey() {
- if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
- return `fr-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
-}
+import React from 'react'
+import CatalogRulePicker from './CatalogRulePicker'
/**
- * Fokusbereiche mit „+“ (muss haben) und „−“ (darf nicht haben); optional nur Übungen ohne Fokus-Zuordnung.
+ * Fokusbereiche inkl. „nur ohne Zuordnung“; Regeln über CatalogRulePicker (+/−).
*/
export default function ExerciseFocusRulePicker({
focusOptions,
focusRules,
focusOnlyWithout,
- excludeWithoutFocus,
legacyFocusAreaIds = [],
onPatch,
}) {
- const [pendingId, setPendingId] = useState('')
-
- const legacyWarning = useMemo(
- () => Array.isArray(legacyFocusAreaIds) && legacyFocusAreaIds.length > 0,
- [legacyFocusAreaIds]
- )
-
- const addRule = (mode) => {
- const id = String(pendingId || '').trim()
- if (!id || focusOnlyWithout) return
- const dup = (focusRules || []).some(
- (r) => String(r.focus_area_id) === id && r.mode === mode
- )
- if (dup) return
- onPatch({
- focus_rules: [...(focusRules || []), { key: newRuleKey(), focus_area_id: id, mode }],
- })
- setPendingId('')
- }
-
- const removeRule = (key) => {
- onPatch({
- focus_rules: (focusRules || []).filter((r) => r.key !== key),
- })
- }
+ const legacyWarning =
+ Array.isArray(legacyFocusAreaIds) && legacyFocusAreaIds.length > 0 && !focusOnlyWithout
const setFocusOnly = (on) => {
if (on) {
@@ -50,7 +22,6 @@ export default function ExerciseFocusRulePicker({
focus_rules: [],
focus_area_ids: [],
})
- setPendingId('')
return
}
onPatch({ focus_only_without: false })
@@ -59,85 +30,33 @@ export default function ExerciseFocusRulePicker({
return (
- setFocusOnly(e.target.checked)}
- />
- Nur Übungen ohne Fokusbereich (keine Zuordnung)
+ setFocusOnly(e.target.checked)} />
+
+ Nur Übungen ohne Fokusbereich (keine Zuordnung)
+
{!focusOnlyWithout ? (
<>
{legacyWarning ? (
-
- Es sind noch ältere Fokusfilter (ODER-Liste) aktiv — bitte über die Chips entfernen oder durch Regeln
- ersetzen.
+
+ Ältere ODER-Fokusliste aktiv — über die Chips auf der Übersicht entfernen.
) : null}
-
Fokusbereiche (+ mit / − ohne)
-
- Mehrere „+“: alle müssen gesetzt sein (UND). Mehrere „−“: keiner davon darf gesetzt sein.
- Kombination z. B. „+ Karate“ und „− Fitness“ = hat Karate, aber nicht zusätzlich Fitness.
-
-
- setPendingId(e.target.value)}
- aria-label="Fokusbereich wählen"
- >
- Fokusbereich wählen …
- {focusOptions.map((o) => (
-
- {o.label || o.id}
-
- ))}
-
- addRule('require')}>
- + Mit
-
- addRule('forbid')}>
- − Ohne
-
-
- {(focusRules || []).length > 0 ? (
-
- {(focusRules || []).map((r) => {
- const opt = focusOptions.find((o) => String(o.id) === String(r.focus_area_id))
- const name = opt?.label || r.focus_area_id
- return (
-
-
- {r.mode === 'forbid' ? '−' : '+'} {name}
-
- removeRule(r.key)}>
- Entfernen
-
-
- )
- })}
-
- ) : null}
+
>
) : (
-
- Solange diese Option aktiv ist, sind Fokus-Regeln und die ODER-Fokusliste deaktiviert.
+
+ Fokus-Regeln sind deaktiviert.
)}
diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx
index 1f36dd4..658dc2f 100644
--- a/frontend/src/components/ExercisePickerModal.jsx
+++ b/frontend/src/components/ExercisePickerModal.jsx
@@ -9,9 +9,12 @@ import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import {
INITIAL_EXERCISE_LIST_FILTERS,
mergeExerciseListPrefsFromApi,
+ splitMnCatalogRules,
+ splitScalarCatalogRules,
} from '../constants/exerciseListFilters'
import MultiSelectCombo from './MultiSelectCombo'
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
+import CatalogRulePicker from './CatalogRulePicker'
const PAGE_SIZE = 100
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
@@ -155,36 +158,44 @@ export default function ExercisePickerModal({
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 mustInc = []
- const mustExc = []
- for (const r of filters.focus_rules || []) {
- const id = Number(r.focus_area_id)
- if (!Number.isFinite(id) || id < 1) continue
- if (r.mode === 'forbid') mustExc.push(id)
- else mustInc.push(id)
- }
- const uniqNums = (arr) => [...new Set(arr)]
- if (mustInc.length) q.focus_area_must_include_ids = uniqNums(mustInc)
- if (mustExc.length) q.focus_area_must_exclude_ids = uniqNums(mustExc)
+ 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 sd = ids(filters.style_direction_ids)
- if (sd?.length) q.style_direction_ids = sd
- const tt = ids(filters.training_type_ids)
- if (tt?.length) q.training_type_ids = tt
- const tg = ids(filters.target_group_ids)
- if (tg?.length) q.target_group_ids = tg
+
+ 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.visibility_any?.length) q.visibility_any = [...filters.visibility_any]
- if (filters.status_any?.length) q.status_any = [...filters.status_any]
- if (filters.visibility_exclude_any?.length)
- q.visibility_exclude_any = [...filters.visibility_exclude_any]
- if (filters.status_exclude_any?.length) q.status_exclude_any = [...filters.status_exclude_any]
if (filters.exclude_without_focus) q.exclude_without_focus = true
if (filters.include_archived) q.include_archived = true
if (debouncedSearch) q.search = debouncedSearch
@@ -311,8 +322,8 @@ export default function ExercisePickerModal({
{filterOpen && (
- Zwischen den Bereichen gilt UND . Fokus: „+ mit“ / „− ohne“ kombinierbar (mehrere „+“ =
- alle gesetzt). Sonstige Mehrfachauswahl pro Feld mit ODER .
+ Felder gelten mit UND . Kataloge: mehrere „+“ = alle zutreffend; „−“ schließt aus.
+ Sichtbarkeit/Status: mehrere „+“ = eine davon (ODER); „−“ blendet aus.
setFilters((f) => ({ ...f, ...patch }))}
/>
-
-
- Stilrichtung
- setFilters((f) => ({ ...f, style_direction_ids: v }))}
- options={styleOptions}
- placeholder="Stilrichtung …"
- />
-
-
- Trainingsstil
- setFilters((f) => ({ ...f, training_type_ids: v }))}
- options={trainingTypeOptions}
- placeholder="Trainingsstil …"
- />
-
-
- Zielgruppe
- setFilters((f) => ({ ...f, target_group_ids: v }))}
- options={targetGroupOptions}
- placeholder="Zielgruppe …"
- />
-
+
+ setFilters((f) => ({ ...f, ...patch }))}
+ />
+ setFilters((f) => ({ ...f, ...patch }))}
+ />
+ setFilters((f) => ({ ...f, ...patch }))}
+ />
Fähigkeit
@@ -387,46 +398,25 @@ export default function ExercisePickerModal({
-
-
- Sichtbarkeit
- setFilters((f) => ({ ...f, visibility_any: v }))}
- options={visibilityOptions}
- placeholder="Sichtbarkeit …"
- />
-
-
- Status
- setFilters((f) => ({ ...f, status_any: v }))}
- options={statusOptions}
- placeholder="Status …"
- />
-
-
- Ausblenden
-
-
- Sichtbarkeit nicht
- setFilters((f) => ({ ...f, visibility_exclude_any: v }))}
- options={visibilityOptions}
- placeholder="ausblenden …"
- />
-
-
- Status nicht
- setFilters((f) => ({ ...f, status_exclude_any: v }))}
- options={statusOptions}
- placeholder="ausblenden …"
- />
-
+
+ setFilters((f) => ({ ...f, ...patch }))}
+ />
+ setFilters((f) => ({ ...f, ...patch }))}
+ />
normalizeCatalogRule(r, i, key)).filter(Boolean)
+ }
+
+ if (raw.focus_only_without !== undefined) out.focus_only_without = !!raw.focus_only_without
+
+ if (!out.visibility_rules.length) {
+ const vr = []
+ ;(raw.visibility_any || []).forEach((id, i) => {
+ const n = normalizeCatalogRule({ id, mode: 'require', key: `lv-${i}` }, i, 'visibility_rules')
+ if (n) vr.push(n)
+ })
+ ;(raw.visibility_exclude_any || []).forEach((id, i) => {
+ const n = normalizeCatalogRule({ id, mode: 'forbid', key: `lve-${i}` }, i, 'visibility_rules')
+ if (n) vr.push(n)
+ })
+ if (vr.length) out.visibility_rules = vr
+ }
+
+ if (!out.status_rules.length) {
+ const sr = []
+ ;(raw.status_any || []).forEach((id, i) => {
+ const n = normalizeCatalogRule({ id, mode: 'require', key: `ls-${i}` }, i, 'status_rules')
+ if (n) sr.push(n)
+ })
+ ;(raw.status_exclude_any || []).forEach((id, i) => {
+ const n = normalizeCatalogRule({ id, mode: 'forbid', key: `lse-${i}` }, i, 'status_rules')
+ if (n) sr.push(n)
+ })
+ if (sr.length) out.status_rules = sr
+ }
+
for (const k of PREFS_KEYS) {
+ if (CATALOG_RULE_FIELD_KEYS.includes(k)) continue
+ if (k === 'focus_only_without') continue
if (raw[k] === undefined) continue
- if (k === 'focus_rules' && Array.isArray(raw[k])) {
- out.focus_rules = raw[k]
- .filter((r) => r && r.focus_area_id != null && (r.mode === 'require' || r.mode === 'forbid'))
- .map((r, i) => ({
- key: r.key || `imp-${i}-${r.focus_area_id}-${r.mode}`,
- focus_area_id: String(r.focus_area_id),
- mode: r.mode,
- }))
- continue
- }
- if (k === 'focus_only_without') {
- out.focus_only_without = !!raw[k]
+ if (
+ k === 'visibility_any' ||
+ k === 'visibility_exclude_any' ||
+ k === 'status_any' ||
+ k === 'status_exclude_any'
+ ) {
continue
}
if (k.endsWith('_ids') || k.endsWith('_any')) {
diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx
index 7acac57..5bc40ca 100644
--- a/frontend/src/pages/ExercisesListPage.jsx
+++ b/frontend/src/pages/ExercisesListPage.jsx
@@ -17,12 +17,15 @@ import { useAuth } from '../context/AuthContext'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import MultiSelectCombo from '../components/MultiSelectCombo'
import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker'
+import CatalogRulePicker from '../components/CatalogRulePicker'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
import PageSectionNav from '../components/PageSectionNav'
import {
INITIAL_EXERCISE_LIST_FILTERS,
mergeExerciseListPrefsFromApi,
compactExerciseListPrefsPayload,
+ splitMnCatalogRules,
+ splitScalarCatalogRules,
} from '../constants/exerciseListFilters'
import { sanitizeExerciseRichText, coerceApiNameList } from '../utils/sanitizeHtml'
import { canUserRequestExerciseDelete } from '../utils/exercisePermissions'
@@ -51,6 +54,22 @@ function statusLabel(s) {
return STATUS_LABELS[s] || s || '—'
}
+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 exerciseFocusNames(ex) {
const fromApi = coerceApiNameList(ex.focus_area_names)
if (fromApi.length) return fromApi
@@ -222,19 +241,7 @@ function ExercisesListPage() {
const filterChips = useMemo(() => {
const chips = []
- ;(filters.focus_rules || []).forEach((r) => {
- const opt = focusOptions.find((o) => String(o.id) === String(r.focus_area_id))
- const verb = r.mode === 'forbid' ? 'Fokus ohne' : 'Fokus mit'
- chips.push({
- key: `fr-${r.key}`,
- label: `${verb}: ${opt?.label ?? r.focus_area_id}`,
- onRemove: () =>
- setFilters((prev) => ({
- ...prev,
- focus_rules: prev.focus_rules.filter((x) => x.key !== r.key),
- })),
- })
- })
+ pushCatalogRuleFilterChips(chips, 'focus_rules', filters.focus_rules, focusOptions, 'Fokus', setFilters)
if (filters.focus_only_without) {
chips.push({
@@ -256,11 +263,37 @@ function ExercisesListPage() {
})),
})
})
+
+ 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: ${opt?.label ?? id}`,
+ label: `Stil (ODER, älter): ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
@@ -272,7 +305,7 @@ function ExercisesListPage() {
const opt = trainingTypeOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `tt-${id}`,
- label: `Trainingsstil: ${opt?.label ?? id}`,
+ label: `Trainingsstil (ODER, älter): ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
@@ -284,7 +317,7 @@ function ExercisesListPage() {
const opt = targetGroupOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `tg-${id}`,
- label: `Zielgruppe: ${opt?.label ?? id}`,
+ label: `Zielgruppe (ODER, älter): ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
@@ -292,6 +325,7 @@ function ExercisesListPage() {
})),
})
})
+
;(filters.skill_ids || []).forEach((id) => {
const opt = skillOptions.find((o) => String(o.id) === String(id))
chips.push({
@@ -320,55 +354,15 @@ function ExercisesListPage() {
})
}
- ;(filters.visibility_any || []).forEach((id) => {
- const opt = visibilityOptions.find((o) => String(o.id) === String(id))
- chips.push({
- key: `vis-${id}`,
- label: `Sichtbarkeit: ${opt?.label ?? id}`,
- onRemove: () =>
- setFilters((prev) => ({
- ...prev,
- visibility_any: prev.visibility_any.filter((x) => String(x) !== String(id)),
- })),
- })
- })
- ;(filters.status_any || []).forEach((id) => {
- const opt = statusOptions.find((o) => String(o.id) === String(id))
- chips.push({
- key: `st-${id}`,
- label: `Status: ${opt?.label ?? id}`,
- onRemove: () =>
- setFilters((prev) => ({
- ...prev,
- status_any: prev.status_any.filter((x) => String(x) !== String(id)),
- })),
- })
- })
-
- ;(filters.visibility_exclude_any || []).forEach((id) => {
- const opt = visibilityOptions.find((o) => String(o.id) === String(id))
- chips.push({
- key: `vex-${id}`,
- label: `Sichtbarkeit ausblenden: ${opt?.label ?? id}`,
- onRemove: () =>
- setFilters((prev) => ({
- ...prev,
- visibility_exclude_any: prev.visibility_exclude_any.filter((x) => String(x) !== String(id)),
- })),
- })
- })
- ;(filters.status_exclude_any || []).forEach((id) => {
- const opt = statusOptions.find((o) => String(o.id) === String(id))
- chips.push({
- key: `sex-${id}`,
- label: `Status ausblenden: ${opt?.label ?? id}`,
- onRemove: () =>
- setFilters((prev) => ({
- ...prev,
- status_exclude_any: prev.status_exclude_any.filter((x) => String(x) !== String(id)),
- })),
- })
- })
+ 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({
@@ -395,6 +389,7 @@ function ExercisesListPage() {
skillOptions,
visibilityOptions,
statusOptions,
+ setFilters,
])
/** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */
@@ -408,36 +403,44 @@ function ExercisesListPage() {
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 mustInc = []
- const mustExc = []
- for (const r of filters.focus_rules || []) {
- const id = Number(r.focus_area_id)
- if (!Number.isFinite(id) || id < 1) continue
- if (r.mode === 'forbid') mustExc.push(id)
- else mustInc.push(id)
- }
- const uniqNums = (arr) => [...new Set(arr)]
- if (mustInc.length) q.focus_area_must_include_ids = uniqNums(mustInc)
- if (mustExc.length) q.focus_area_must_exclude_ids = uniqNums(mustExc)
+ 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 sd = ids(filters.style_direction_ids)
- if (sd?.length) q.style_direction_ids = sd
- const tt = ids(filters.training_type_ids)
- if (tt?.length) q.training_type_ids = tt
- const tg = ids(filters.target_group_ids)
- if (tg?.length) q.target_group_ids = tg
+
+ 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.visibility_any?.length) q.visibility_any = [...filters.visibility_any]
- if (filters.status_any?.length) q.status_any = [...filters.status_any]
- if (filters.visibility_exclude_any?.length)
- q.visibility_exclude_any = [...filters.visibility_exclude_any]
- if (filters.status_exclude_any?.length) q.status_exclude_any = [...filters.status_exclude_any]
if (filters.exclude_without_focus) q.exclude_without_focus = true
if (filters.include_archived) q.include_archived = true
if (debouncedSearch) q.search = debouncedSearch
@@ -896,7 +899,8 @@ function ExercisesListPage() {
Zwischen den Bereichen gilt UND . Fokusbereiche: mehrere „+ mit“ bedeuten alle müssen
gesetzt sein; „− ohne“ schließt Übungen aus, die diesen Fokus zusätzlich haben. Stilrichtung /
- Trainingsstil / Zielgruppe: mehrere Werte pro Feld mit ODER .
+ Trainingsstil / Zielgruppe: mehrere „+“ = alle zutreffend (UND); „−“ verbietet die Zuordnung. Unter
+ „Freigabe“: Sichtbarkeit / Status mit „+“ = eine davon (ODER); „−“ blendet aus.
@@ -908,34 +912,34 @@ function ExercisesListPage() {
legacyFocusAreaIds={filters.focus_area_ids}
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
/>
-
-
- Stilrichtung
- setFilters({ ...filters, style_direction_ids: v })}
- options={styleOptions}
- placeholder="Stilrichtung suchen …"
- />
-
-
- Trainingsstil
- setFilters({ ...filters, training_type_ids: v })}
- options={trainingTypeOptions}
- placeholder="Trainingsstil suchen …"
- />
-
-
- Zielgruppe
- setFilters({ ...filters, target_group_ids: v })}
- options={targetGroupOptions}
- placeholder="Zielgruppe suchen …"
- />
-
+
+ setFilters((prev) => ({ ...prev, ...patch }))}
+ />
+ setFilters((prev) => ({ ...prev, ...patch }))}
+ />
+ setFilters((prev) => ({ ...prev, ...patch }))}
+ />
@@ -993,31 +997,11 @@ function ExercisesListPage() {
- Ausblenden
+ Ausblenden / Liste
- Negativlisten schließen Treffer aus (weitere Felder weiterhin mit UND verknüpft).
+ Sichtbarkeit und Status steuern Sie unter „Freigabe“ mit + und −. Hier nur globale Listen-Optionen.
-
-
- Sichtbarkeit nicht anzeigen
- setFilters({ ...filters, visibility_exclude_any: v })}
- options={visibilityOptions}
- placeholder="z. B. Global ausblenden …"
- />
-
-
- Status nicht anzeigen
- setFilters({ ...filters, status_exclude_any: v })}
- options={statusOptions}
- placeholder="z. B. Entwurf ausblenden …"
- />
-
-
-
+
Freigabe
-
-
- Sichtbarkeit
- setFilters({ ...filters, visibility_any: v })}
- options={visibilityOptions}
- placeholder="Sichtbarkeit wählen …"
- />
-
-
- Status
- setFilters({ ...filters, status_any: v })}
- options={statusOptions}
- placeholder="Status wählen …"
- />
-
+
+ Pro Übung nur ein Wert: mehrere „+“ bedeuten „eine davon“ (ODER). „−“ blendet Werte aus.
+
+
+ setFilters((prev) => ({ ...prev, ...patch }))}
+ />
+ setFilters((prev) => ({ ...prev, ...patch }))}
+ />