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"])
|
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")))
|
MEDIA_ROOT = Path(os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent.parent / "media")))
|
||||||
MAX_EXERCISE_MEDIA = 10
|
MAX_EXERCISE_MEDIA = 10
|
||||||
MAX_UPLOAD_BYTES = 50 * 1024 * 1024
|
MAX_UPLOAD_BYTES = 50 * 1024 * 1024
|
||||||
|
|
@ -272,6 +319,9 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict:
|
||||||
(exercise_id,)
|
(exercise_id,)
|
||||||
)
|
)
|
||||||
exercise["skills"] = [r2d(r) for r in cur.fetchall()]
|
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
|
# Variants (1:N) - mit Progression
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
@ -369,8 +419,8 @@ def assign_exercise_relations(cur, conn, exercise_id: int, data: dict):
|
||||||
skill["skill_id"],
|
skill["skill_id"],
|
||||||
skill.get("is_primary", False),
|
skill.get("is_primary", False),
|
||||||
skill.get("intensity"),
|
skill.get("intensity"),
|
||||||
skill.get("required_level"),
|
normalize_exercise_skill_level(skill.get("required_level")),
|
||||||
skill.get("target_level"),
|
normalize_exercise_skill_level(skill.get("target_level")),
|
||||||
skill.get("ai_suggested", False),
|
skill.get("ai_suggested", False),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -388,7 +438,16 @@ def list_exercises(
|
||||||
visibility: Optional[str] = Query(default=None),
|
visibility: Optional[str] = Query(default=None),
|
||||||
status: Optional[str] = Query(default=None),
|
status: Optional[str] = Query(default=None),
|
||||||
skill_id: Optional[int] = 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),
|
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),
|
limit: int = Query(default=50, ge=1, le=100),
|
||||||
offset: int = Query(default=0, ge=0),
|
offset: int = Query(default=0, ge=0),
|
||||||
session: dict = Depends(require_auth),
|
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)")
|
where.append("EXISTS (SELECT 1 FROM exercise_skills es WHERE es.exercise_id = e.id AND es.skill_id = %s)")
|
||||||
params.append(skill_id)
|
params.append(skill_id)
|
||||||
|
|
||||||
# Volltext-Suche (tsvector)
|
if style_direction_id:
|
||||||
if search:
|
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)")
|
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 (primary_focus_name für Listen-Ansicht gemäß Spec-Beispiel „focus_area“-Label)
|
||||||
query = f"""
|
query = f"""
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,11 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Mapping: SMW-Integer → Shinkan-Stufenname
|
# Mapping: SMW-Integer → Shinkan-Stufenname
|
||||||
CAPABILITY_LEVEL_MAP = {
|
CAPABILITY_LEVEL_MAP = {
|
||||||
"1": "einsteiger",
|
"1": "basis",
|
||||||
"2": "grundlagen",
|
"2": "grundlagen",
|
||||||
"3": "aufbau",
|
"3": "aufbau",
|
||||||
"4": "fortgeschritten",
|
"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:
|
def map_capability_level(level_str: str) -> str:
|
||||||
"""Wandelt Integer-Level in benannte Stufe: "3" → "aufbau" """
|
"""Wandelt Integer-Level in kanonischen Stufen-Slug: "3" → "aufbau" """
|
||||||
return CAPABILITY_LEVEL_MAP.get(level_str.strip(), "einsteiger")
|
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.
|
Erstellt Skill-Zuordnungen aus PrimaryCapability + CapabilityLevel.
|
||||||
|
|
||||||
CapabilityLevel [3, 2] korrespondiert mit PrimaryCapability [Schnellkraft, Schnelligkeitsausdauer]
|
CapabilityLevel [3, 2] korrespondiert mit PrimaryCapability [Schnellkraft, Schnelligkeitsausdauer]
|
||||||
→ ergibt: [{skill: Schnellkraft, target_level: 3}, {skill: Schnelligkeitsausdauer, target_level: 2}]
|
→ target_level als kanonischer Slug (basis … optimierung), DB VARCHAR.
|
||||||
|
|
||||||
WICHTIG: target_level ist INTEGER (1-5), nicht String!
|
|
||||||
"""
|
"""
|
||||||
skills = mapped.get("skill_names", [])
|
skills = mapped.get("skill_names", [])
|
||||||
levels = mapped.get("skill_levels_raw", [])
|
levels = mapped.get("skill_levels_raw", [])
|
||||||
|
|
@ -283,18 +281,18 @@ def build_skill_assignments(mapped: dict) -> list[dict]:
|
||||||
assignments = []
|
assignments = []
|
||||||
for idx, skill_name in enumerate(skills):
|
for idx, skill_name in enumerate(skills):
|
||||||
level_str = levels[idx] if idx < len(levels) else "1"
|
level_str = levels[idx] if idx < len(levels) else "1"
|
||||||
# Konvertiere zu INTEGER statt String-Namen
|
|
||||||
try:
|
try:
|
||||||
target_level = int(level_str.strip())
|
raw = str(level_str).strip()
|
||||||
except (ValueError, AttributeError):
|
except (TypeError, AttributeError):
|
||||||
target_level = 1 # Fallback
|
raw = "1"
|
||||||
|
target_slug = map_capability_level(raw) if raw else "basis"
|
||||||
|
|
||||||
assignments.append({
|
assignments.append({
|
||||||
"skill_name": skill_name,
|
"skill_name": skill_name,
|
||||||
"target_level": target_level, # INTEGER 1-5
|
"target_level": target_slug,
|
||||||
"required_level": None, # Nicht im Wiki spezifiziert
|
"required_level": None,
|
||||||
"intensity": None, # Nicht im Wiki spezifiziert
|
"intensity": None,
|
||||||
"is_primary": idx == 0,
|
"is_primary": idx == 0,
|
||||||
})
|
})
|
||||||
return assignments
|
return assignments
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.7.6"
|
APP_VERSION = "0.7.7"
|
||||||
BUILD_DATE = "2026-04-27"
|
BUILD_DATE = "2026-04-27"
|
||||||
DB_SCHEMA_VERSION = "20260427028"
|
DB_SCHEMA_VERSION = "20260427029"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"auth": "1.0.0",
|
"auth": "1.0.0",
|
||||||
|
|
@ -23,6 +23,15 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.7.6",
|
||||||
"date": "2026-04-27",
|
"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 { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { sanitizeTrainerHtml } from '../utils/htmlUtils'
|
import { sanitizeTrainerHtml } from '../utils/htmlUtils'
|
||||||
|
import { formatSkillLevelSlug } from '../constants/skillLevels'
|
||||||
|
|
||||||
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
|
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
|
||||||
|
|
||||||
|
|
@ -242,15 +243,19 @@ function ExerciseDetailPage() {
|
||||||
<section className="card exercise-detail-section">
|
<section className="card exercise-detail-section">
|
||||||
<h2>Fähigkeiten</h2>
|
<h2>Fähigkeiten</h2>
|
||||||
<div className="exercise-tag-row">
|
<div className="exercise-tag-row">
|
||||||
{exercise.skills.map((s) => (
|
{exercise.skills.map((s) => {
|
||||||
<span key={s.id} className={`exercise-tag${s.is_primary ? ' exercise-tag--accent' : ''}`}>
|
const rl = formatSkillLevelSlug(s.required_level)
|
||||||
{s.skill_name}
|
const tl = formatSkillLevelSlug(s.target_level)
|
||||||
{s.intensity ? ` · ${s.intensity}` : ''}
|
const lvl =
|
||||||
{s.required_level || s.target_level
|
rl || tl ? ` (${[rl, tl].filter(Boolean).join(' → ')})` : ''
|
||||||
? ` (${[s.required_level, s.target_level].filter(Boolean).join(' → ')})`
|
return (
|
||||||
: ''}
|
<span key={s.id} className={`exercise-tag${s.is_primary ? ' exercise-tag--accent' : ''}`}>
|
||||||
</span>
|
{s.skill_name}
|
||||||
))}
|
{s.intensity ? ` · ${s.intensity}` : ''}
|
||||||
|
{lvl}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import api, { buildExerciseApiPayload } from '../utils/api'
|
import api, { buildExerciseApiPayload } from '../utils/api'
|
||||||
import RichTextEditor from '../components/RichTextEditor'
|
import RichTextEditor from '../components/RichTextEditor'
|
||||||
|
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
|
||||||
|
|
||||||
const INTENSITY_OPTIONS = [
|
const INTENSITY_OPTIONS = [
|
||||||
{ value: '', label: '—' },
|
{ value: '', label: '—' },
|
||||||
|
|
@ -10,15 +11,6 @@ const INTENSITY_OPTIONS = [
|
||||||
{ value: 'hoch', label: 'hoch' },
|
{ 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() {
|
function emptyForm() {
|
||||||
return {
|
return {
|
||||||
title: '',
|
title: '',
|
||||||
|
|
@ -78,8 +70,8 @@ function detailToForm(exercise) {
|
||||||
skill_id: s.skill_id,
|
skill_id: s.skill_id,
|
||||||
is_primary: s.is_primary || false,
|
is_primary: s.is_primary || false,
|
||||||
intensity: s.intensity || '',
|
intensity: s.intensity || '',
|
||||||
required_level: s.required_level || '',
|
required_level: normalizeSkillLevelSlug(s.required_level),
|
||||||
target_level: s.target_level || '',
|
target_level: normalizeSkillLevelSlug(s.target_level),
|
||||||
})) || [],
|
})) || [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -636,7 +628,7 @@ function ExerciseFormPage() {
|
||||||
value={row.required_level || ''}
|
value={row.required_level || ''}
|
||||||
onChange={(e) => updateSkillField(idx, 'required_level', e.target.value)}
|
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}>
|
<option key={`r-${o.value}`} value={o.value}>
|
||||||
von {o.label}
|
von {o.label}
|
||||||
</option>
|
</option>
|
||||||
|
|
@ -647,7 +639,7 @@ function ExerciseFormPage() {
|
||||||
value={row.target_level || ''}
|
value={row.target_level || ''}
|
||||||
onChange={(e) => updateSkillField(idx, 'target_level', e.target.value)}
|
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}>
|
<option key={`t-${o.value}`} value={o.value}>
|
||||||
bis {o.label}
|
bis {o.label}
|
||||||
</option>
|
</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 { Link } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
|
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
||||||
|
|
||||||
const PAGE_SIZE = 100
|
const PAGE_SIZE = 100
|
||||||
|
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
|
||||||
|
|
||||||
function ExercisesListPage() {
|
function ExercisesListPage() {
|
||||||
const [exercises, setExercises] = useState([])
|
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 [loading, setLoading] = useState(true)
|
||||||
const [loadingMore, setLoadingMore] = useState(false)
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
const [offset, setOffset] = useState(0)
|
const [offset, setOffset] = useState(0)
|
||||||
const [hasMore, setHasMore] = useState(false)
|
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({
|
const [filters, setFilters] = useState({
|
||||||
focus_area: '',
|
focus_area: '',
|
||||||
|
style_direction_id: '',
|
||||||
|
training_type_id: '',
|
||||||
|
target_group_id: '',
|
||||||
|
skill_id: '',
|
||||||
|
skill_min_level: '',
|
||||||
|
skill_max_level: '',
|
||||||
visibility: '',
|
visibility: '',
|
||||||
status: '',
|
status: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
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
|
let cancelled = false
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setOffset(0)
|
setOffset(0)
|
||||||
try {
|
try {
|
||||||
const [batch, focusAreasData] = await Promise.all([
|
const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset: 0 })
|
||||||
api.listExercises({ ...filters, limit: PAGE_SIZE, offset: 0 }),
|
|
||||||
api.listFocusAreas(),
|
|
||||||
])
|
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setExercises(batch)
|
setExercises(batch)
|
||||||
setFocusAreas(focusAreasData)
|
|
||||||
setHasMore(batch.length === PAGE_SIZE)
|
setHasMore(batch.length === PAGE_SIZE)
|
||||||
setOffset(batch.length)
|
setOffset(batch.length)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -45,13 +122,13 @@ function ExercisesListPage() {
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [filters])
|
}, [queryBase, catalogsReady])
|
||||||
|
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
if (loadingMore || !hasMore) return
|
if (loadingMore || !hasMore) return
|
||||||
setLoadingMore(true)
|
setLoadingMore(true)
|
||||||
try {
|
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])
|
setExercises((prev) => [...prev, ...batch])
|
||||||
setHasMore(batch.length === PAGE_SIZE)
|
setHasMore(batch.length === PAGE_SIZE)
|
||||||
setOffset((o) => o + batch.length)
|
setOffset((o) => o + batch.length)
|
||||||
|
|
@ -72,7 +149,7 @@ function ExercisesListPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (!catalogsReady || loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||||
<div className="spinner"></div>
|
<div className="spinner"></div>
|
||||||
|
|
@ -100,6 +177,32 @@ function ExercisesListPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card exercise-filters-compact" style={{ marginBottom: '12px' }}>
|
<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>
|
<div>
|
||||||
<label className="form-label">Fokus</label>
|
<label className="form-label">Fokus</label>
|
||||||
<select
|
<select
|
||||||
|
|
@ -108,13 +211,103 @@ function ExercisesListPage() {
|
||||||
onChange={(e) => setFilters({ ...filters, focus_area: e.target.value })}
|
onChange={(e) => setFilters({ ...filters, focus_area: e.target.value })}
|
||||||
>
|
>
|
||||||
<option value="">Alle</option>
|
<option value="">Alle</option>
|
||||||
{focusAreas.map((fa) => (
|
{catalogs.focusAreas.map((fa) => (
|
||||||
<option key={fa.id} value={fa.id}>
|
<option key={fa.id} value={fa.id}>
|
||||||
{fa.icon} {fa.name}
|
{fa.icon} {fa.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="form-label">Sichtbarkeit</label>
|
<label className="form-label">Sichtbarkeit</label>
|
||||||
<select
|
<select
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user