feat: update capability levels and enhance exercise filtering
- Updated capability level mappings in the backend to reflect new terminology (e.g., "einsteiger" to "basis" and "experte" to "optimierung"). - Refactored the exercise management logic to normalize skill levels using canonical slugs, improving consistency across the application. - Enhanced the ExercisesListPage with additional filtering options for style direction, training type, and target group, along with AI search capabilities. - Incremented application version to 0.7.7 and updated changelog to document these changes.
This commit is contained in:
parent
c6d20e4dec
commit
76098f5244
27
backend/migrations/029_skill_level_standard_names.sql
Normal file
27
backend/migrations/029_skill_level_standard_names.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
-- Migration 029: Einheitliche Fähigkeitsstufen 1–5 (Basis … Optimierung)
|
||||
-- Datum: 2026-04-27
|
||||
|
||||
-- exercise_skills: Legacy- und Zahl-Strings → kanonische Slugs
|
||||
UPDATE exercise_skills SET required_level = 'basis' WHERE required_level IN ('einsteiger', '1');
|
||||
UPDATE exercise_skills SET required_level = 'optimierung' WHERE required_level IN ('experte', '5');
|
||||
UPDATE exercise_skills SET required_level = 'grundlagen' WHERE required_level = '2';
|
||||
UPDATE exercise_skills SET required_level = 'aufbau' WHERE required_level = '3';
|
||||
UPDATE exercise_skills SET required_level = 'fortgeschritten' WHERE required_level = '4';
|
||||
|
||||
UPDATE exercise_skills SET target_level = 'basis' WHERE target_level IN ('einsteiger', '1');
|
||||
UPDATE exercise_skills SET target_level = 'optimierung' WHERE target_level IN ('experte', '5');
|
||||
UPDATE exercise_skills SET target_level = 'grundlagen' WHERE target_level = '2';
|
||||
UPDATE exercise_skills SET target_level = 'aufbau' WHERE target_level = '3';
|
||||
UPDATE exercise_skills SET target_level = 'fortgeschritten' WHERE target_level = '4';
|
||||
|
||||
-- Reifegradmodell-Stufen (einheitliche Bezeichnungen für Stufe 1–5)
|
||||
UPDATE model_levels SET
|
||||
name = CASE level_number
|
||||
WHEN 1 THEN 'Basis'
|
||||
WHEN 2 THEN 'Grundlagen'
|
||||
WHEN 3 THEN 'Aufbau'
|
||||
WHEN 4 THEN 'Fortgeschritten'
|
||||
WHEN 5 THEN 'Optimierung'
|
||||
ELSE name
|
||||
END
|
||||
WHERE level_number BETWEEN 1 AND 5;
|
||||
|
|
@ -21,6 +21,53 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
router = APIRouter(prefix="/api", tags=["exercises"])
|
||||
|
||||
# Kanonische Fähigkeitsstufen 1–5 (Übung ↔ Skill-Zeile), siehe Migration 029
|
||||
_CANONICAL_SKILL_LEVELS = frozenset(
|
||||
{"basis", "grundlagen", "aufbau", "fortgeschritten", "optimierung"}
|
||||
)
|
||||
_LEGACY_SKILL_LEVEL_SLUG = {
|
||||
"einsteiger": "basis",
|
||||
"experte": "optimierung",
|
||||
"1": "basis",
|
||||
"2": "grundlagen",
|
||||
"3": "aufbau",
|
||||
"4": "fortgeschritten",
|
||||
"5": "optimierung",
|
||||
}
|
||||
|
||||
# SQL: numerischer Rang aus target_level (fallback required_level) für Filter
|
||||
_EXERCISE_SKILL_LEVEL_RANK_SQL = """
|
||||
CASE COALESCE(
|
||||
NULLIF(TRIM(LOWER(es.target_level::text)), ''),
|
||||
NULLIF(TRIM(LOWER(es.required_level::text)), '')
|
||||
)
|
||||
WHEN 'basis' THEN 1
|
||||
WHEN 'grundlagen' THEN 2
|
||||
WHEN 'aufbau' THEN 3
|
||||
WHEN 'fortgeschritten' THEN 4
|
||||
WHEN 'optimierung' THEN 5
|
||||
WHEN 'einsteiger' THEN 1
|
||||
WHEN 'experte' THEN 5
|
||||
WHEN '1' THEN 1
|
||||
WHEN '2' THEN 2
|
||||
WHEN '3' THEN 3
|
||||
WHEN '4' THEN 4
|
||||
WHEN '5' THEN 5
|
||||
ELSE NULL END
|
||||
""".strip()
|
||||
|
||||
|
||||
def normalize_exercise_skill_level(value) -> Optional[str]:
|
||||
"""Wandelt Legacy-/Zahlencodes in kanonische Slugs; ungültig → None."""
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip().lower()
|
||||
if not s:
|
||||
return None
|
||||
if s in _CANONICAL_SKILL_LEVELS:
|
||||
return s
|
||||
return _LEGACY_SKILL_LEVEL_SLUG.get(s)
|
||||
|
||||
MEDIA_ROOT = Path(os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent.parent / "media")))
|
||||
MAX_EXERCISE_MEDIA = 10
|
||||
MAX_UPLOAD_BYTES = 50 * 1024 * 1024
|
||||
|
|
@ -272,6 +319,9 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict:
|
|||
(exercise_id,)
|
||||
)
|
||||
exercise["skills"] = [r2d(r) for r in cur.fetchall()]
|
||||
for sk in exercise["skills"]:
|
||||
sk["required_level"] = normalize_exercise_skill_level(sk.get("required_level"))
|
||||
sk["target_level"] = normalize_exercise_skill_level(sk.get("target_level"))
|
||||
|
||||
# Variants (1:N) - mit Progression
|
||||
cur.execute(
|
||||
|
|
@ -369,8 +419,8 @@ def assign_exercise_relations(cur, conn, exercise_id: int, data: dict):
|
|||
skill["skill_id"],
|
||||
skill.get("is_primary", False),
|
||||
skill.get("intensity"),
|
||||
skill.get("required_level"),
|
||||
skill.get("target_level"),
|
||||
normalize_exercise_skill_level(skill.get("required_level")),
|
||||
normalize_exercise_skill_level(skill.get("target_level")),
|
||||
skill.get("ai_suggested", False),
|
||||
)
|
||||
)
|
||||
|
|
@ -388,7 +438,16 @@ def list_exercises(
|
|||
visibility: Optional[str] = Query(default=None),
|
||||
status: Optional[str] = Query(default=None),
|
||||
skill_id: Optional[int] = Query(default=None),
|
||||
style_direction_id: Optional[int] = Query(default=None),
|
||||
training_type_id: Optional[int] = Query(default=None),
|
||||
target_group_id: Optional[int] = Query(default=None),
|
||||
skill_min_level: Optional[int] = Query(default=None, ge=1, le=5),
|
||||
skill_max_level: Optional[int] = Query(default=None, ge=1, le=5),
|
||||
search: Optional[str] = Query(default=None),
|
||||
ai_search: Optional[str] = Query(
|
||||
default=None,
|
||||
description="Platzhalter KI-Suche: derzeit gleiche Volltextlogik wie search (später Embeddings/Reranking)",
|
||||
),
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
session: dict = Depends(require_auth),
|
||||
|
|
@ -428,10 +487,52 @@ def list_exercises(
|
|||
where.append("EXISTS (SELECT 1 FROM exercise_skills es WHERE es.exercise_id = e.id AND es.skill_id = %s)")
|
||||
params.append(skill_id)
|
||||
|
||||
# Volltext-Suche (tsvector)
|
||||
if search:
|
||||
if style_direction_id:
|
||||
where.append(
|
||||
"EXISTS (SELECT 1 FROM exercise_style_directions esd "
|
||||
"WHERE esd.exercise_id = e.id AND esd.style_direction_id = %s)"
|
||||
)
|
||||
params.append(style_direction_id)
|
||||
|
||||
if training_type_id:
|
||||
where.append(
|
||||
"EXISTS (SELECT 1 FROM exercise_training_types ett "
|
||||
"WHERE ett.exercise_id = e.id AND ett.training_type_id = %s)"
|
||||
)
|
||||
params.append(training_type_id)
|
||||
|
||||
if target_group_id:
|
||||
where.append(
|
||||
"EXISTS (SELECT 1 FROM exercise_target_groups etg "
|
||||
"WHERE etg.exercise_id = e.id AND etg.target_group_id = %s)"
|
||||
)
|
||||
params.append(target_group_id)
|
||||
|
||||
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
|
||||
hi = skill_max_level if skill_max_level is not None else 5
|
||||
if lo > hi:
|
||||
lo, hi = hi, lo
|
||||
where.append(
|
||||
"EXISTS (SELECT 1 FROM exercise_skills es WHERE es.exercise_id = e.id AND ("
|
||||
+ _EXERCISE_SKILL_LEVEL_RANK_SQL
|
||||
+ ") BETWEEN %s AND %s)"
|
||||
)
|
||||
params.extend([lo, hi])
|
||||
|
||||
# Volltext (tsvector); ai_search gleiche Engine, bei zwei Begriffen ODER-Verknüpfung
|
||||
s1 = (search or "").strip()
|
||||
s2 = (ai_search or "").strip()
|
||||
if s1 and s2 and s1 != s2:
|
||||
where.append(
|
||||
"(e.search_vector @@ plainto_tsquery('german', %s) "
|
||||
"OR e.search_vector @@ plainto_tsquery('german', %s))"
|
||||
)
|
||||
params.extend([s1, s2])
|
||||
elif s1 or s2:
|
||||
qtext = s1 or s2
|
||||
where.append("e.search_vector @@ plainto_tsquery('german', %s)")
|
||||
params.append(search)
|
||||
params.append(qtext)
|
||||
|
||||
# Query (primary_focus_name für Listen-Ansicht gemäß Spec-Beispiel „focus_area“-Label)
|
||||
query = f"""
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
# Mapping: SMW-Integer → Shinkan-Stufenname
|
||||
CAPABILITY_LEVEL_MAP = {
|
||||
"1": "einsteiger",
|
||||
"1": "basis",
|
||||
"2": "grundlagen",
|
||||
"3": "aufbau",
|
||||
"4": "fortgeschritten",
|
||||
"5": "experte",
|
||||
"5": "optimierung",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -167,8 +167,8 @@ def parse_equipment(raw: list[str]) -> list[str]:
|
|||
|
||||
|
||||
def map_capability_level(level_str: str) -> str:
|
||||
"""Wandelt Integer-Level in benannte Stufe: "3" → "aufbau" """
|
||||
return CAPABILITY_LEVEL_MAP.get(level_str.strip(), "einsteiger")
|
||||
"""Wandelt Integer-Level in kanonischen Stufen-Slug: "3" → "aufbau" """
|
||||
return CAPABILITY_LEVEL_MAP.get(level_str.strip(), "basis")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
|
@ -273,9 +273,7 @@ def build_skill_assignments(mapped: dict) -> list[dict]:
|
|||
Erstellt Skill-Zuordnungen aus PrimaryCapability + CapabilityLevel.
|
||||
|
||||
CapabilityLevel [3, 2] korrespondiert mit PrimaryCapability [Schnellkraft, Schnelligkeitsausdauer]
|
||||
→ ergibt: [{skill: Schnellkraft, target_level: 3}, {skill: Schnelligkeitsausdauer, target_level: 2}]
|
||||
|
||||
WICHTIG: target_level ist INTEGER (1-5), nicht String!
|
||||
→ target_level als kanonischer Slug (basis … optimierung), DB VARCHAR.
|
||||
"""
|
||||
skills = mapped.get("skill_names", [])
|
||||
levels = mapped.get("skill_levels_raw", [])
|
||||
|
|
@ -283,18 +281,18 @@ def build_skill_assignments(mapped: dict) -> list[dict]:
|
|||
assignments = []
|
||||
for idx, skill_name in enumerate(skills):
|
||||
level_str = levels[idx] if idx < len(levels) else "1"
|
||||
# Konvertiere zu INTEGER statt String-Namen
|
||||
try:
|
||||
target_level = int(level_str.strip())
|
||||
except (ValueError, AttributeError):
|
||||
target_level = 1 # Fallback
|
||||
raw = str(level_str).strip()
|
||||
except (TypeError, AttributeError):
|
||||
raw = "1"
|
||||
target_slug = map_capability_level(raw) if raw else "basis"
|
||||
|
||||
assignments.append({
|
||||
"skill_name": skill_name,
|
||||
"target_level": target_level, # INTEGER 1-5
|
||||
"required_level": None, # Nicht im Wiki spezifiziert
|
||||
"intensity": None, # Nicht im Wiki spezifiziert
|
||||
"is_primary": idx == 0,
|
||||
"skill_name": skill_name,
|
||||
"target_level": target_slug,
|
||||
"required_level": None,
|
||||
"intensity": None,
|
||||
"is_primary": idx == 0,
|
||||
})
|
||||
return assignments
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.7.6"
|
||||
APP_VERSION = "0.7.7"
|
||||
BUILD_DATE = "2026-04-27"
|
||||
DB_SCHEMA_VERSION = "20260427028"
|
||||
DB_SCHEMA_VERSION = "20260427029"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"auth": "1.0.0",
|
||||
|
|
@ -23,6 +23,15 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.7.7",
|
||||
"date": "2026-04-27",
|
||||
"changes": [
|
||||
"DB 029: Fähigkeitsstufen Einheit (basis–optimierung), model_levels Namen 1–5",
|
||||
"Übungen: GET /exercises Filter Stilrichtung, Trainingsstil, Zielgruppe, Fähigkeits-Stufe min/max, ai_search (Volltext-Platzhalter)",
|
||||
"API: exercise_skills Level-Normalisierung bei Schreiben/Lesen; Wiki-Import Slugs statt Zahl in DB",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.7.6",
|
||||
"date": "2026-04-27",
|
||||
|
|
|
|||
37
frontend/src/constants/skillLevels.js
Normal file
37
frontend/src/constants/skillLevels.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/** Kanonische Übungs-Fähigkeitsstufen 1–5 (Slug → Anzeige) */
|
||||
export const SKILL_LEVEL_OPTIONS = [
|
||||
{ value: '', label: '—', level: null },
|
||||
{ value: 'basis', label: '1 · Basis', level: 1 },
|
||||
{ value: 'grundlagen', label: '2 · Grundlagen', level: 2 },
|
||||
{ value: 'aufbau', label: '3 · Aufbau', level: 3 },
|
||||
{ value: 'fortgeschritten', label: '4 · Fortgeschritten', level: 4 },
|
||||
{ value: 'optimierung', label: '5 · Optimierung', level: 5 },
|
||||
]
|
||||
|
||||
const LEGACY_MAP = {
|
||||
einsteiger: 'basis',
|
||||
experte: 'optimierung',
|
||||
'1': 'basis',
|
||||
'2': 'grundlagen',
|
||||
'3': 'aufbau',
|
||||
'4': 'fortgeschritten',
|
||||
'5': 'optimierung',
|
||||
}
|
||||
|
||||
const LABEL_BY_SLUG = Object.fromEntries(
|
||||
SKILL_LEVEL_OPTIONS.filter((o) => o.value).map((o) => [o.value, o.label])
|
||||
)
|
||||
|
||||
export function normalizeSkillLevelSlug(raw) {
|
||||
if (raw == null || raw === '') return ''
|
||||
const s = String(raw).trim().toLowerCase()
|
||||
if (LEGACY_MAP[s]) return LEGACY_MAP[s]
|
||||
if (LABEL_BY_SLUG[s]) return s
|
||||
return ''
|
||||
}
|
||||
|
||||
export function formatSkillLevelSlug(raw) {
|
||||
const slug = normalizeSkillLevelSlug(raw)
|
||||
if (!slug) return ''
|
||||
return LABEL_BY_SLUG[slug] || slug
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'
|
|||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import { sanitizeTrainerHtml } from '../utils/htmlUtils'
|
||||
import { formatSkillLevelSlug } from '../constants/skillLevels'
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
|
||||
|
||||
|
|
@ -242,15 +243,19 @@ function ExerciseDetailPage() {
|
|||
<section className="card exercise-detail-section">
|
||||
<h2>Fähigkeiten</h2>
|
||||
<div className="exercise-tag-row">
|
||||
{exercise.skills.map((s) => (
|
||||
<span key={s.id} className={`exercise-tag${s.is_primary ? ' exercise-tag--accent' : ''}`}>
|
||||
{s.skill_name}
|
||||
{s.intensity ? ` · ${s.intensity}` : ''}
|
||||
{s.required_level || s.target_level
|
||||
? ` (${[s.required_level, s.target_level].filter(Boolean).join(' → ')})`
|
||||
: ''}
|
||||
</span>
|
||||
))}
|
||||
{exercise.skills.map((s) => {
|
||||
const rl = formatSkillLevelSlug(s.required_level)
|
||||
const tl = formatSkillLevelSlug(s.target_level)
|
||||
const lvl =
|
||||
rl || tl ? ` (${[rl, tl].filter(Boolean).join(' → ')})` : ''
|
||||
return (
|
||||
<span key={s.id} className={`exercise-tag${s.is_primary ? ' exercise-tag--accent' : ''}`}>
|
||||
{s.skill_name}
|
||||
{s.intensity ? ` · ${s.intensity}` : ''}
|
||||
{lvl}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'
|
|||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import api, { buildExerciseApiPayload } from '../utils/api'
|
||||
import RichTextEditor from '../components/RichTextEditor'
|
||||
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
|
||||
|
||||
const INTENSITY_OPTIONS = [
|
||||
{ value: '', label: '—' },
|
||||
|
|
@ -10,15 +11,6 @@ const INTENSITY_OPTIONS = [
|
|||
{ value: 'hoch', label: 'hoch' },
|
||||
]
|
||||
|
||||
const LEVEL_OPTIONS = [
|
||||
{ value: '', label: '—' },
|
||||
{ value: 'einsteiger', label: 'Einsteiger' },
|
||||
{ value: 'grundlagen', label: 'Grundlagen' },
|
||||
{ value: 'aufbau', label: 'Aufbau' },
|
||||
{ value: 'fortgeschritten', label: 'Fortgeschritten' },
|
||||
{ value: 'experte', label: 'Experte' },
|
||||
]
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
title: '',
|
||||
|
|
@ -78,8 +70,8 @@ function detailToForm(exercise) {
|
|||
skill_id: s.skill_id,
|
||||
is_primary: s.is_primary || false,
|
||||
intensity: s.intensity || '',
|
||||
required_level: s.required_level || '',
|
||||
target_level: s.target_level || '',
|
||||
required_level: normalizeSkillLevelSlug(s.required_level),
|
||||
target_level: normalizeSkillLevelSlug(s.target_level),
|
||||
})) || [],
|
||||
}
|
||||
}
|
||||
|
|
@ -636,7 +628,7 @@ function ExerciseFormPage() {
|
|||
value={row.required_level || ''}
|
||||
onChange={(e) => updateSkillField(idx, 'required_level', e.target.value)}
|
||||
>
|
||||
{LEVEL_OPTIONS.map((o) => (
|
||||
{SKILL_LEVEL_OPTIONS.map((o) => (
|
||||
<option key={`r-${o.value}`} value={o.value}>
|
||||
von {o.label}
|
||||
</option>
|
||||
|
|
@ -647,7 +639,7 @@ function ExerciseFormPage() {
|
|||
value={row.target_level || ''}
|
||||
onChange={(e) => updateSkillField(idx, 'target_level', e.target.value)}
|
||||
>
|
||||
{LEVEL_OPTIONS.map((o) => (
|
||||
{SKILL_LEVEL_OPTIONS.map((o) => (
|
||||
<option key={`t-${o.value}`} value={o.value}>
|
||||
bis {o.label}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -1,35 +1,112 @@
|
|||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
|
||||
|
||||
function ExercisesListPage() {
|
||||
const [exercises, setExercises] = useState([])
|
||||
const [focusAreas, setFocusAreas] = useState([])
|
||||
const [catalogs, setCatalogs] = useState({
|
||||
focusAreas: [],
|
||||
styleDirections: [],
|
||||
trainingTypes: [],
|
||||
targetGroups: [],
|
||||
skills: [],
|
||||
})
|
||||
const [catalogsReady, setCatalogsReady] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [aiSearchInput, setAiSearchInput] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
const [debouncedAiSearch, setDebouncedAiSearch] = useState('')
|
||||
const [filters, setFilters] = useState({
|
||||
focus_area: '',
|
||||
style_direction_id: '',
|
||||
training_type_id: '',
|
||||
target_group_id: '',
|
||||
skill_id: '',
|
||||
skill_min_level: '',
|
||||
skill_max_level: '',
|
||||
visibility: '',
|
||||
status: '',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
|
||||
return () => clearTimeout(t)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedAiSearch(aiSearchInput.trim()), 400)
|
||||
return () => clearTimeout(t)
|
||||
}, [aiSearchInput])
|
||||
|
||||
const queryBase = useMemo(() => {
|
||||
const q = {}
|
||||
const n = (v) => (v === '' || v == null ? undefined : Number(v))
|
||||
if (filters.focus_area) q.focus_area = n(filters.focus_area)
|
||||
if (filters.style_direction_id) q.style_direction_id = n(filters.style_direction_id)
|
||||
if (filters.training_type_id) q.training_type_id = n(filters.training_type_id)
|
||||
if (filters.target_group_id) q.target_group_id = n(filters.target_group_id)
|
||||
if (filters.skill_id) q.skill_id = n(filters.skill_id)
|
||||
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) q.visibility = filters.visibility
|
||||
if (filters.status) q.status = filters.status
|
||||
if (debouncedSearch) q.search = debouncedSearch
|
||||
if (debouncedAiSearch) q.ai_search = debouncedAiSearch
|
||||
return q
|
||||
}, [filters, debouncedSearch, debouncedAiSearch])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const [fa, sd, tt, tg, sk] = await Promise.all([
|
||||
api.listFocusAreas(),
|
||||
api.listStyleDirections(),
|
||||
api.listTrainingTypes(),
|
||||
api.listTargetGroups(),
|
||||
api.listSkills(),
|
||||
])
|
||||
if (!cancelled) {
|
||||
setCatalogs({
|
||||
focusAreas: fa,
|
||||
styleDirections: sd,
|
||||
trainingTypes: tt,
|
||||
targetGroups: tg,
|
||||
skills: sk,
|
||||
})
|
||||
setCatalogsReady(true)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error(err)
|
||||
alert('Kataloge konnten nicht geladen werden: ' + err.message)
|
||||
setCatalogsReady(true)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!catalogsReady) return
|
||||
let cancelled = false
|
||||
const run = async () => {
|
||||
setLoading(true)
|
||||
setOffset(0)
|
||||
try {
|
||||
const [batch, focusAreasData] = await Promise.all([
|
||||
api.listExercises({ ...filters, limit: PAGE_SIZE, offset: 0 }),
|
||||
api.listFocusAreas(),
|
||||
])
|
||||
const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset: 0 })
|
||||
if (cancelled) return
|
||||
setExercises(batch)
|
||||
setFocusAreas(focusAreasData)
|
||||
setHasMore(batch.length === PAGE_SIZE)
|
||||
setOffset(batch.length)
|
||||
} catch (err) {
|
||||
|
|
@ -45,13 +122,13 @@ function ExercisesListPage() {
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [filters])
|
||||
}, [queryBase, catalogsReady])
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loadingMore || !hasMore) return
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const batch = await api.listExercises({ ...filters, limit: PAGE_SIZE, offset })
|
||||
const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset })
|
||||
setExercises((prev) => [...prev, ...batch])
|
||||
setHasMore(batch.length === PAGE_SIZE)
|
||||
setOffset((o) => o + batch.length)
|
||||
|
|
@ -72,7 +149,7 @@ function ExercisesListPage() {
|
|||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
if (!catalogsReady || loading) {
|
||||
return (
|
||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<div className="spinner"></div>
|
||||
|
|
@ -100,6 +177,32 @@ function ExercisesListPage() {
|
|||
</div>
|
||||
|
||||
<div className="card exercise-filters-compact" style={{ marginBottom: '12px' }}>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label className="form-label">Volltextsuche (Titel, Ziel, …)</label>
|
||||
<input
|
||||
type="search"
|
||||
className="form-input"
|
||||
placeholder="Suchbegriffe…"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<label className="form-label">Ergänzende Suche / KI-Vorbereitung (Beta)</label>
|
||||
<input
|
||||
type="search"
|
||||
className="form-input"
|
||||
placeholder="zweiter Begriff — aktuell zusätzliche Volltextsuche (ODER); später KI"
|
||||
value={aiSearchInput}
|
||||
onChange={(e) => setAiSearchInput(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '6px' }}>
|
||||
Standardfilter aus Verein und Trainerrolle folgen später; die KI-Nutzung der zweiten Zeile
|
||||
kommt mit einem eigenen Endpunkt.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Fokus</label>
|
||||
<select
|
||||
|
|
@ -108,13 +211,103 @@ function ExercisesListPage() {
|
|||
onChange={(e) => setFilters({ ...filters, focus_area: e.target.value })}
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
{focusAreas.map((fa) => (
|
||||
{catalogs.focusAreas.map((fa) => (
|
||||
<option key={fa.id} value={fa.id}>
|
||||
{fa.icon} {fa.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Stilrichtung</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={filters.style_direction_id}
|
||||
onChange={(e) => setFilters({ ...filters, style_direction_id: e.target.value })}
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
{catalogs.styleDirections.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Trainingsstil</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={filters.training_type_id}
|
||||
onChange={(e) => setFilters({ ...filters, training_type_id: e.target.value })}
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
{catalogs.trainingTypes.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Zielgruppe</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={filters.target_group_id}
|
||||
onChange={(e) => setFilters({ ...filters, target_group_id: e.target.value })}
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
{catalogs.targetGroups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Fähigkeit</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={filters.skill_id}
|
||||
onChange={(e) => setFilters({ ...filters, skill_id: e.target.value })}
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
{catalogs.skills.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Fähigkeit Stufe von</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={filters.skill_min_level}
|
||||
onChange={(e) => setFilters({ ...filters, skill_min_level: e.target.value })}
|
||||
>
|
||||
<option value="">egal</option>
|
||||
{LEVEL_FILTER_OPTS.map((o) => (
|
||||
<option key={o.value} value={String(o.level)}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Fähigkeit Stufe bis</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={filters.skill_max_level}
|
||||
onChange={(e) => setFilters({ ...filters, skill_max_level: e.target.value })}
|
||||
>
|
||||
<option value="">egal</option>
|
||||
{LEVEL_FILTER_OPTS.map((o) => (
|
||||
<option key={`m-${o.value}`} value={String(o.level)}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Sichtbarkeit</label>
|
||||
<select
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user