UX - Filter #12
|
|
@ -981,6 +981,30 @@ def list_exercises(
|
||||||
default=[],
|
default=[],
|
||||||
description="Keiner dieser Fokusbereiche darf gesetzt sein („−“)",
|
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(
|
include_archived: bool = Query(
|
||||||
default=False,
|
default=False,
|
||||||
description="Archivierte einbeziehen; Standard false (außer Statusfilter enthält archived)",
|
description="Archivierte einbeziehen; Standard false (außer Statusfilter enthält archived)",
|
||||||
|
|
@ -1110,32 +1134,77 @@ def list_exercises(
|
||||||
)
|
)
|
||||||
params.extend(sk_ids)
|
params.extend(sk_ids)
|
||||||
|
|
||||||
sd_ids = _merge_ids(style_direction_ids, style_direction_id)
|
sd_or = _merge_ids(style_direction_ids, style_direction_id)
|
||||||
if sd_ids:
|
sd_inc = _dedupe_positive_ids(list(style_direction_must_include_ids))
|
||||||
ph = ",".join(["%s"] * len(sd_ids))
|
sd_exc = _dedupe_positive_ids(list(style_direction_must_exclude_ids))
|
||||||
|
if sd_or:
|
||||||
|
ph = ",".join(["%s"] * len(sd_or))
|
||||||
where.append(
|
where.append(
|
||||||
"EXISTS (SELECT 1 FROM exercise_style_directions esd "
|
"EXISTS (SELECT 1 FROM exercise_style_directions esd "
|
||||||
f"WHERE esd.exercise_id = e.id AND esd.style_direction_id IN ({ph}))"
|
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)
|
tt_or = _merge_ids(training_type_ids, training_type_id)
|
||||||
if tt_ids:
|
tt_inc = _dedupe_positive_ids(list(training_type_must_include_ids))
|
||||||
ph = ",".join(["%s"] * len(tt_ids))
|
tt_exc = _dedupe_positive_ids(list(training_type_must_exclude_ids))
|
||||||
|
if tt_or:
|
||||||
|
ph = ",".join(["%s"] * len(tt_or))
|
||||||
where.append(
|
where.append(
|
||||||
"EXISTS (SELECT 1 FROM exercise_training_types ett "
|
"EXISTS (SELECT 1 FROM exercise_training_types ett "
|
||||||
f"WHERE ett.exercise_id = e.id AND ett.training_type_id IN ({ph}))"
|
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)
|
tg_or = _merge_ids(target_group_ids, target_group_id)
|
||||||
if tg_ids:
|
tg_inc = _dedupe_positive_ids(list(target_group_must_include_ids))
|
||||||
ph = ",".join(["%s"] * len(tg_ids))
|
tg_exc = _dedupe_positive_ids(list(target_group_must_exclude_ids))
|
||||||
|
if tg_or:
|
||||||
|
ph = ",".join(["%s"] * len(tg_or))
|
||||||
where.append(
|
where.append(
|
||||||
"EXISTS (SELECT 1 FROM exercise_target_groups etg "
|
"EXISTS (SELECT 1 FROM exercise_target_groups etg "
|
||||||
f"WHERE etg.exercise_id = e.id AND etg.target_group_id IN ({ph}))"
|
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:
|
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
|
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 {
|
.exercise-filters-modal-grid--two {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
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 {
|
.exercise-filter-chips-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
@ -2823,6 +2827,93 @@ a.analysis-split__nav-item {
|
||||||
font-weight: 600;
|
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 */
|
/* Reifegradmodell-Admin: klare Schritte, responsives Raster */
|
||||||
.admin-matrix-alert {
|
.admin-matrix-alert {
|
||||||
border: 1px solid var(--danger);
|
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'
|
import React from 'react'
|
||||||
|
import CatalogRulePicker from './CatalogRulePicker'
|
||||||
function newRuleKey() {
|
|
||||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
|
|
||||||
return `fr-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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({
|
export default function ExerciseFocusRulePicker({
|
||||||
focusOptions,
|
focusOptions,
|
||||||
focusRules,
|
focusRules,
|
||||||
focusOnlyWithout,
|
focusOnlyWithout,
|
||||||
excludeWithoutFocus,
|
|
||||||
legacyFocusAreaIds = [],
|
legacyFocusAreaIds = [],
|
||||||
onPatch,
|
onPatch,
|
||||||
}) {
|
}) {
|
||||||
const [pendingId, setPendingId] = useState('')
|
const legacyWarning =
|
||||||
|
Array.isArray(legacyFocusAreaIds) && legacyFocusAreaIds.length > 0 && !focusOnlyWithout
|
||||||
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 setFocusOnly = (on) => {
|
const setFocusOnly = (on) => {
|
||||||
if (on) {
|
if (on) {
|
||||||
|
|
@ -50,7 +22,6 @@ export default function ExerciseFocusRulePicker({
|
||||||
focus_rules: [],
|
focus_rules: [],
|
||||||
focus_area_ids: [],
|
focus_area_ids: [],
|
||||||
})
|
})
|
||||||
setPendingId('')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
onPatch({ focus_only_without: false })
|
onPatch({ focus_only_without: false })
|
||||||
|
|
@ -59,85 +30,33 @@ export default function ExerciseFocusRulePicker({
|
||||||
return (
|
return (
|
||||||
<div className="exercise-focus-rule-picker">
|
<div className="exercise-focus-rule-picker">
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', marginBottom: '10px' }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', marginBottom: '10px' }}>
|
||||||
<input
|
<input type="checkbox" checked={!!focusOnlyWithout} onChange={(e) => setFocusOnly(e.target.checked)} />
|
||||||
type="checkbox"
|
<span>
|
||||||
checked={!!focusOnlyWithout}
|
Nur Übungen <strong>ohne</strong> Fokusbereich (keine Zuordnung)
|
||||||
onChange={(e) => setFocusOnly(e.target.checked)}
|
</span>
|
||||||
/>
|
|
||||||
<span>Nur Übungen <strong>ohne</strong> Fokusbereich (keine Zuordnung)</span>
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{!focusOnlyWithout ? (
|
{!focusOnlyWithout ? (
|
||||||
<>
|
<>
|
||||||
{legacyWarning ? (
|
{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>
|
|
||||||
) : null}
|
|
||||||
<label className="form-label">Fokusbereiche (+ mit / − ohne)</label>
|
|
||||||
<p className="muted" style={{ fontSize: '12px', marginTop: 0, marginBottom: '8px' }}>
|
<p className="muted" style={{ fontSize: '12px', marginTop: 0, marginBottom: '8px' }}>
|
||||||
Mehrere „+“: alle müssen gesetzt sein (UND). Mehrere „−“: keiner davon darf gesetzt sein.
|
Ältere ODER-Fokusliste aktiv — über die Chips auf der Übersicht entfernen.
|
||||||
Kombination z. B. „+ Karate“ und „− Fitness“ = hat Karate, aber nicht zusätzlich Fitness.
|
|
||||||
</p>
|
</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}
|
) : 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 }}>
|
<p className="muted" style={{ fontSize: '12px', marginTop: 0 }}>
|
||||||
Solange diese Option aktiv ist, sind Fokus-Regeln und die ODER-Fokusliste deaktiviert.
|
Fokus-Regeln sind deaktiviert.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,12 @@ import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
||||||
import {
|
import {
|
||||||
INITIAL_EXERCISE_LIST_FILTERS,
|
INITIAL_EXERCISE_LIST_FILTERS,
|
||||||
mergeExerciseListPrefsFromApi,
|
mergeExerciseListPrefsFromApi,
|
||||||
|
splitMnCatalogRules,
|
||||||
|
splitScalarCatalogRules,
|
||||||
} from '../constants/exerciseListFilters'
|
} from '../constants/exerciseListFilters'
|
||||||
import MultiSelectCombo from './MultiSelectCombo'
|
import MultiSelectCombo from './MultiSelectCombo'
|
||||||
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
|
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
|
||||||
|
import CatalogRulePicker from './CatalogRulePicker'
|
||||||
|
|
||||||
const PAGE_SIZE = 100
|
const PAGE_SIZE = 100
|
||||||
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
|
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 n = (v) => (v === '' || v == null ? undefined : Number(v))
|
||||||
const ids = (arr) =>
|
const ids = (arr) =>
|
||||||
Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined
|
Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined
|
||||||
const mustInc = []
|
const fMn = splitMnCatalogRules(filters.focus_rules)
|
||||||
const mustExc = []
|
if (fMn.includeIds.length) q.focus_area_must_include_ids = fMn.includeIds
|
||||||
for (const r of filters.focus_rules || []) {
|
if (fMn.excludeIds.length) q.focus_area_must_exclude_ids = fMn.excludeIds
|
||||||
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)
|
|
||||||
if (filters.focus_only_without) q.focus_only_without_focus_areas = true
|
if (filters.focus_only_without) q.focus_only_without_focus_areas = true
|
||||||
|
|
||||||
const fa = ids(filters.focus_area_ids)
|
const fa = ids(filters.focus_area_ids)
|
||||||
if (fa?.length) q.focus_area_ids = fa
|
if (fa?.length) q.focus_area_ids = fa
|
||||||
const sd = ids(filters.style_direction_ids)
|
|
||||||
if (sd?.length) q.style_direction_ids = sd
|
const sdMn = splitMnCatalogRules(filters.style_direction_rules)
|
||||||
const tt = ids(filters.training_type_ids)
|
if (sdMn.includeIds.length) q.style_direction_must_include_ids = sdMn.includeIds
|
||||||
if (tt?.length) q.training_type_ids = tt
|
if (sdMn.excludeIds.length) q.style_direction_must_exclude_ids = sdMn.excludeIds
|
||||||
const tg = ids(filters.target_group_ids)
|
const sdLegacy = ids(filters.style_direction_ids)
|
||||||
if (tg?.length) q.target_group_ids = tg
|
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)
|
const sk = ids(filters.skill_ids)
|
||||||
if (sk?.length) q.skill_ids = sk
|
if (sk?.length) q.skill_ids = sk
|
||||||
if (filters.skill_min_level) q.skill_min_level = n(filters.skill_min_level)
|
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.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.exclude_without_focus) q.exclude_without_focus = true
|
||||||
if (filters.include_archived) q.include_archived = true
|
if (filters.include_archived) q.include_archived = true
|
||||||
if (debouncedSearch) q.search = debouncedSearch
|
if (debouncedSearch) q.search = debouncedSearch
|
||||||
|
|
@ -311,8 +322,8 @@ export default function ExercisePickerModal({
|
||||||
{filterOpen && (
|
{filterOpen && (
|
||||||
<div style={{ marginTop: '0.35rem', fontSize: '13px', color: 'var(--text2)' }}>
|
<div style={{ marginTop: '0.35rem', fontSize: '13px', color: 'var(--text2)' }}>
|
||||||
<p style={{ margin: '0 0 12px 0' }}>
|
<p style={{ margin: '0 0 12px 0' }}>
|
||||||
Zwischen den Bereichen gilt <strong>UND</strong>. Fokus: „+ mit“ / „− ohne“ kombinierbar (mehrere „+“ =
|
Felder gelten mit <strong>UND</strong>. Kataloge: mehrere „+“ = alle zutreffend; „−“ schließt aus.
|
||||||
alle gesetzt). Sonstige Mehrfachauswahl pro Feld mit <strong>ODER</strong>.
|
Sichtbarkeit/Status: mehrere „+“ = eine davon (ODER); „−“ blendet aus.
|
||||||
</p>
|
</p>
|
||||||
<ExerciseFocusRulePicker
|
<ExerciseFocusRulePicker
|
||||||
focusOptions={focusOptions}
|
focusOptions={focusOptions}
|
||||||
|
|
@ -321,35 +332,35 @@ export default function ExercisePickerModal({
|
||||||
legacyFocusAreaIds={filters.focus_area_ids}
|
legacyFocusAreaIds={filters.focus_area_ids}
|
||||||
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||||||
/>
|
/>
|
||||||
<div className="exercise-filters-modal-grid" style={{ marginTop: 12 }}>
|
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--catalog" style={{ marginTop: 12 }}>
|
||||||
<div>
|
<CatalogRulePicker
|
||||||
<label className="form-label">Stilrichtung</label>
|
label="Stilrichtung"
|
||||||
<MultiSelectCombo
|
hint="+ alle nötig (UND). − verbietet Zuordnung."
|
||||||
value={filters.style_direction_ids}
|
|
||||||
onChange={(v) => setFilters((f) => ({ ...f, style_direction_ids: v }))}
|
|
||||||
options={styleOptions}
|
options={styleOptions}
|
||||||
placeholder="Stilrichtung …"
|
rules={filters.style_direction_rules}
|
||||||
|
rulesFieldName="style_direction_rules"
|
||||||
|
placeholder="Stil …"
|
||||||
|
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
<CatalogRulePicker
|
||||||
<div>
|
label="Trainingsstil"
|
||||||
<label className="form-label">Trainingsstil</label>
|
hint="+ alle nötig (UND). − verbietet Zuordnung."
|
||||||
<MultiSelectCombo
|
|
||||||
value={filters.training_type_ids}
|
|
||||||
onChange={(v) => setFilters((f) => ({ ...f, training_type_ids: v }))}
|
|
||||||
options={trainingTypeOptions}
|
options={trainingTypeOptions}
|
||||||
|
rules={filters.training_type_rules}
|
||||||
|
rulesFieldName="training_type_rules"
|
||||||
placeholder="Trainingsstil …"
|
placeholder="Trainingsstil …"
|
||||||
|
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
<CatalogRulePicker
|
||||||
<div>
|
label="Zielgruppe"
|
||||||
<label className="form-label">Zielgruppe</label>
|
hint="+ alle nötig (UND). − verbietet Zuordnung."
|
||||||
<MultiSelectCombo
|
|
||||||
value={filters.target_group_ids}
|
|
||||||
onChange={(v) => setFilters((f) => ({ ...f, target_group_ids: v }))}
|
|
||||||
options={targetGroupOptions}
|
options={targetGroupOptions}
|
||||||
placeholder="Zielgruppe …"
|
rules={filters.target_group_rules}
|
||||||
|
rulesFieldName="target_group_rules"
|
||||||
|
placeholder="Gruppe …"
|
||||||
|
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: 12 }}>
|
<div style={{ marginTop: 12 }}>
|
||||||
<label className="form-label">Fähigkeit</label>
|
<label className="form-label">Fähigkeit</label>
|
||||||
<MultiSelectCombo
|
<MultiSelectCombo
|
||||||
|
|
@ -387,47 +398,26 @@ export default function ExercisePickerModal({
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two" style={{ marginTop: 12 }}>
|
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two exercise-filters-modal-grid--catalog" style={{ marginTop: 12 }}>
|
||||||
<div>
|
<CatalogRulePicker
|
||||||
<label className="form-label">Sichtbarkeit</label>
|
label="Sichtbarkeit"
|
||||||
<MultiSelectCombo
|
|
||||||
value={filters.visibility_any}
|
|
||||||
onChange={(v) => setFilters((f) => ({ ...f, visibility_any: v }))}
|
|
||||||
options={visibilityOptions}
|
options={visibilityOptions}
|
||||||
|
rules={filters.visibility_rules}
|
||||||
|
rulesFieldName="visibility_rules"
|
||||||
|
idKind="string"
|
||||||
placeholder="Sichtbarkeit …"
|
placeholder="Sichtbarkeit …"
|
||||||
|
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
<CatalogRulePicker
|
||||||
<div>
|
label="Status"
|
||||||
<label className="form-label">Status</label>
|
|
||||||
<MultiSelectCombo
|
|
||||||
value={filters.status_any}
|
|
||||||
onChange={(v) => setFilters((f) => ({ ...f, status_any: v }))}
|
|
||||||
options={statusOptions}
|
options={statusOptions}
|
||||||
|
rules={filters.status_rules}
|
||||||
|
rulesFieldName="status_rules"
|
||||||
|
idKind="string"
|
||||||
placeholder="Status …"
|
placeholder="Status …"
|
||||||
|
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
|
||||||
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
<label
|
<label
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,137 @@
|
||||||
/** Gemeinsame Default-Filter für Übungslisten (Übersicht + Auswahlmodal). */
|
/** Gemeinsame Default-Filter für Übungslisten (Übersicht + Auswahlmodal). */
|
||||||
export const INITIAL_EXERCISE_LIST_FILTERS = {
|
export const INITIAL_EXERCISE_LIST_FILTERS = {
|
||||||
/** Legacy: ODER über mehrere Fokus-IDs (wird nur noch aus gespeicherten Presets oder Chips genutzt). */
|
|
||||||
focus_area_ids: [],
|
focus_area_ids: [],
|
||||||
/** Regeln: mode require = muss Fokus haben, forbid = darf diesen Fokus nicht haben (UND über alle require). */
|
|
||||||
focus_rules: [],
|
focus_rules: [],
|
||||||
/** Nur Übungen ohne einen Eintrag in exercise_focus_areas. */
|
|
||||||
focus_only_without: false,
|
focus_only_without: false,
|
||||||
style_direction_ids: [],
|
style_direction_ids: [],
|
||||||
|
style_direction_rules: [],
|
||||||
training_type_ids: [],
|
training_type_ids: [],
|
||||||
|
training_type_rules: [],
|
||||||
target_group_ids: [],
|
target_group_ids: [],
|
||||||
|
target_group_rules: [],
|
||||||
skill_ids: [],
|
skill_ids: [],
|
||||||
skill_min_level: '',
|
skill_min_level: '',
|
||||||
skill_max_level: '',
|
skill_max_level: '',
|
||||||
visibility_any: [],
|
visibility_any: [],
|
||||||
status_any: [],
|
|
||||||
visibility_exclude_any: [],
|
visibility_exclude_any: [],
|
||||||
|
visibility_rules: [],
|
||||||
|
status_any: [],
|
||||||
status_exclude_any: [],
|
status_exclude_any: [],
|
||||||
|
status_rules: [],
|
||||||
exclude_without_focus: false,
|
exclude_without_focus: false,
|
||||||
include_archived: 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)
|
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.
|
* Ruft aus dem Profilfeld exercise_list_prefs einen gültigen Filter-State ab.
|
||||||
*/
|
*/
|
||||||
export function mergeExerciseListPrefsFromApi(raw) {
|
export function mergeExerciseListPrefsFromApi(raw) {
|
||||||
const out = { ...INITIAL_EXERCISE_LIST_FILTERS }
|
const out = { ...INITIAL_EXERCISE_LIST_FILTERS }
|
||||||
if (!raw || typeof raw !== 'object') return out
|
if (!raw || typeof raw !== 'object') return out
|
||||||
for (const k of PREFS_KEYS) {
|
|
||||||
if (raw[k] === undefined) continue
|
for (const key of CATALOG_RULE_FIELD_KEYS) {
|
||||||
if (k === 'focus_rules' && Array.isArray(raw[k])) {
|
if (!Array.isArray(raw[key])) continue
|
||||||
out.focus_rules = raw[k]
|
out[key] = raw[key].map((r, i) => normalizeCatalogRule(r, i, key)).filter(Boolean)
|
||||||
.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 (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 === 'visibility_any' ||
|
||||||
|
k === 'visibility_exclude_any' ||
|
||||||
|
k === 'status_any' ||
|
||||||
|
k === 'status_exclude_any'
|
||||||
|
) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (k.endsWith('_ids') || k.endsWith('_any')) {
|
if (k.endsWith('_ids') || k.endsWith('_any')) {
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,15 @@ import { useAuth } from '../context/AuthContext'
|
||||||
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
||||||
import MultiSelectCombo from '../components/MultiSelectCombo'
|
import MultiSelectCombo from '../components/MultiSelectCombo'
|
||||||
import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker'
|
import ExerciseFocusRulePicker from '../components/ExerciseFocusRulePicker'
|
||||||
|
import CatalogRulePicker from '../components/CatalogRulePicker'
|
||||||
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
||||||
import PageSectionNav from '../components/PageSectionNav'
|
import PageSectionNav from '../components/PageSectionNav'
|
||||||
import {
|
import {
|
||||||
INITIAL_EXERCISE_LIST_FILTERS,
|
INITIAL_EXERCISE_LIST_FILTERS,
|
||||||
mergeExerciseListPrefsFromApi,
|
mergeExerciseListPrefsFromApi,
|
||||||
compactExerciseListPrefsPayload,
|
compactExerciseListPrefsPayload,
|
||||||
|
splitMnCatalogRules,
|
||||||
|
splitScalarCatalogRules,
|
||||||
} from '../constants/exerciseListFilters'
|
} from '../constants/exerciseListFilters'
|
||||||
import { sanitizeExerciseRichText, coerceApiNameList } from '../utils/sanitizeHtml'
|
import { sanitizeExerciseRichText, coerceApiNameList } from '../utils/sanitizeHtml'
|
||||||
import { canUserRequestExerciseDelete } from '../utils/exercisePermissions'
|
import { canUserRequestExerciseDelete } from '../utils/exercisePermissions'
|
||||||
|
|
@ -51,6 +54,22 @@ function statusLabel(s) {
|
||||||
return STATUS_LABELS[s] || 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) {
|
function exerciseFocusNames(ex) {
|
||||||
const fromApi = coerceApiNameList(ex.focus_area_names)
|
const fromApi = coerceApiNameList(ex.focus_area_names)
|
||||||
if (fromApi.length) return fromApi
|
if (fromApi.length) return fromApi
|
||||||
|
|
@ -222,19 +241,7 @@ function ExercisesListPage() {
|
||||||
const filterChips = useMemo(() => {
|
const filterChips = useMemo(() => {
|
||||||
const chips = []
|
const chips = []
|
||||||
|
|
||||||
;(filters.focus_rules || []).forEach((r) => {
|
pushCatalogRuleFilterChips(chips, 'focus_rules', filters.focus_rules, focusOptions, 'Fokus', setFilters)
|
||||||
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),
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (filters.focus_only_without) {
|
if (filters.focus_only_without) {
|
||||||
chips.push({
|
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) => {
|
;(filters.style_direction_ids || []).forEach((id) => {
|
||||||
const opt = styleOptions.find((o) => String(o.id) === String(id))
|
const opt = styleOptions.find((o) => String(o.id) === String(id))
|
||||||
chips.push({
|
chips.push({
|
||||||
key: `sd-${id}`,
|
key: `sd-${id}`,
|
||||||
label: `Stil: ${opt?.label ?? id}`,
|
label: `Stil (ODER, älter): ${opt?.label ?? id}`,
|
||||||
onRemove: () =>
|
onRemove: () =>
|
||||||
setFilters((prev) => ({
|
setFilters((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -272,7 +305,7 @@ function ExercisesListPage() {
|
||||||
const opt = trainingTypeOptions.find((o) => String(o.id) === String(id))
|
const opt = trainingTypeOptions.find((o) => String(o.id) === String(id))
|
||||||
chips.push({
|
chips.push({
|
||||||
key: `tt-${id}`,
|
key: `tt-${id}`,
|
||||||
label: `Trainingsstil: ${opt?.label ?? id}`,
|
label: `Trainingsstil (ODER, älter): ${opt?.label ?? id}`,
|
||||||
onRemove: () =>
|
onRemove: () =>
|
||||||
setFilters((prev) => ({
|
setFilters((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -284,7 +317,7 @@ function ExercisesListPage() {
|
||||||
const opt = targetGroupOptions.find((o) => String(o.id) === String(id))
|
const opt = targetGroupOptions.find((o) => String(o.id) === String(id))
|
||||||
chips.push({
|
chips.push({
|
||||||
key: `tg-${id}`,
|
key: `tg-${id}`,
|
||||||
label: `Zielgruppe: ${opt?.label ?? id}`,
|
label: `Zielgruppe (ODER, älter): ${opt?.label ?? id}`,
|
||||||
onRemove: () =>
|
onRemove: () =>
|
||||||
setFilters((prev) => ({
|
setFilters((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -292,6 +325,7 @@ function ExercisesListPage() {
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
;(filters.skill_ids || []).forEach((id) => {
|
;(filters.skill_ids || []).forEach((id) => {
|
||||||
const opt = skillOptions.find((o) => String(o.id) === String(id))
|
const opt = skillOptions.find((o) => String(o.id) === String(id))
|
||||||
chips.push({
|
chips.push({
|
||||||
|
|
@ -320,55 +354,15 @@ function ExercisesListPage() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
;(filters.visibility_any || []).forEach((id) => {
|
pushCatalogRuleFilterChips(
|
||||||
const opt = visibilityOptions.find((o) => String(o.id) === String(id))
|
chips,
|
||||||
chips.push({
|
'visibility_rules',
|
||||||
key: `vis-${id}`,
|
filters.visibility_rules,
|
||||||
label: `Sichtbarkeit: ${opt?.label ?? id}`,
|
visibilityOptions,
|
||||||
onRemove: () =>
|
'Sichtbarkeit',
|
||||||
setFilters((prev) => ({
|
setFilters
|
||||||
...prev,
|
)
|
||||||
visibility_any: prev.visibility_any.filter((x) => String(x) !== String(id)),
|
pushCatalogRuleFilterChips(chips, 'status_rules', filters.status_rules, statusOptions, 'Status', setFilters)
|
||||||
})),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
;(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)),
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
if (filters.exclude_without_focus) {
|
if (filters.exclude_without_focus) {
|
||||||
chips.push({
|
chips.push({
|
||||||
|
|
@ -395,6 +389,7 @@ function ExercisesListPage() {
|
||||||
skillOptions,
|
skillOptions,
|
||||||
visibilityOptions,
|
visibilityOptions,
|
||||||
statusOptions,
|
statusOptions,
|
||||||
|
setFilters,
|
||||||
])
|
])
|
||||||
|
|
||||||
/** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */
|
/** 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 n = (v) => (v === '' || v == null ? undefined : Number(v))
|
||||||
const ids = (arr) =>
|
const ids = (arr) =>
|
||||||
Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined
|
Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined
|
||||||
const mustInc = []
|
const fMn = splitMnCatalogRules(filters.focus_rules)
|
||||||
const mustExc = []
|
if (fMn.includeIds.length) q.focus_area_must_include_ids = fMn.includeIds
|
||||||
for (const r of filters.focus_rules || []) {
|
if (fMn.excludeIds.length) q.focus_area_must_exclude_ids = fMn.excludeIds
|
||||||
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)
|
|
||||||
if (filters.focus_only_without) q.focus_only_without_focus_areas = true
|
if (filters.focus_only_without) q.focus_only_without_focus_areas = true
|
||||||
|
|
||||||
const fa = ids(filters.focus_area_ids)
|
const fa = ids(filters.focus_area_ids)
|
||||||
if (fa?.length) q.focus_area_ids = fa
|
if (fa?.length) q.focus_area_ids = fa
|
||||||
const sd = ids(filters.style_direction_ids)
|
|
||||||
if (sd?.length) q.style_direction_ids = sd
|
const sdMn = splitMnCatalogRules(filters.style_direction_rules)
|
||||||
const tt = ids(filters.training_type_ids)
|
if (sdMn.includeIds.length) q.style_direction_must_include_ids = sdMn.includeIds
|
||||||
if (tt?.length) q.training_type_ids = tt
|
if (sdMn.excludeIds.length) q.style_direction_must_exclude_ids = sdMn.excludeIds
|
||||||
const tg = ids(filters.target_group_ids)
|
const sdLegacy = ids(filters.style_direction_ids)
|
||||||
if (tg?.length) q.target_group_ids = tg
|
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)
|
const sk = ids(filters.skill_ids)
|
||||||
if (sk?.length) q.skill_ids = sk
|
if (sk?.length) q.skill_ids = sk
|
||||||
if (filters.skill_min_level) q.skill_min_level = n(filters.skill_min_level)
|
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.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.exclude_without_focus) q.exclude_without_focus = true
|
||||||
if (filters.include_archived) q.include_archived = true
|
if (filters.include_archived) q.include_archived = true
|
||||||
if (debouncedSearch) q.search = debouncedSearch
|
if (debouncedSearch) q.search = debouncedSearch
|
||||||
|
|
@ -896,7 +899,8 @@ function ExercisesListPage() {
|
||||||
<p className="muted" style={{ marginTop: 0, marginBottom: '14px' }}>
|
<p className="muted" style={{ marginTop: 0, marginBottom: '14px' }}>
|
||||||
Zwischen den Bereichen gilt <strong>UND</strong>. Fokusbereiche: mehrere „+ mit“ bedeuten alle müssen
|
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 /
|
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>
|
</p>
|
||||||
|
|
||||||
<section className="exercise-filter-section">
|
<section className="exercise-filter-section">
|
||||||
|
|
@ -908,35 +912,35 @@ function ExercisesListPage() {
|
||||||
legacyFocusAreaIds={filters.focus_area_ids}
|
legacyFocusAreaIds={filters.focus_area_ids}
|
||||||
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
|
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
|
||||||
/>
|
/>
|
||||||
<div className="exercise-filters-modal-grid" style={{ marginTop: '14px' }}>
|
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--catalog" style={{ marginTop: '12px' }}>
|
||||||
<div>
|
<CatalogRulePicker
|
||||||
<label className="form-label">Stilrichtung</label>
|
label="Stilrichtung"
|
||||||
<MultiSelectCombo
|
hint="+ alle nötig (UND). − verbietet Zuordnung."
|
||||||
value={filters.style_direction_ids}
|
|
||||||
onChange={(v) => setFilters({ ...filters, style_direction_ids: v })}
|
|
||||||
options={styleOptions}
|
options={styleOptions}
|
||||||
placeholder="Stilrichtung suchen …"
|
rules={filters.style_direction_rules}
|
||||||
|
rulesFieldName="style_direction_rules"
|
||||||
|
placeholder="Stil …"
|
||||||
|
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
<CatalogRulePicker
|
||||||
<div>
|
label="Trainingsstil"
|
||||||
<label className="form-label">Trainingsstil</label>
|
hint="+ alle nötig (UND). − verbietet Zuordnung."
|
||||||
<MultiSelectCombo
|
|
||||||
value={filters.training_type_ids}
|
|
||||||
onChange={(v) => setFilters({ ...filters, training_type_ids: v })}
|
|
||||||
options={trainingTypeOptions}
|
options={trainingTypeOptions}
|
||||||
placeholder="Trainingsstil suchen …"
|
rules={filters.training_type_rules}
|
||||||
|
rulesFieldName="training_type_rules"
|
||||||
|
placeholder="Trainingsstil …"
|
||||||
|
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
<CatalogRulePicker
|
||||||
<div>
|
label="Zielgruppe"
|
||||||
<label className="form-label">Zielgruppe</label>
|
hint="+ alle nötig (UND). − verbietet Zuordnung."
|
||||||
<MultiSelectCombo
|
|
||||||
value={filters.target_group_ids}
|
|
||||||
onChange={(v) => setFilters({ ...filters, target_group_ids: v })}
|
|
||||||
options={targetGroupOptions}
|
options={targetGroupOptions}
|
||||||
placeholder="Zielgruppe suchen …"
|
rules={filters.target_group_rules}
|
||||||
|
rulesFieldName="target_group_rules"
|
||||||
|
placeholder="Gruppe …"
|
||||||
|
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="exercise-filter-section">
|
<section className="exercise-filter-section">
|
||||||
|
|
@ -993,31 +997,11 @@ function ExercisesListPage() {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="exercise-filter-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' }}>
|
<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>
|
</p>
|
||||||
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two">
|
<div style={{ marginTop: '6px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
<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' }}>
|
|
||||||
<label
|
<label
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
@ -1054,26 +1038,29 @@ function ExercisesListPage() {
|
||||||
|
|
||||||
<section className="exercise-filter-section exercise-filter-section--last">
|
<section className="exercise-filter-section exercise-filter-section--last">
|
||||||
<h4 className="exercise-filter-section-title">Freigabe</h4>
|
<h4 className="exercise-filter-section-title">Freigabe</h4>
|
||||||
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two">
|
<p className="muted" style={{ marginTop: 0, marginBottom: '10px', fontSize: '12px' }}>
|
||||||
<div>
|
Pro Übung nur ein Wert: mehrere „+“ bedeuten „eine davon“ (ODER). „−“ blendet Werte aus.
|
||||||
<label className="form-label">Sichtbarkeit</label>
|
</p>
|
||||||
<MultiSelectCombo
|
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two exercise-filters-modal-grid--catalog">
|
||||||
value={filters.visibility_any}
|
<CatalogRulePicker
|
||||||
onChange={(v) => setFilters({ ...filters, visibility_any: v })}
|
label="Sichtbarkeit"
|
||||||
options={visibilityOptions}
|
options={visibilityOptions}
|
||||||
placeholder="Sichtbarkeit wählen …"
|
rules={filters.visibility_rules}
|
||||||
|
rulesFieldName="visibility_rules"
|
||||||
|
idKind="string"
|
||||||
|
placeholder="Sichtbarkeit …"
|
||||||
|
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
<CatalogRulePicker
|
||||||
<div>
|
label="Status"
|
||||||
<label className="form-label">Status</label>
|
|
||||||
<MultiSelectCombo
|
|
||||||
value={filters.status_any}
|
|
||||||
onChange={(v) => setFilters({ ...filters, status_any: v })}
|
|
||||||
options={statusOptions}
|
options={statusOptions}
|
||||||
placeholder="Status wählen …"
|
rules={filters.status_rules}
|
||||||
|
rulesFieldName="status_rules"
|
||||||
|
idKind="string"
|
||||||
|
placeholder="Status …"
|
||||||
|
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div className="exercise-filter-modal__footer">
|
<div className="exercise-filter-modal__footer">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user