shinkan-jinkendo/frontend/src/pages/ExercisesListPage.jsx
Lars 8c9c97bedb
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 40s
refactor: simplify page layout for improved responsiveness
- Removed constrained width classes from multiple pages to allow full-width layout, enhancing adaptability on larger screens.
- Updated app.css to eliminate unnecessary max-width properties, ensuring a more fluid design across various components.
- Adjusted styles in Navigation and other pages for consistent full-width presentation, improving user experience on diverse devices.
2026-05-05 12:41:59 +02:00

732 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import MultiSelectCombo from '../components/MultiSelectCombo'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
const PAGE_SIZE = 100
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
const INITIAL_FILTERS = {
focus_area_ids: [],
style_direction_ids: [],
training_type_ids: [],
target_group_ids: [],
skill_ids: [],
skill_min_level: '',
skill_max_level: '',
visibility_any: [],
status_any: [],
}
function levelOptionShort(levelStr) {
const o = LEVEL_FILTER_OPTS.find((x) => String(x.level) === String(levelStr))
return o ? String(o.level) : String(levelStr)
}
function ExercisesListPage() {
const [exercises, setExercises] = useState([])
const [catalogs, setCatalogs] = useState({
focusAreas: [],
styleDirections: [],
trainingTypes: [],
targetGroups: [],
skills: [],
})
const [catalogsReady, setCatalogsReady] = useState(false)
const [listFetching, setListFetching] = useState(false)
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(() => ({ ...INITIAL_FILTERS }))
const [filterModalOpen, setFilterModalOpen] = useState(false)
const [pageTab, setPageTab] = useState('list')
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
return () => clearTimeout(t)
}, [searchInput])
useEffect(() => {
const t = setTimeout(() => setDebouncedAiSearch(aiSearchInput.trim()), 400)
return () => clearTimeout(t)
}, [aiSearchInput])
useEffect(() => {
if (!filterModalOpen) return
const onKey = (e) => {
if (e.key === 'Escape') setFilterModalOpen(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [filterModalOpen])
const focusOptions = useMemo(
() =>
catalogs.focusAreas.map((fa) => ({
id: fa.id,
label: `${fa.icon || ''} ${fa.name || ''}`.trim(),
})),
[catalogs.focusAreas]
)
const styleOptions = useMemo(
() => catalogs.styleDirections.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
[catalogs.styleDirections]
)
const trainingTypeOptions = useMemo(
() => catalogs.trainingTypes.map((t) => ({ id: t.id, label: t.name || String(t.id) })),
[catalogs.trainingTypes]
)
const targetGroupOptions = useMemo(
() => catalogs.targetGroups.map((g) => ({ id: g.id, label: g.name || String(g.id) })),
[catalogs.targetGroups]
)
const skillOptions = useMemo(
() => catalogs.skills.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
[catalogs.skills]
)
const visibilityOptions = useMemo(
() => [
{ id: 'private', label: 'Privat' },
{ id: 'club', label: 'Verein' },
{ id: 'official', label: 'Offiziell' },
],
[]
)
const statusOptions = useMemo(
() => [
{ id: 'draft', label: 'Entwurf' },
{ id: 'in_review', label: 'In Prüfung' },
{ id: 'approved', label: 'Freigegeben' },
{ id: 'archived', label: 'Archiviert' },
],
[]
)
const filterChips = useMemo(() => {
const chips = []
;(filters.focus_area_ids || []).forEach((id) => {
const opt = focusOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `fa-${id}`,
label: `Fokus: ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
focus_area_ids: prev.focus_area_ids.filter((x) => String(x) !== String(id)),
})),
})
})
;(filters.style_direction_ids || []).forEach((id) => {
const opt = styleOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `sd-${id}`,
label: `Stil: ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
style_direction_ids: prev.style_direction_ids.filter((x) => String(x) !== String(id)),
})),
})
})
;(filters.training_type_ids || []).forEach((id) => {
const opt = trainingTypeOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `tt-${id}`,
label: `Trainingsstil: ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
training_type_ids: prev.training_type_ids.filter((x) => String(x) !== String(id)),
})),
})
})
;(filters.target_group_ids || []).forEach((id) => {
const opt = targetGroupOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `tg-${id}`,
label: `Zielgruppe: ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
target_group_ids: prev.target_group_ids.filter((x) => String(x) !== String(id)),
})),
})
})
;(filters.skill_ids || []).forEach((id) => {
const opt = skillOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `sk-${id}`,
label: `Fähigkeit: ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
skill_ids: prev.skill_ids.filter((x) => String(x) !== String(id)),
})),
})
})
if (filters.skill_min_level || filters.skill_max_level) {
const a = filters.skill_min_level ? levelOptionShort(filters.skill_min_level) : '…'
const b = filters.skill_max_level ? levelOptionShort(filters.skill_max_level) : '…'
chips.push({
key: 'skill-levels',
label: `Stufe ${a}${b}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
skill_min_level: '',
skill_max_level: '',
})),
})
}
;(filters.visibility_any || []).forEach((id) => {
const opt = visibilityOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `vis-${id}`,
label: `Sichtbarkeit: ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
visibility_any: prev.visibility_any.filter((x) => String(x) !== String(id)),
})),
})
})
;(filters.status_any || []).forEach((id) => {
const opt = statusOptions.find((o) => String(o.id) === String(id))
chips.push({
key: `st-${id}`,
label: `Status: ${opt?.label ?? id}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
status_any: prev.status_any.filter((x) => String(x) !== String(id)),
})),
})
})
return chips
}, [
filters,
focusOptions,
styleOptions,
trainingTypeOptions,
targetGroupOptions,
skillOptions,
visibilityOptions,
statusOptions,
])
/** Für Browser-/datalist-Vorschläge (aktuelle Treffer-Titel, begrenzt) */
const searchTitleSuggestions = useMemo(() => {
const titles = exercises.map((e) => (e.title || '').trim()).filter(Boolean)
return [...new Set(titles)].slice(0, 80)
}, [exercises])
const queryBase = useMemo(() => {
const q = {}
const n = (v) => (v === '' || v == null ? undefined : Number(v))
const ids = (arr) =>
Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined
const fa = ids(filters.focus_area_ids)
if (fa?.length) q.focus_area_ids = fa
const sd = ids(filters.style_direction_ids)
if (sd?.length) q.style_direction_ids = sd
const tt = ids(filters.training_type_ids)
if (tt?.length) q.training_type_ids = tt
const tg = ids(filters.target_group_ids)
if (tg?.length) q.target_group_ids = tg
const sk = ids(filters.skill_ids)
if (sk?.length) q.skill_ids = sk
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_any?.length) q.visibility_any = [...filters.visibility_any]
if (filters.status_any?.length) q.status_any = [...filters.status_any]
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 || pageTab !== 'list') return
let cancelled = false
const run = async () => {
setListFetching(true)
setOffset(0)
try {
const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset: 0 })
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) setListFetching(false)
}
}
run()
return () => {
cancelled = true
}
}, [queryBase, catalogsReady, pageTab])
const loadMore = async () => {
if (loadingMore || !hasMore) return
setLoadingMore(true)
try {
const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset })
setExercises((prev) => [...prev, ...batch])
setHasMore(batch.length === PAGE_SIZE)
setOffset((o) => o + batch.length)
} catch (err) {
alert('Fehler: ' + err.message)
} finally {
setLoadingMore(false)
}
}
const handleDelete = async (exercise) => {
if (!confirm(`Übung "${exercise.title}" wirklich löschen?`)) return
try {
await api.deleteExercise(exercise.id)
setExercises((prev) => prev.filter((e) => e.id !== exercise.id))
} catch (err) {
alert('Fehler beim Löschen: ' + err.message)
}
}
const resetAllFilters = useCallback(() => setFilters({ ...INITIAL_FILTERS }), [])
if (!catalogsReady && pageTab === 'list') {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner"></div>
<p>Lade Kataloge</p>
</div>
)
}
return (
<div className="app-page">
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '12px',
flexWrap: 'wrap',
gap: '8px',
}}
>
<h1 style={{ fontSize: '1.35rem' }}>Übungen</h1>
{pageTab === 'list' ? (
<Link to="/exercises/new" className="btn btn-primary">
+ Neu
</Link>
) : (
<span />
)}
</div>
<div
role="tablist"
aria-label="Übungen Bereiche"
style={{ display: 'flex', gap: '8px', marginBottom: '14px', flexWrap: 'wrap' }}
>
<button
type="button"
role="tab"
aria-selected={pageTab === 'list'}
className={pageTab === 'list' ? 'btn btn-primary' : 'btn btn-secondary'}
onClick={() => setPageTab('list')}
>
Liste
</button>
<button
type="button"
role="tab"
aria-selected={pageTab === 'progression'}
className={pageTab === 'progression' ? 'btn btn-primary' : 'btn btn-secondary'}
onClick={() => setPageTab('progression')}
>
Progressionsgraphen
</button>
</div>
{pageTab === 'progression' ? (
<ExerciseProgressionGraphPanel />
) : (
<>
<div className="card exercise-search-bar" style={{ marginBottom: '12px' }}>
<label className="form-label">Volltextsuche (Titel, Ziel, )</label>
<datalist id="exercise-search-titles">
{searchTitleSuggestions.map((t) => (
<option key={t} value={t} />
))}
</datalist>
<input
type="search"
className="form-input"
placeholder="Suchbegriffe…"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
autoComplete="on"
name="exercise-fulltext-search"
list="exercise-search-titles"
enterKeyHint="search"
style={{ marginBottom: '10px' }}
/>
<label className="form-label">Ergänzende Suche / KI-Vorbereitung (Beta)</label>
<input
type="search"
className="form-input"
placeholder="zweiter Begriff — zusätzliche Volltextsuche (ODER)"
value={aiSearchInput}
onChange={(e) => setAiSearchInput(e.target.value)}
autoComplete="on"
name="exercise-ai-search"
list="exercise-search-titles"
enterKeyHint="search"
/>
<div className="exercise-search-bar__actions">
<button type="button" className="btn btn-secondary exercise-filter-trigger" onClick={() => setFilterModalOpen(true)}>
Filter
{filterChips.length > 0 ? (
<span className="exercise-filter-badge" aria-hidden>
{filterChips.length}
</span>
) : null}
</button>
{filterChips.length > 0 ? (
<button type="button" className="btn" onClick={resetAllFilters}>
Alle entfernen
</button>
) : null}
</div>
{filterChips.length > 0 ? (
<div className="exercise-filter-chips-row" role="list" aria-label="Aktive Filter">
{filterChips.map((c) => (
<button
key={c.key}
type="button"
role="listitem"
className="exercise-filter-chip"
title="Filter entfernen"
onClick={() => c.onRemove()}
>
<span className="exercise-filter-chip__text">{c.label}</span>
<span className="exercise-filter-chip__x" aria-hidden>
×
</span>
</button>
))}
</div>
) : null}
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '10px', marginBottom: 0 }}>
Trefferliste aktualisiert sich kurz nach Eingabe. Titel der aktuellen Liste erscheinen als Vorschläge (Pfeil im
Feld). Fachliche Filter über Filter zwischen Feldern UND, Auswahl mehrerer Werte je Feld mit ODER.
</p>
</div>
{filterModalOpen && (
<div
className="admin-modal-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) setFilterModalOpen(false)
}}
>
<div
className="admin-modal-sheet exercise-filter-modal"
role="dialog"
aria-modal="true"
aria-labelledby="exercise-filter-modal-title"
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 id="exercise-filter-modal-title" className="admin-modal-sheet__title">
Übungen filtern
</h3>
<button
type="button"
className="btn btn-secondary admin-modal-sheet__close"
onClick={() => setFilterModalOpen(false)}
>
Schließen
</button>
</div>
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
<p style={{ fontSize: '13px', color: 'var(--text2)', marginTop: 0, marginBottom: '14px' }}>
Zwischen den Bereichen gilt <strong>UND</strong>. Innerhalb eines Feldes werden mehrere Einträge mit{' '}
<strong>ODER</strong> verknüpft.
</p>
<section className="exercise-filter-section">
<h4 className="exercise-filter-section-title">Zuordnung</h4>
<div className="exercise-filters-modal-grid">
<div>
<label className="form-label">Fokus</label>
<MultiSelectCombo
value={filters.focus_area_ids}
onChange={(v) => setFilters({ ...filters, focus_area_ids: v })}
options={focusOptions}
placeholder="Fokus suchen oder „▼ Alle“ …"
/>
</div>
<div>
<label className="form-label">Stilrichtung</label>
<MultiSelectCombo
value={filters.style_direction_ids}
onChange={(v) => setFilters({ ...filters, style_direction_ids: v })}
options={styleOptions}
placeholder="Stilrichtung suchen …"
/>
</div>
<div>
<label className="form-label">Trainingsstil</label>
<MultiSelectCombo
value={filters.training_type_ids}
onChange={(v) => setFilters({ ...filters, training_type_ids: v })}
options={trainingTypeOptions}
placeholder="Trainingsstil suchen …"
/>
</div>
<div>
<label className="form-label">Zielgruppe</label>
<MultiSelectCombo
value={filters.target_group_ids}
onChange={(v) => setFilters({ ...filters, target_group_ids: v })}
options={targetGroupOptions}
placeholder="Zielgruppe suchen …"
/>
</div>
</div>
</section>
<section className="exercise-filter-section">
<h4 className="exercise-filter-section-title">Fähigkeit und zugehörige Stufe</h4>
<div className="exercise-filter-skill-block">
<label className="form-label">Fähigkeit</label>
<MultiSelectCombo
value={filters.skill_ids}
onChange={(v) => setFilters({ ...filters, skill_ids: v })}
options={skillOptions}
placeholder="Fähigkeit suchen …"
/>
<p className="exercise-filter-skill-hint">
Die Stufen filtern nach dem Niveau der Zuordnung Übung Fähigkeit (vonbis).
</p>
<div className="exercise-filter-skill-levels-row">
<label className="exercise-filter-skill-level-field">
<span className="exercise-filter-skill-level-caption">von</span>
<select
className="form-input exercise-filter-level-select"
title="Mindest-Stufe"
value={filters.skill_min_level}
onChange={(e) => setFilters({ ...filters, skill_min_level: e.target.value })}
>
<option value=""></option>
{LEVEL_FILTER_OPTS.map((o) => (
<option key={o.value} value={String(o.level)} title={o.label}>
{o.level}
</option>
))}
</select>
</label>
<span className="exercise-filter-skill-dash" aria-hidden>
</span>
<label className="exercise-filter-skill-level-field">
<span className="exercise-filter-skill-level-caption">bis</span>
<select
className="form-input exercise-filter-level-select"
title="Höchst-Stufe"
value={filters.skill_max_level}
onChange={(e) => setFilters({ ...filters, skill_max_level: e.target.value })}
>
<option value=""></option>
{LEVEL_FILTER_OPTS.map((o) => (
<option key={`m-${o.value}`} value={String(o.level)} title={o.label}>
{o.level}
</option>
))}
</select>
</label>
</div>
</div>
</section>
<section className="exercise-filter-section exercise-filter-section--last">
<h4 className="exercise-filter-section-title">Freigabe</h4>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two">
<div>
<label className="form-label">Sichtbarkeit</label>
<MultiSelectCombo
value={filters.visibility_any}
onChange={(v) => setFilters({ ...filters, visibility_any: v })}
options={visibilityOptions}
placeholder="Sichtbarkeit wählen …"
/>
</div>
<div>
<label className="form-label">Status</label>
<MultiSelectCombo
value={filters.status_any}
onChange={(v) => setFilters({ ...filters, status_any: v })}
options={statusOptions}
placeholder="Status wählen …"
/>
</div>
</div>
</section>
</div>
<div className="exercise-filter-modal__footer">
<button type="button" className="btn" onClick={resetAllFilters}>
Alle Filter zurücksetzen
</button>
<button type="button" className="btn btn-primary" onClick={() => setFilterModalOpen(false)}>
Fertig
</button>
</div>
</div>
</div>
)}
{listFetching && exercises.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '2rem' }}>
<div className="spinner"></div>
<p style={{ color: 'var(--text2)', marginTop: '12px' }}>Lade Übungen</p>
</div>
) : exercises.length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
Keine Übungen gefunden.
</p>
</div>
) : (
<>
{listFetching ? (
<p style={{ fontSize: '13px', color: 'var(--text3)', marginBottom: '8px' }}>Aktualisiere Treffer</p>
) : null}
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: '10px' }}>
{exercises.length} angezeigt
{hasMore ? ' · es gibt weitere Einträge' : ''}
</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '12px',
}}
>
{exercises.map((exercise) => (
<div key={exercise.id} className="card exercise-card">
<div className="exercise-card__body">
<h3 style={{ marginBottom: '8px', fontSize: '1.05rem', lineHeight: 1.3 }}>
<Link
to={`/exercises/${exercise.id}`}
style={{ color: 'inherit', textDecoration: 'none' }}
>
{exercise.title}
</Link>
</h3>
<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>
</div>
{exercise.summary && (
<p style={{ color: 'var(--text2)', fontSize: '13px', lineHeight: 1.4 }}>
{exercise.summary.length > 160
? `${exercise.summary.slice(0, 160)}`
: exercise.summary}
</p>
)}
</div>
<div className="exercise-card__actions">
<Link to={`/exercises/${exercise.id}`} className="btn btn-secondary">
Ansehen
</Link>
<Link to={`/exercises/${exercise.id}/edit`} className="btn btn-secondary">
Bearbeiten
</Link>
<button
type="button"
className="btn"
style={{
background: 'var(--danger)',
color: 'white',
border: 'none',
}}
onClick={() => handleDelete(exercise)}
>
Löschen
</button>
</div>
</div>
))}
</div>
{hasMore && (
<div style={{ textAlign: 'center', marginTop: '16px' }}>
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
{loadingMore ? 'Laden…' : 'Mehr laden'}
</button>
</div>
)}
</>
)}
</>
)}
</div>
)
}
export default ExercisesListPage