feat: update version and enhance exercise filtering features
Some checks failed
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 29s
Some checks failed
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 29s
- Bumped application version to 0.8.40 and updated module versions accordingly. - Introduced new focus area filtering options in the ExercisesListPage, allowing users to include or exclude exercises based on specified focus areas. - Added utility functions for deduplicating and merging focus area IDs to improve filtering logic. - Enhanced the ExercisePickerModal and ExercisesListPage components to support new focus rules and improve user experience with focus area selections.
This commit is contained in:
parent
cfd40889ac
commit
518918a6e5
|
|
@ -645,6 +645,21 @@ def _merge_ids(multi: list[int], single: Optional[int]) -> list[int]:
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_positive_ids(ids: list[int]) -> list[int]:
|
||||||
|
seen: set[int] = set()
|
||||||
|
out: list[int] = []
|
||||||
|
for raw in ids or []:
|
||||||
|
try:
|
||||||
|
xi = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if xi < 1 or xi in seen:
|
||||||
|
continue
|
||||||
|
seen.add(xi)
|
||||||
|
out.append(xi)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _merge_str_any(multi: list[str], single: Optional[str]) -> list[str]:
|
def _merge_str_any(multi: list[str], single: Optional[str]) -> list[str]:
|
||||||
seen = set()
|
seen = set()
|
||||||
out = []
|
out = []
|
||||||
|
|
@ -954,6 +969,18 @@ def list_exercises(
|
||||||
default=False,
|
default=False,
|
||||||
description="Wenn true: nur Übungen mit mindestens einem Fokusbereich",
|
description="Wenn true: nur Übungen mit mindestens einem Fokusbereich",
|
||||||
),
|
),
|
||||||
|
focus_only_without_focus_areas: bool = Query(
|
||||||
|
default=False,
|
||||||
|
description="Nur Übungen ohne einen einzigen Fokusbereich (M:N exercise_focus_areas leer)",
|
||||||
|
),
|
||||||
|
focus_area_must_include_ids: list[int] = Query(
|
||||||
|
default=[],
|
||||||
|
description="Alle genannten Fokusbereiche müssen gesetzt sein (UND / „+“)",
|
||||||
|
),
|
||||||
|
focus_area_must_exclude_ids: list[int] = Query(
|
||||||
|
default=[],
|
||||||
|
description="Keiner dieser Fokusbereiche darf gesetzt 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)",
|
||||||
|
|
@ -1022,18 +1049,58 @@ def list_exercises(
|
||||||
where.append(f"(e.status IS NULL OR LOWER(TRIM(e.status::text)) NOT IN ({ph}))")
|
where.append(f"(e.status IS NULL OR LOWER(TRIM(e.status::text)) NOT IN ({ph}))")
|
||||||
params.extend(st_excl)
|
params.extend(st_excl)
|
||||||
|
|
||||||
if exclude_without_focus:
|
focus_only = focus_only_without_focus_areas
|
||||||
where.append(
|
must_inc = _dedupe_positive_ids(list(focus_area_must_include_ids))
|
||||||
"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)"
|
must_exc = _dedupe_positive_ids(list(focus_area_must_exclude_ids))
|
||||||
)
|
fa_or = _merge_ids(focus_area_ids, focus_area)
|
||||||
|
|
||||||
fa_ids = _merge_ids(focus_area_ids, focus_area)
|
if focus_only:
|
||||||
if fa_ids:
|
if exclude_without_focus:
|
||||||
ph = ",".join(["%s"] * len(fa_ids))
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="focus_only_without_focus_areas schließt exclude_without_focus aus.",
|
||||||
|
)
|
||||||
|
if fa_or:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_ids (ODER-Liste) verwendet werden.",
|
||||||
|
)
|
||||||
|
if must_inc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_must_include_ids verwendet werden.",
|
||||||
|
)
|
||||||
|
if must_exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="focus_only_without_focus_areas darf nicht zusammen mit focus_area_must_exclude_ids verwendet werden.",
|
||||||
|
)
|
||||||
where.append(
|
where.append(
|
||||||
f"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))"
|
"NOT EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)"
|
||||||
)
|
)
|
||||||
params.extend(fa_ids)
|
else:
|
||||||
|
if exclude_without_focus:
|
||||||
|
where.append(
|
||||||
|
"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id)"
|
||||||
|
)
|
||||||
|
if fa_or:
|
||||||
|
ph = ",".join(["%s"] * len(fa_or))
|
||||||
|
where.append(
|
||||||
|
f"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))"
|
||||||
|
)
|
||||||
|
params.extend(fa_or)
|
||||||
|
for fid in must_inc:
|
||||||
|
where.append(
|
||||||
|
"EXISTS (SELECT 1 FROM exercise_focus_areas efa WHERE efa.exercise_id = e.id AND efa.focus_area_id = %s)"
|
||||||
|
)
|
||||||
|
params.append(fid)
|
||||||
|
if must_exc:
|
||||||
|
ph = ",".join(["%s"] * len(must_exc))
|
||||||
|
where.append(
|
||||||
|
f"NOT EXISTS (SELECT 1 FROM exercise_focus_areas efa "
|
||||||
|
f"WHERE efa.exercise_id = e.id AND efa.focus_area_id IN ({ph}))"
|
||||||
|
)
|
||||||
|
params.extend(must_exc)
|
||||||
|
|
||||||
sk_ids = _merge_ids(skill_ids, skill_id)
|
sk_ids = _merge_ids(skill_ids, skill_id)
|
||||||
if sk_ids:
|
if sk_ids:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.39"
|
APP_VERSION = "0.8.40"
|
||||||
BUILD_DATE = "2026-05-06"
|
BUILD_DATE = "2026-05-06"
|
||||||
DB_SCHEMA_VERSION = "20260506043"
|
DB_SCHEMA_VERSION = "20260506043"
|
||||||
|
|
||||||
|
|
@ -15,7 +15,7 @@ MODULE_VERSIONS = {
|
||||||
"groups": "0.1.0",
|
"groups": "0.1.0",
|
||||||
"skills": "0.1.0",
|
"skills": "0.1.0",
|
||||||
"methods": "0.1.0",
|
"methods": "0.1.0",
|
||||||
"exercises": "2.9.0", # DELETE RBAC (Trainer/Vereinsadmin/Plattform); Nutzungs-409; Listenfilter Negativlisten + Archiv-Standard; exercise_list_prefs
|
"exercises": "2.10.0", # GET /exercises: focus_area_must_include/exclude_ids, focus_only_without_focus_areas; UI +/- Fokusregeln
|
||||||
"training_units": "0.2.0",
|
"training_units": "0.2.0",
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
|
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
|
||||||
|
|
@ -27,6 +27,13 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.8.40",
|
||||||
|
"date": "2026-05-06",
|
||||||
|
"changes": [
|
||||||
|
"Übungen Liste: Fokusfilter mit UND-+ (must_include) und UND-− (must_exclude), nur ohne Fokusbereich (focus_only_without); Frontend Dropdown + Mit / − Ohne",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.8.39",
|
"version": "0.8.39",
|
||||||
"date": "2026-05-06",
|
"date": "2026-05-06",
|
||||||
|
|
|
||||||
145
frontend/src/components/ExerciseFocusRulePicker.jsx
Normal file
145
frontend/src/components/ExerciseFocusRulePicker.jsx
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
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)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fokusbereiche mit „+“ (muss haben) und „−“ (darf nicht haben); optional nur Übungen ohne Fokus-Zuordnung.
|
||||||
|
*/
|
||||||
|
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 setFocusOnly = (on) => {
|
||||||
|
if (on) {
|
||||||
|
onPatch({
|
||||||
|
focus_only_without: true,
|
||||||
|
exclude_without_focus: false,
|
||||||
|
focus_rules: [],
|
||||||
|
focus_area_ids: [],
|
||||||
|
})
|
||||||
|
setPendingId('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onPatch({ focus_only_without: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
</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>
|
||||||
|
) : 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}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="muted" style={{ fontSize: '13px', marginTop: 0 }}>
|
||||||
|
Solange diese Option aktiv ist, sind Fokus-Regeln und die ODER-Fokusliste deaktiviert.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
mergeExerciseListPrefsFromApi,
|
mergeExerciseListPrefsFromApi,
|
||||||
} from '../constants/exerciseListFilters'
|
} from '../constants/exerciseListFilters'
|
||||||
import MultiSelectCombo from './MultiSelectCombo'
|
import MultiSelectCombo from './MultiSelectCombo'
|
||||||
|
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
|
||||||
|
|
||||||
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)
|
||||||
|
|
@ -154,6 +155,19 @@ 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 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)
|
||||||
|
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)
|
const sd = ids(filters.style_direction_ids)
|
||||||
|
|
@ -297,18 +311,17 @@ 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>, innerhalb ODER wie in der Übungsübersicht.
|
Zwischen den Bereichen gilt <strong>UND</strong>. Fokus: „+ mit“ / „− ohne“ kombinierbar (mehrere „+“ =
|
||||||
|
alle gesetzt). Sonstige Mehrfachauswahl pro Feld mit <strong>ODER</strong>.
|
||||||
</p>
|
</p>
|
||||||
<div className="exercise-filters-modal-grid">
|
<ExerciseFocusRulePicker
|
||||||
<div>
|
focusOptions={focusOptions}
|
||||||
<label className="form-label">Fokus</label>
|
focusRules={filters.focus_rules}
|
||||||
<MultiSelectCombo
|
focusOnlyWithout={filters.focus_only_without}
|
||||||
value={filters.focus_area_ids}
|
legacyFocusAreaIds={filters.focus_area_ids}
|
||||||
onChange={(v) => setFilters((f) => ({ ...f, focus_area_ids: v }))}
|
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||||||
options={focusOptions}
|
/>
|
||||||
placeholder="Fokus …"
|
<div className="exercise-filters-modal-grid" style={{ marginTop: 12 }}>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">Stilrichtung</label>
|
<label className="form-label">Stilrichtung</label>
|
||||||
<MultiSelectCombo
|
<MultiSelectCombo
|
||||||
|
|
@ -416,11 +429,26 @@ export default function ExercisePickerModal({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
cursor: filters.focus_only_without ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: filters.focus_only_without ? 0.55 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
disabled={!!filters.focus_only_without}
|
||||||
checked={!!filters.exclude_without_focus}
|
checked={!!filters.exclude_without_focus}
|
||||||
onChange={(e) => setFilters((f) => ({ ...f, exclude_without_focus: e.target.checked }))}
|
onChange={(e) =>
|
||||||
|
setFilters((f) => ({
|
||||||
|
...f,
|
||||||
|
exclude_without_focus: e.target.checked,
|
||||||
|
...(e.target.checked ? { focus_only_without: false } : {}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<span>Ohne Fokus ausblenden</span>
|
<span>Ohne Fokus ausblenden</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
/** 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: [],
|
||||||
|
/** Nur Übungen ohne einen Eintrag in exercise_focus_areas. */
|
||||||
|
focus_only_without: false,
|
||||||
style_direction_ids: [],
|
style_direction_ids: [],
|
||||||
training_type_ids: [],
|
training_type_ids: [],
|
||||||
target_group_ids: [],
|
target_group_ids: [],
|
||||||
|
|
@ -25,6 +30,20 @@ export function mergeExerciseListPrefsFromApi(raw) {
|
||||||
if (!raw || typeof raw !== 'object') return out
|
if (!raw || typeof raw !== 'object') return out
|
||||||
for (const k of PREFS_KEYS) {
|
for (const k of PREFS_KEYS) {
|
||||||
if (raw[k] === undefined) 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]
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (k.endsWith('_ids') || k.endsWith('_any')) {
|
if (k.endsWith('_ids') || k.endsWith('_any')) {
|
||||||
if (Array.isArray(raw[k])) out[k] = raw[k].map(String)
|
if (Array.isArray(raw[k])) out[k] = raw[k].map(String)
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import api from '../utils/api'
|
||||||
import { useAuth } from '../context/AuthContext'
|
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 ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
||||||
import PageSectionNav from '../components/PageSectionNav'
|
import PageSectionNav from '../components/PageSectionNav'
|
||||||
import {
|
import {
|
||||||
|
|
@ -221,11 +222,33 @@ function ExercisesListPage() {
|
||||||
const filterChips = useMemo(() => {
|
const filterChips = useMemo(() => {
|
||||||
const chips = []
|
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),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (filters.focus_only_without) {
|
||||||
|
chips.push({
|
||||||
|
key: 'focus-only-none',
|
||||||
|
label: 'Nur ohne Fokusbereich',
|
||||||
|
onRemove: () => setFilters((prev) => ({ ...prev, focus_only_without: false })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
;(filters.focus_area_ids || []).forEach((id) => {
|
;(filters.focus_area_ids || []).forEach((id) => {
|
||||||
const opt = focusOptions.find((o) => String(o.id) === String(id))
|
const opt = focusOptions.find((o) => String(o.id) === String(id))
|
||||||
chips.push({
|
chips.push({
|
||||||
key: `fa-${id}`,
|
key: `fa-${id}`,
|
||||||
label: `Fokus: ${opt?.label ?? id}`,
|
label: `Fokus (ODER, älter): ${opt?.label ?? id}`,
|
||||||
onRemove: () =>
|
onRemove: () =>
|
||||||
setFilters((prev) => ({
|
setFilters((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -385,6 +408,19 @@ 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 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)
|
||||||
|
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)
|
const sd = ids(filters.style_direction_ids)
|
||||||
|
|
@ -858,22 +894,21 @@ function ExercisesListPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
|
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
|
||||||
<p className="muted" style={{ marginTop: 0, marginBottom: '14px' }}>
|
<p className="muted" style={{ marginTop: 0, marginBottom: '14px' }}>
|
||||||
Zwischen den Bereichen gilt <strong>UND</strong>. Innerhalb eines Feldes werden mehrere Einträge mit{' '}
|
Zwischen den Bereichen gilt <strong>UND</strong>. Fokusbereiche: mehrere „+ mit“ bedeuten alle müssen
|
||||||
<strong>ODER</strong> verknüpft.
|
gesetzt sein; „− ohne“ schließt Übungen aus, die diesen Fokus zusätzlich haben. Stilrichtung /
|
||||||
|
Trainingsstil / Zielgruppe: mehrere Werte pro Feld mit <strong>ODER</strong>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<section className="exercise-filter-section">
|
<section className="exercise-filter-section">
|
||||||
<h4 className="exercise-filter-section-title">Zuordnung</h4>
|
<h4 className="exercise-filter-section-title">Zuordnung</h4>
|
||||||
<div className="exercise-filters-modal-grid">
|
<ExerciseFocusRulePicker
|
||||||
<div>
|
focusOptions={focusOptions}
|
||||||
<label className="form-label">Fokus</label>
|
focusRules={filters.focus_rules}
|
||||||
<MultiSelectCombo
|
focusOnlyWithout={filters.focus_only_without}
|
||||||
value={filters.focus_area_ids}
|
legacyFocusAreaIds={filters.focus_area_ids}
|
||||||
onChange={(v) => setFilters({ ...filters, focus_area_ids: v })}
|
onPatch={(patch) => setFilters((prev) => ({ ...prev, ...patch }))}
|
||||||
options={focusOptions}
|
/>
|
||||||
placeholder="Fokus suchen oder „▼ Alle“ …"
|
<div className="exercise-filters-modal-grid" style={{ marginTop: '14px' }}>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">Stilrichtung</label>
|
<label className="form-label">Stilrichtung</label>
|
||||||
<MultiSelectCombo
|
<MultiSelectCombo
|
||||||
|
|
@ -983,11 +1018,26 @@ function ExercisesListPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: '14px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
<div style={{ marginTop: '14px', display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer' }}>
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
cursor: filters.focus_only_without ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: filters.focus_only_without ? 0.55 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
disabled={!!filters.focus_only_without}
|
||||||
checked={!!filters.exclude_without_focus}
|
checked={!!filters.exclude_without_focus}
|
||||||
onChange={(e) => setFilters({ ...filters, exclude_without_focus: e.target.checked })}
|
onChange={(e) =>
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
exclude_without_focus: e.target.checked,
|
||||||
|
...(e.target.checked ? { focus_only_without: false } : {}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<span>Übungen ohne Fokusbereich ausblenden</span>
|
<span>Übungen ohne Fokusbereich ausblenden</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
// Shinkan Jinkendo Frontend Version
|
// Shinkan Jinkendo Frontend Version
|
||||||
|
|
||||||
export const APP_VERSION = "0.8.39"
|
export const APP_VERSION = "0.8.40"
|
||||||
export const BUILD_DATE = "2026-05-06"
|
export const BUILD_DATE = "2026-05-06"
|
||||||
|
|
||||||
export const PAGE_VERSIONS = {
|
export const PAGE_VERSIONS = {
|
||||||
LoginPage: "1.0.0",
|
LoginPage: "1.0.0",
|
||||||
Dashboard: "1.0.0",
|
Dashboard: "1.0.0",
|
||||||
AccountSettingsPage: "1.0.0",
|
AccountSettingsPage: "1.0.0",
|
||||||
ExercisesPage: "1.4.0", // Negativfilter, Archiv-Standard, gespeicherte Standardfilter (exercise_list_prefs); Löschen-UX
|
ExercisesPage: "1.5.0", // Fokus +/- Regeln, nur ohne Fokusbereich; Filterprefs
|
||||||
ClubsPage: "1.1.0",
|
ClubsPage: "1.1.0",
|
||||||
SkillsPage: "1.0.0",
|
SkillsPage: "1.0.0",
|
||||||
TrainingPlanningPage: "1.4.0",
|
TrainingPlanningPage: "1.4.0",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user