feat: enhance exercise filtering capabilities with new catalog rules
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 7s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 28s
Test Suite / pytest-backend (pull_request) Successful in 5s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 7s
Test Suite / playwright-tests (pull_request) Failing after 28s
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 7s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 28s
Test Suite / pytest-backend (pull_request) Successful in 5s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 7s
Test Suite / playwright-tests (pull_request) Failing after 28s
- Introduced new filtering options for style directions, training types, and target groups in the exercise list. - Implemented catalog rule picker components to manage inclusion and exclusion of exercise attributes. - Updated utility functions to handle new catalog rules for improved filtering logic. - Enhanced the ExercisesListPage and ExercisePickerModal to support the new filtering features, improving user experience.
This commit is contained in:
parent
518918a6e5
commit
b9d27b59b0
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
109
frontend/src/components/CatalogRulePicker.jsx
Normal file
109
frontend/src/components/CatalogRulePicker.jsx
Normal file
|
|
@ -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 (
|
||||
<div className={`catalog-rule-picker${disabled ? ' catalog-rule-picker--disabled' : ''}`}>
|
||||
<label className="form-label catalog-rule-picker__label">{label}</label>
|
||||
{hint ? (
|
||||
<p className="muted catalog-rule-picker__hint" style={{ fontSize: '11px', marginTop: '2px', marginBottom: '6px' }}>
|
||||
{hint}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="catalog-rule-picker__chips" aria-live="polite">
|
||||
{(rules || []).map((r) => (
|
||||
<span key={r.key} className="catalog-rule-chip">
|
||||
<span className={`catalog-rule-chip__sign catalog-rule-chip__sign--${r.mode}`}>
|
||||
{r.mode === 'forbid' ? '−' : '+'}
|
||||
</span>
|
||||
<span className="catalog-rule-chip__text">{labelFor(r.id)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="catalog-rule-chip__x"
|
||||
aria-label={`${label}: Regel entfernen`}
|
||||
onClick={() => removeRule(r.key)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="catalog-rule-picker__row">
|
||||
<select
|
||||
className="form-input catalog-rule-picker__select"
|
||||
value={pendingId}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setPendingId(e.target.value)}
|
||||
aria-label={label}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{options.map((o) => (
|
||||
<option key={o.id} value={String(o.id)}>
|
||||
{o.label || o.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-small catalog-rule-picker__sign-btn"
|
||||
disabled={disabled || !pendingId}
|
||||
title="Muss zutreffen"
|
||||
onClick={() => addRule('require')}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-small catalog-rule-picker__sign-btn"
|
||||
disabled={disabled || !pendingId}
|
||||
title="Darf nicht zutreffen"
|
||||
onClick={() => addRule('forbid')}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="exercise-focus-rule-picker">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', marginBottom: '10px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!focusOnlyWithout}
|
||||
onChange={(e) => setFocusOnly(e.target.checked)}
|
||||
/>
|
||||
<span>Nur Übungen <strong>ohne</strong> Fokusbereich (keine Zuordnung)</span>
|
||||
<input type="checkbox" checked={!!focusOnlyWithout} onChange={(e) => setFocusOnly(e.target.checked)} />
|
||||
<span>
|
||||
Nur Übungen <strong>ohne</strong> Fokusbereich (keine Zuordnung)
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{!focusOnlyWithout ? (
|
||||
<>
|
||||
{legacyWarning ? (
|
||||
<p className="muted" style={{ fontSize: '13px', marginTop: 0, marginBottom: '10px' }}>
|
||||
Es sind noch ältere Fokusfilter (ODER-Liste) aktiv — bitte über die Chips entfernen oder durch Regeln
|
||||
ersetzen.
|
||||
<p className="muted" style={{ fontSize: '12px', marginTop: 0, marginBottom: '8px' }}>
|
||||
Ältere ODER-Fokusliste aktiv — über die Chips auf der Übersicht entfernen.
|
||||
</p>
|
||||
) : null}
|
||||
<label className="form-label">Fokusbereiche (+ mit / − ohne)</label>
|
||||
<p className="muted" style={{ fontSize: '12px', marginTop: 0, marginBottom: '8px' }}>
|
||||
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.
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', marginBottom: '10px' }}>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ minWidth: '200px', flex: '1 1 180px' }}
|
||||
value={pendingId}
|
||||
onChange={(e) => setPendingId(e.target.value)}
|
||||
aria-label="Fokusbereich wählen"
|
||||
>
|
||||
<option value="">Fokusbereich wählen …</option>
|
||||
{focusOptions.map((o) => (
|
||||
<option key={o.id} value={String(o.id)}>
|
||||
{o.label || o.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="btn btn-secondary btn-small" disabled={!pendingId} onClick={() => addRule('require')}>
|
||||
+ Mit
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary btn-small" disabled={!pendingId} onClick={() => addRule('forbid')}>
|
||||
− Ohne
|
||||
</button>
|
||||
</div>
|
||||
{(focusRules || []).length > 0 ? (
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 12px 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 (
|
||||
<li
|
||||
key={r.key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
padding: '6px 10px',
|
||||
marginBottom: '6px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--surface2)',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<strong>{r.mode === 'forbid' ? '−' : '+'}</strong> {name}
|
||||
</span>
|
||||
<button type="button" className="btn btn-secondary btn-small" onClick={() => removeRule(r.key)}>
|
||||
Entfernen
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
<CatalogRulePicker
|
||||
label="Fokusbereiche"
|
||||
hint="+ alle erforderlich (UND). − keine dieser Zuordnungen."
|
||||
options={focusOptions}
|
||||
rules={focusRules}
|
||||
rulesFieldName="focus_rules"
|
||||
idKind="numeric"
|
||||
placeholder="Fokus …"
|
||||
onPatch={onPatch}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p className="muted" style={{ fontSize: '13px', marginTop: 0 }}>
|
||||
Solange diese Option aktiv ist, sind Fokus-Regeln und die ODER-Fokusliste deaktiviert.
|
||||
<p className="muted" style={{ fontSize: '12px', marginTop: 0 }}>
|
||||
Fokus-Regeln sind deaktiviert.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<div style={{ marginTop: '0.35rem', fontSize: '13px', color: 'var(--text2)' }}>
|
||||
<p style={{ margin: '0 0 12px 0' }}>
|
||||
Zwischen den Bereichen gilt <strong>UND</strong>. Fokus: „+ mit“ / „− ohne“ kombinierbar (mehrere „+“ =
|
||||
alle gesetzt). Sonstige Mehrfachauswahl pro Feld mit <strong>ODER</strong>.
|
||||
Felder gelten mit <strong>UND</strong>. Kataloge: mehrere „+“ = alle zutreffend; „−“ schließt aus.
|
||||
Sichtbarkeit/Status: mehrere „+“ = eine davon (ODER); „−“ blendet aus.
|
||||
</p>
|
||||
<ExerciseFocusRulePicker
|
||||
focusOptions={focusOptions}
|
||||
|
|
@ -321,34 +332,34 @@ export default function ExercisePickerModal({
|
|||
legacyFocusAreaIds={filters.focus_area_ids}
|
||||
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||||
/>
|
||||
<div className="exercise-filters-modal-grid" style={{ marginTop: 12 }}>
|
||||
<div>
|
||||
<label className="form-label">Stilrichtung</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.style_direction_ids}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, style_direction_ids: v }))}
|
||||
options={styleOptions}
|
||||
placeholder="Stilrichtung …"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Trainingsstil</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.training_type_ids}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, training_type_ids: v }))}
|
||||
options={trainingTypeOptions}
|
||||
placeholder="Trainingsstil …"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Zielgruppe</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.target_group_ids}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, target_group_ids: v }))}
|
||||
options={targetGroupOptions}
|
||||
placeholder="Zielgruppe …"
|
||||
/>
|
||||
</div>
|
||||
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--catalog" style={{ marginTop: 12 }}>
|
||||
<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((f) => ({ ...f, ...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((f) => ({ ...f, ...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((f) => ({ ...f, ...patch }))}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<label className="form-label">Fähigkeit</label>
|
||||
|
|
@ -387,46 +398,25 @@ export default function ExercisePickerModal({
|
|||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two" style={{ marginTop: 12 }}>
|
||||
<div>
|
||||
<label className="form-label">Sichtbarkeit</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.visibility_any}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, visibility_any: v }))}
|
||||
options={visibilityOptions}
|
||||
placeholder="Sichtbarkeit …"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Status</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.status_any}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, status_any: v }))}
|
||||
options={statusOptions}
|
||||
placeholder="Status …"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ margin: '12px 0 8px', fontWeight: 600, fontSize: '13px' }}>Ausblenden</p>
|
||||
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two">
|
||||
<div>
|
||||
<label className="form-label">Sichtbarkeit nicht</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.visibility_exclude_any}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, visibility_exclude_any: v }))}
|
||||
options={visibilityOptions}
|
||||
placeholder="ausblenden …"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Status nicht</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.status_exclude_any}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, status_exclude_any: v }))}
|
||||
options={statusOptions}
|
||||
placeholder="ausblenden …"
|
||||
/>
|
||||
</div>
|
||||
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two exercise-filters-modal-grid--catalog" style={{ marginTop: 12 }}>
|
||||
<CatalogRulePicker
|
||||
label="Sichtbarkeit"
|
||||
options={visibilityOptions}
|
||||
rules={filters.visibility_rules}
|
||||
rulesFieldName="visibility_rules"
|
||||
idKind="string"
|
||||
placeholder="Sichtbarkeit …"
|
||||
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||||
/>
|
||||
<CatalogRulePicker
|
||||
label="Status"
|
||||
options={statusOptions}
|
||||
rules={filters.status_rules}
|
||||
rulesFieldName="status_rules"
|
||||
idKind="string"
|
||||
placeholder="Status …"
|
||||
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<label
|
||||
|
|
|
|||
|
|
@ -1,47 +1,137 @@
|
|||
/** Gemeinsame Default-Filter für Übungslisten (Übersicht + Auswahlmodal). */
|
||||
export const INITIAL_EXERCISE_LIST_FILTERS = {
|
||||
/** Legacy: ODER über mehrere Fokus-IDs (wird nur noch aus gespeicherten Presets oder Chips genutzt). */
|
||||
focus_area_ids: [],
|
||||
/** Regeln: mode require = muss Fokus haben, forbid = darf diesen Fokus nicht haben (UND über alle require). */
|
||||
focus_rules: [],
|
||||
/** Nur Übungen ohne einen Eintrag in exercise_focus_areas. */
|
||||
focus_only_without: false,
|
||||
style_direction_ids: [],
|
||||
style_direction_rules: [],
|
||||
training_type_ids: [],
|
||||
training_type_rules: [],
|
||||
target_group_ids: [],
|
||||
target_group_rules: [],
|
||||
skill_ids: [],
|
||||
skill_min_level: '',
|
||||
skill_max_level: '',
|
||||
visibility_any: [],
|
||||
status_any: [],
|
||||
visibility_exclude_any: [],
|
||||
visibility_rules: [],
|
||||
status_any: [],
|
||||
status_exclude_any: [],
|
||||
status_rules: [],
|
||||
exclude_without_focus: false,
|
||||
include_archived: false,
|
||||
}
|
||||
|
||||
export const CATALOG_RULE_FIELD_KEYS = [
|
||||
'focus_rules',
|
||||
'style_direction_rules',
|
||||
'training_type_rules',
|
||||
'target_group_rules',
|
||||
'visibility_rules',
|
||||
'status_rules',
|
||||
]
|
||||
|
||||
const PREFS_KEYS = Object.keys(INITIAL_EXERCISE_LIST_FILTERS)
|
||||
|
||||
export function newCatalogRuleKey(prefix = 'r') {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) return `${prefix}-${crypto.randomUUID()}`
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
}
|
||||
|
||||
/** Einheitliche Regel-Zeile: { key, id, mode }. Legacy: focus_area_id. */
|
||||
export function normalizeCatalogRule(r, i, prefix = 'r') {
|
||||
if (!r || typeof r !== 'object') return null
|
||||
const id = String(r.id ?? r.focus_area_id ?? '').trim()
|
||||
if (!id) return null
|
||||
const mode = r.mode === 'forbid' ? 'forbid' : 'require'
|
||||
return {
|
||||
key: r.key || newCatalogRuleKey(prefix),
|
||||
id,
|
||||
mode,
|
||||
}
|
||||
}
|
||||
|
||||
export function splitMnCatalogRules(rules) {
|
||||
const inc = []
|
||||
const exc = []
|
||||
for (const r of rules || []) {
|
||||
const id = Number(r.id ?? r.focus_area_id)
|
||||
if (!Number.isFinite(id) || id < 1) continue
|
||||
if (r.mode === 'forbid') exc.push(id)
|
||||
else inc.push(id)
|
||||
}
|
||||
return {
|
||||
includeIds: [...new Set(inc)],
|
||||
excludeIds: [...new Set(exc)],
|
||||
}
|
||||
}
|
||||
|
||||
/** Für visibility/status (einfaches Feld mit einem Wert pro Übung): + → OR (Liste), − → Ausschlussliste. */
|
||||
export function splitScalarCatalogRules(rules) {
|
||||
const inc = []
|
||||
const exc = []
|
||||
for (const r of rules || []) {
|
||||
let id = String(r.id ?? '').trim().toLowerCase()
|
||||
if (!id) continue
|
||||
if (r.mode === 'forbid') exc.push(id)
|
||||
else inc.push(id)
|
||||
}
|
||||
return {
|
||||
includeVals: [...new Set(inc)],
|
||||
excludeVals: [...new Set(exc)],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft aus dem Profilfeld exercise_list_prefs einen gültigen Filter-State ab.
|
||||
*/
|
||||
export function mergeExerciseListPrefsFromApi(raw) {
|
||||
const out = { ...INITIAL_EXERCISE_LIST_FILTERS }
|
||||
if (!raw || typeof raw !== 'object') return out
|
||||
|
||||
for (const key of CATALOG_RULE_FIELD_KEYS) {
|
||||
if (!Array.isArray(raw[key])) continue
|
||||
out[key] = raw[key].map((r, i) => 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')) {
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<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 Werte pro Feld mit <strong>ODER</strong>.
|
||||
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">
|
||||
|
|
@ -908,34 +912,34 @@ function ExercisesListPage() {
|
|||
legacyFocusAreaIds={filters.focus_area_ids}
|
||||
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
|
||||
/>
|
||||
<div className="exercise-filters-modal-grid" style={{ marginTop: '14px' }}>
|
||||
<div>
|
||||
<label className="form-label">Stilrichtung</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.style_direction_ids}
|
||||
onChange={(v) => setFilters({ ...filters, style_direction_ids: v })}
|
||||
options={styleOptions}
|
||||
placeholder="Stilrichtung suchen …"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Trainingsstil</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.training_type_ids}
|
||||
onChange={(v) => setFilters({ ...filters, training_type_ids: v })}
|
||||
options={trainingTypeOptions}
|
||||
placeholder="Trainingsstil suchen …"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Zielgruppe</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.target_group_ids}
|
||||
onChange={(v) => setFilters({ ...filters, target_group_ids: v })}
|
||||
options={targetGroupOptions}
|
||||
placeholder="Zielgruppe suchen …"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
|
|
@ -993,31 +997,11 @@ function ExercisesListPage() {
|
|||
</section>
|
||||
|
||||
<section className="exercise-filter-section">
|
||||
<h4 className="exercise-filter-section-title">Ausblenden</h4>
|
||||
<h4 className="exercise-filter-section-title">Ausblenden / Liste</h4>
|
||||
<p className="muted" style={{ marginTop: 0, marginBottom: '12px', fontSize: '13px' }}>
|
||||
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.
|
||||
</p>
|
||||
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two">
|
||||
<div>
|
||||
<label className="form-label">Sichtbarkeit nicht anzeigen</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.visibility_exclude_any}
|
||||
onChange={(v) => setFilters({ ...filters, visibility_exclude_any: v })}
|
||||
options={visibilityOptions}
|
||||
placeholder="z. B. Global ausblenden …"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Status nicht anzeigen</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.status_exclude_any}
|
||||
onChange={(v) => setFilters({ ...filters, status_exclude_any: v })}
|
||||
options={statusOptions}
|
||||
placeholder="z. B. Entwurf ausblenden …"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '14px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<div style={{ marginTop: '6px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
|
@ -1054,25 +1038,28 @@ function ExercisesListPage() {
|
|||
|
||||
<section className="exercise-filter-section exercise-filter-section--last">
|
||||
<h4 className="exercise-filter-section-title">Freigabe</h4>
|
||||
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two">
|
||||
<div>
|
||||
<label className="form-label">Sichtbarkeit</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.visibility_any}
|
||||
onChange={(v) => setFilters({ ...filters, visibility_any: v })}
|
||||
options={visibilityOptions}
|
||||
placeholder="Sichtbarkeit wählen …"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Status</label>
|
||||
<MultiSelectCombo
|
||||
value={filters.status_any}
|
||||
onChange={(v) => setFilters({ ...filters, status_any: v })}
|
||||
options={statusOptions}
|
||||
placeholder="Status wählen …"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user