feat: update capability levels and enhance exercise filtering
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 5s
Test Suite / playwright-tests (push) Failing after 1m55s

- 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:
Lars 2026-04-27 18:25:23 +02:00
parent c6d20e4dec
commit 76098f5244
8 changed files with 418 additions and 56 deletions

View File

@ -0,0 +1,27 @@
-- Migration 029: Einheitliche Fähigkeitsstufen 15 (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 15)
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;

View File

@ -21,6 +21,53 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["exercises"])
# Kanonische Fähigkeitsstufen 15 (Ü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"""

View File

@ -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

View File

@ -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 (basisoptimierung), model_levels Namen 15",
"Ü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",

View File

@ -0,0 +1,37 @@
/** Kanonische Übungs-Fähigkeitsstufen 15 (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
}

View File

@ -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>
)}

View File

@ -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>

View File

@ -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