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 ( +
+ + {hint ? ( +

+ {hint} +

+ ) : null} +
+ {(rules || []).map((r) => ( + + + {r.mode === 'forbid' ? '−' : '+'} + + {labelFor(r.id)} + + + ))} +
+
+ + + +
+
+ ) +} 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 (
{!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} - -

- 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. -

-
- - - -
- {(focusRules || []).length > 0 ? ( - - ) : 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 }))} /> -
-
- - setFilters((f) => ({ ...f, style_direction_ids: v }))} - options={styleOptions} - placeholder="Stilrichtung …" - /> -
-
- - setFilters((f) => ({ ...f, training_type_ids: v }))} - options={trainingTypeOptions} - placeholder="Trainingsstil …" - /> -
-
- - setFilters((f) => ({ ...f, target_group_ids: v }))} - options={targetGroupOptions} - placeholder="Zielgruppe …" - /> -
+
+ setFilters((f) => ({ ...f, ...patch }))} + /> + setFilters((f) => ({ ...f, ...patch }))} + /> + setFilters((f) => ({ ...f, ...patch }))} + />
@@ -387,46 +398,25 @@ export default function ExercisePickerModal({
-
-
- - setFilters((f) => ({ ...f, visibility_any: v }))} - options={visibilityOptions} - placeholder="Sichtbarkeit …" - /> -
-
- - setFilters((f) => ({ ...f, status_any: v }))} - options={statusOptions} - placeholder="Status …" - /> -
-
-

Ausblenden

-
-
- - setFilters((f) => ({ ...f, visibility_exclude_any: v }))} - options={visibilityOptions} - placeholder="ausblenden …" - /> -
-
- - setFilters((f) => ({ ...f, status_exclude_any: v }))} - options={statusOptions} - placeholder="ausblenden …" - /> -
+
+ setFilters((f) => ({ ...f, ...patch }))} + /> + setFilters((f) => ({ ...f, ...patch }))} + />