2026-04-27 18:25:23 +02:00
|
|
|
import React, { useState, useEffect, useMemo } from 'react'
|
2026-04-27 14:27:25 +02:00
|
|
|
import { Link } from 'react-router-dom'
|
|
|
|
|
import api from '../utils/api'
|
2026-04-27 18:25:23 +02:00
|
|
|
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
2026-04-27 14:27:25 +02:00
|
|
|
|
2026-04-27 14:48:46 +02:00
|
|
|
const PAGE_SIZE = 100
|
2026-04-27 18:25:23 +02:00
|
|
|
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
|
2026-04-27 14:48:46 +02:00
|
|
|
|
2026-04-27 14:27:25 +02:00
|
|
|
function ExercisesListPage() {
|
|
|
|
|
const [exercises, setExercises] = useState([])
|
2026-04-27 18:25:23 +02:00
|
|
|
const [catalogs, setCatalogs] = useState({
|
|
|
|
|
focusAreas: [],
|
|
|
|
|
styleDirections: [],
|
|
|
|
|
trainingTypes: [],
|
|
|
|
|
targetGroups: [],
|
|
|
|
|
skills: [],
|
|
|
|
|
})
|
|
|
|
|
const [catalogsReady, setCatalogsReady] = useState(false)
|
2026-04-27 14:27:25 +02:00
|
|
|
const [loading, setLoading] = useState(true)
|
2026-04-27 14:48:46 +02:00
|
|
|
const [loadingMore, setLoadingMore] = useState(false)
|
|
|
|
|
const [offset, setOffset] = useState(0)
|
|
|
|
|
const [hasMore, setHasMore] = useState(false)
|
2026-04-27 18:25:23 +02:00
|
|
|
const [searchInput, setSearchInput] = useState('')
|
|
|
|
|
const [aiSearchInput, setAiSearchInput] = useState('')
|
|
|
|
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
|
|
|
|
const [debouncedAiSearch, setDebouncedAiSearch] = useState('')
|
2026-04-27 14:27:25 +02:00
|
|
|
const [filters, setFilters] = useState({
|
|
|
|
|
focus_area: '',
|
2026-04-27 18:25:23 +02:00
|
|
|
style_direction_id: '',
|
|
|
|
|
training_type_id: '',
|
|
|
|
|
target_group_id: '',
|
|
|
|
|
skill_id: '',
|
|
|
|
|
skill_min_level: '',
|
|
|
|
|
skill_max_level: '',
|
2026-04-27 14:27:25 +02:00
|
|
|
visibility: '',
|
|
|
|
|
status: '',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-04-27 18:25:23 +02:00
|
|
|
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
|
2026-04-27 14:48:46 +02:00
|
|
|
let cancelled = false
|
|
|
|
|
const run = async () => {
|
|
|
|
|
setLoading(true)
|
|
|
|
|
setOffset(0)
|
|
|
|
|
try {
|
2026-04-27 18:25:23 +02:00
|
|
|
const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset: 0 })
|
2026-04-27 14:48:46 +02:00
|
|
|
if (cancelled) return
|
|
|
|
|
setExercises(batch)
|
|
|
|
|
setHasMore(batch.length === PAGE_SIZE)
|
|
|
|
|
setOffset(batch.length)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
console.error('Failed to load data:', err)
|
|
|
|
|
alert('Fehler beim Laden: ' + err.message)
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
if (!cancelled) setLoading(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
run()
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true
|
|
|
|
|
}
|
2026-04-27 18:25:23 +02:00
|
|
|
}, [queryBase, catalogsReady])
|
2026-04-27 14:27:25 +02:00
|
|
|
|
2026-04-27 14:48:46 +02:00
|
|
|
const loadMore = async () => {
|
|
|
|
|
if (loadingMore || !hasMore) return
|
|
|
|
|
setLoadingMore(true)
|
2026-04-27 14:27:25 +02:00
|
|
|
try {
|
2026-04-27 18:25:23 +02:00
|
|
|
const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset })
|
2026-04-27 14:48:46 +02:00
|
|
|
setExercises((prev) => [...prev, ...batch])
|
|
|
|
|
setHasMore(batch.length === PAGE_SIZE)
|
|
|
|
|
setOffset((o) => o + batch.length)
|
2026-04-27 14:27:25 +02:00
|
|
|
} catch (err) {
|
2026-04-27 14:48:46 +02:00
|
|
|
alert('Fehler: ' + err.message)
|
2026-04-27 14:27:25 +02:00
|
|
|
} finally {
|
2026-04-27 14:48:46 +02:00
|
|
|
setLoadingMore(false)
|
2026-04-27 14:27:25 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleDelete = async (exercise) => {
|
|
|
|
|
if (!confirm(`Übung "${exercise.title}" wirklich löschen?`)) return
|
|
|
|
|
try {
|
|
|
|
|
await api.deleteExercise(exercise.id)
|
2026-04-27 14:48:46 +02:00
|
|
|
setExercises((prev) => prev.filter((e) => e.id !== exercise.id))
|
2026-04-27 14:27:25 +02:00
|
|
|
} catch (err) {
|
|
|
|
|
alert('Fehler beim Löschen: ' + err.message)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-27 18:25:23 +02:00
|
|
|
if (!catalogsReady || loading) {
|
2026-04-27 14:27:25 +02:00
|
|
|
return (
|
|
|
|
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
|
|
|
|
<div className="spinner"></div>
|
|
|
|
|
<p>Laden...</p>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-04-27 14:48:46 +02:00
|
|
|
<div style={{ padding: '12px', maxWidth: '1200px', margin: '0 auto' }}>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
display: 'flex',
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
marginBottom: '12px',
|
|
|
|
|
flexWrap: 'wrap',
|
|
|
|
|
gap: '8px',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<h1 style={{ fontSize: '1.35rem' }}>Übungen</h1>
|
|
|
|
|
<Link to="/exercises/new" className="btn btn-primary">
|
|
|
|
|
+ Neu
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
2026-04-27 14:27:25 +02:00
|
|
|
|
2026-04-27 14:48:46 +02:00
|
|
|
<div className="card exercise-filters-compact" style={{ marginBottom: '12px' }}>
|
2026-04-27 18:25:23 +02:00
|
|
|
<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>
|
2026-04-27 14:48:46 +02:00
|
|
|
<div>
|
|
|
|
|
<label className="form-label">Fokus</label>
|
|
|
|
|
<select
|
|
|
|
|
className="form-input"
|
|
|
|
|
value={filters.focus_area}
|
|
|
|
|
onChange={(e) => setFilters({ ...filters, focus_area: e.target.value })}
|
2026-04-27 14:27:25 +02:00
|
|
|
>
|
2026-04-27 14:48:46 +02:00
|
|
|
<option value="">Alle</option>
|
2026-04-27 18:25:23 +02:00
|
|
|
{catalogs.focusAreas.map((fa) => (
|
2026-04-27 14:48:46 +02:00
|
|
|
<option key={fa.id} value={fa.id}>
|
|
|
|
|
{fa.icon} {fa.name}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
2026-04-27 14:27:25 +02:00
|
|
|
</div>
|
2026-04-27 18:25:23 +02:00
|
|
|
<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>
|
2026-04-27 14:48:46 +02:00
|
|
|
<div>
|
|
|
|
|
<label className="form-label">Sichtbarkeit</label>
|
|
|
|
|
<select
|
|
|
|
|
className="form-input"
|
|
|
|
|
value={filters.visibility}
|
|
|
|
|
onChange={(e) => setFilters({ ...filters, visibility: e.target.value })}
|
|
|
|
|
>
|
|
|
|
|
<option value="">Alle</option>
|
|
|
|
|
<option value="private">Privat</option>
|
|
|
|
|
<option value="club">Verein</option>
|
|
|
|
|
<option value="official">Offiziell</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label className="form-label">Status</label>
|
|
|
|
|
<select
|
|
|
|
|
className="form-input"
|
|
|
|
|
value={filters.status}
|
|
|
|
|
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
|
|
|
|
>
|
|
|
|
|
<option value="">Alle</option>
|
|
|
|
|
<option value="draft">Entwurf</option>
|
|
|
|
|
<option value="in_review">In Prüfung</option>
|
|
|
|
|
<option value="approved">Freigegeben</option>
|
|
|
|
|
<option value="archived">Archiviert</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-04-27 14:27:25 +02:00
|
|
|
|
2026-04-27 14:48:46 +02:00
|
|
|
{exercises.length === 0 ? (
|
|
|
|
|
<div className="card">
|
|
|
|
|
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
|
|
|
|
Keine Übungen gefunden.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '10px' }}>
|
|
|
|
|
{exercises.length} angezeigt
|
|
|
|
|
{hasMore ? ' · es gibt weitere Einträge' : ''}
|
|
|
|
|
</p>
|
2026-04-27 14:27:25 +02:00
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
display: 'grid',
|
2026-04-27 14:48:46 +02:00
|
|
|
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
|
|
|
|
gap: '12px',
|
2026-04-27 14:27:25 +02:00
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{exercises.map((exercise) => (
|
2026-04-27 14:48:46 +02:00
|
|
|
<div key={exercise.id} className="card exercise-card">
|
|
|
|
|
<div className="exercise-card__body">
|
|
|
|
|
<h3 style={{ marginBottom: '8px', fontSize: '1.05rem', lineHeight: 1.3 }}>
|
2026-04-27 14:27:25 +02:00
|
|
|
<Link
|
|
|
|
|
to={`/exercises/${exercise.id}`}
|
|
|
|
|
style={{ color: 'inherit', textDecoration: 'none' }}
|
|
|
|
|
>
|
|
|
|
|
{exercise.title}
|
|
|
|
|
</Link>
|
|
|
|
|
</h3>
|
2026-04-27 14:48:46 +02:00
|
|
|
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginBottom: '8px' }}>
|
|
|
|
|
{exercise.focus_area && (
|
|
|
|
|
<span className="exercise-tag exercise-tag--accent">{exercise.focus_area}</span>
|
|
|
|
|
)}
|
|
|
|
|
<span className="exercise-tag">{exercise.visibility}</span>
|
|
|
|
|
<span className="exercise-tag">{exercise.status}</span>
|
2026-04-27 14:27:25 +02:00
|
|
|
</div>
|
2026-04-27 14:48:46 +02:00
|
|
|
{exercise.summary && (
|
|
|
|
|
<p style={{ color: 'var(--text2)', fontSize: '13px', lineHeight: 1.4 }}>
|
|
|
|
|
{exercise.summary.length > 160
|
|
|
|
|
? `${exercise.summary.slice(0, 160)}…`
|
|
|
|
|
: exercise.summary}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
2026-04-27 14:27:25 +02:00
|
|
|
</div>
|
2026-04-27 14:48:46 +02:00
|
|
|
<div className="exercise-card__actions">
|
|
|
|
|
<Link to={`/exercises/${exercise.id}`} className="btn btn-secondary">
|
2026-04-27 14:27:25 +02:00
|
|
|
Ansehen
|
|
|
|
|
</Link>
|
2026-04-27 14:48:46 +02:00
|
|
|
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-secondary">
|
2026-04-27 14:27:25 +02:00
|
|
|
Bearbeiten
|
|
|
|
|
</Link>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="btn"
|
|
|
|
|
style={{
|
|
|
|
|
background: 'var(--danger)',
|
|
|
|
|
color: 'white',
|
|
|
|
|
border: 'none',
|
|
|
|
|
}}
|
|
|
|
|
onClick={() => handleDelete(exercise)}
|
|
|
|
|
>
|
|
|
|
|
Löschen
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-04-27 14:48:46 +02:00
|
|
|
{hasMore && (
|
|
|
|
|
<div style={{ textAlign: 'center', marginTop: '16px' }}>
|
|
|
|
|
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
|
|
|
|
|
{loadingMore ? 'Laden…' : 'Mehr laden'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-04-27 14:27:25 +02:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default ExercisesListPage
|