All checks were successful
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 56s
- Bumped app version to 0.8.100, reflecting recent updates. - Improved validation logic for combination exercises in the backend, ensuring proper handling of exercise variants. - Enhanced frontend components, including the ExercisePickerModal, to support filtering and displaying combination exercises. - Updated API payloads and utility functions to accommodate new exercise types and their properties. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
746 lines
29 KiB
JavaScript
746 lines
29 KiB
JavaScript
/**
|
||
* Übungssuche mit Volltext-, KI-/Semantikfeld (aktuell gleiche Engine wie Suche) und erweiterten Filtern.
|
||
* Paginierung bis max. 100 Treffer pro Request (API-Limit).
|
||
*/
|
||
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
||
import api from '../utils/api'
|
||
import { useAuth } from '../context/AuthContext'
|
||
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
||
import {
|
||
INITIAL_EXERCISE_LIST_FILTERS,
|
||
mergeExerciseListPrefsFromApi,
|
||
splitMnCatalogRules,
|
||
splitScalarCatalogRules,
|
||
} from '../constants/exerciseListFilters'
|
||
import MultiSelectCombo from './MultiSelectCombo'
|
||
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
|
||
import CatalogRulePicker from './CatalogRulePicker'
|
||
|
||
const PAGE_SIZE = 100
|
||
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
|
||
|
||
const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS }
|
||
|
||
/** Stub-Ziel für API-Validator (mind. Ziel oder Durchführung); Nutzer ergänzt Details in der Übungsbearbeitung. */
|
||
const QUICK_CREATE_GOAL_PLACEHOLDER =
|
||
'Aus der Trainingsplanung angelegt — bitte Ziel und Durchführung in der Übungsbearbeitung ergänzen.'
|
||
|
||
export default function ExercisePickerModal({
|
||
open,
|
||
onClose,
|
||
onSelectExercise,
|
||
multiSelect = false,
|
||
onSelectExercises = null,
|
||
enableQuickCreateDraft = false,
|
||
/** Wenn gesetzt: z. B. ['simple'] oder ['combination'] — sonst alle Übungsarten */
|
||
exerciseKindAny = undefined,
|
||
}) {
|
||
const { user } = useAuth()
|
||
const [catalogs, setCatalogs] = useState({
|
||
focusAreas: [],
|
||
styleDirections: [],
|
||
trainingTypes: [],
|
||
targetGroups: [],
|
||
skills: [],
|
||
})
|
||
const [catalogsReady, setCatalogsReady] = useState(false)
|
||
const [searchInput, setSearchInput] = useState('')
|
||
const [aiSearchInput, setAiSearchInput] = useState('')
|
||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||
const [debouncedAi, setDebouncedAi] = useState('')
|
||
const [filters, setFilters] = useState(() => ({ ...INITIAL_FILTERS }))
|
||
const [filterOpen, setFilterOpen] = useState(false)
|
||
const [list, setList] = useState([])
|
||
const [loading, setLoading] = useState(false)
|
||
const [loadingMore, setLoadingMore] = useState(false)
|
||
const [offset, setOffset] = useState(0)
|
||
const [hasMore, setHasMore] = useState(false)
|
||
const [multiPicked, setMultiPicked] = useState([])
|
||
const [quickOpen, setQuickOpen] = useState(false)
|
||
const [quickTitle, setQuickTitle] = useState('')
|
||
const [quickSummary, setQuickSummary] = useState('')
|
||
const [quickSaving, setQuickSaving] = useState(false)
|
||
|
||
const toggleMultiPick = (ex) => {
|
||
setMultiPicked((prev) =>
|
||
prev.some((p) => p.id === ex.id) ? prev.filter((p) => p.id !== ex.id) : [...prev, ex]
|
||
)
|
||
}
|
||
|
||
useEffect(() => {
|
||
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 350)
|
||
return () => clearTimeout(t)
|
||
}, [searchInput])
|
||
|
||
useEffect(() => {
|
||
const t = setTimeout(() => setDebouncedAi(aiSearchInput.trim()), 350)
|
||
return () => clearTimeout(t)
|
||
}, [aiSearchInput])
|
||
|
||
useEffect(() => {
|
||
if (!open) return
|
||
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 (e) {
|
||
console.error(e)
|
||
if (!cancelled) setCatalogsReady(true)
|
||
}
|
||
})()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [open])
|
||
|
||
useEffect(() => {
|
||
if (!open) {
|
||
setSearchInput('')
|
||
setAiSearchInput('')
|
||
setDebouncedSearch('')
|
||
setDebouncedAi('')
|
||
setFilters({ ...INITIAL_FILTERS })
|
||
setFilterOpen(false)
|
||
setList([])
|
||
setOffset(0)
|
||
setHasMore(false)
|
||
setMultiPicked([])
|
||
setQuickOpen(false)
|
||
setQuickTitle('')
|
||
setQuickSummary('')
|
||
setQuickSaving(false)
|
||
return
|
||
}
|
||
setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs))
|
||
}, [open, user?.exercise_list_prefs])
|
||
|
||
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 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 fMn = splitMnCatalogRules(filters.focus_rules)
|
||
if (fMn.includeIds.length) q.focus_area_must_include_ids = fMn.includeIds
|
||
if (fMn.excludeIds.length) q.focus_area_must_exclude_ids = fMn.excludeIds
|
||
if (filters.focus_only_without) q.focus_only_without_focus_areas = true
|
||
|
||
const fa = ids(filters.focus_area_ids)
|
||
if (fa?.length) q.focus_area_ids = fa
|
||
|
||
const sdMn = splitMnCatalogRules(filters.style_direction_rules)
|
||
if (sdMn.includeIds.length) q.style_direction_must_include_ids = sdMn.includeIds
|
||
if (sdMn.excludeIds.length) q.style_direction_must_exclude_ids = sdMn.excludeIds
|
||
const sdLegacy = ids(filters.style_direction_ids)
|
||
if (sdLegacy?.length) q.style_direction_ids = sdLegacy
|
||
|
||
const ttMn = splitMnCatalogRules(filters.training_type_rules)
|
||
if (ttMn.includeIds.length) q.training_type_must_include_ids = ttMn.includeIds
|
||
if (ttMn.excludeIds.length) q.training_type_must_exclude_ids = ttMn.excludeIds
|
||
const ttLegacy = ids(filters.training_type_ids)
|
||
if (ttLegacy?.length) q.training_type_ids = ttLegacy
|
||
|
||
const tgMn = splitMnCatalogRules(filters.target_group_rules)
|
||
if (tgMn.includeIds.length) q.target_group_must_include_ids = tgMn.includeIds
|
||
if (tgMn.excludeIds.length) q.target_group_must_exclude_ids = tgMn.excludeIds
|
||
const tgLegacy = ids(filters.target_group_ids)
|
||
if (tgLegacy?.length) q.target_group_ids = tgLegacy
|
||
|
||
const visMn = splitScalarCatalogRules(filters.visibility_rules)
|
||
if (visMn.includeVals.length) q.visibility_any = visMn.includeVals
|
||
if (visMn.excludeVals.length) q.visibility_exclude_any = visMn.excludeVals
|
||
|
||
const stMn = splitScalarCatalogRules(filters.status_rules)
|
||
if (stMn.includeVals.length) q.status_any = stMn.includeVals
|
||
if (stMn.excludeVals.length) q.status_exclude_any = stMn.excludeVals
|
||
|
||
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.exclude_without_focus) q.exclude_without_focus = true
|
||
if (filters.include_archived) q.include_archived = true
|
||
if (debouncedSearch) q.search = debouncedSearch
|
||
if (debouncedAi) q.ai_search = debouncedAi
|
||
if (
|
||
Array.isArray(exerciseKindAny) &&
|
||
exerciseKindAny.length > 0
|
||
) {
|
||
q.exercise_kind_any = exerciseKindAny
|
||
}
|
||
return q
|
||
}, [filters, debouncedSearch, debouncedAi, exerciseKindAny])
|
||
|
||
const reload = useCallback(async () => {
|
||
if (!open || !catalogsReady) return
|
||
setLoading(true)
|
||
setOffset(0)
|
||
try {
|
||
const batch = await api.listExercises({
|
||
...queryBase,
|
||
include_archived: true,
|
||
include_variants: true,
|
||
limit: PAGE_SIZE,
|
||
offset: 0,
|
||
})
|
||
setList(Array.isArray(batch) ? batch : [])
|
||
setHasMore(batch?.length === PAGE_SIZE)
|
||
setOffset(batch?.length ?? 0)
|
||
} catch (e) {
|
||
console.error(e)
|
||
alert(e.message || 'Laden fehlgeschlagen')
|
||
setList([])
|
||
setHasMore(false)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [open, catalogsReady, queryBase])
|
||
|
||
useEffect(() => {
|
||
reload()
|
||
}, [reload])
|
||
|
||
const loadMore = async () => {
|
||
if (!hasMore || loadingMore || loading) return
|
||
setLoadingMore(true)
|
||
try {
|
||
const batch = await api.listExercises({
|
||
...queryBase,
|
||
include_archived: true,
|
||
include_variants: true,
|
||
limit: PAGE_SIZE,
|
||
offset,
|
||
})
|
||
setList((prev) => [...prev, ...(Array.isArray(batch) ? batch : [])])
|
||
setHasMore(batch?.length === PAGE_SIZE)
|
||
setOffset((o) => o + (batch?.length ?? 0))
|
||
} catch (e) {
|
||
console.error(e)
|
||
alert(e.message || 'Mehr laden fehlgeschlagen')
|
||
} finally {
|
||
setLoadingMore(false)
|
||
}
|
||
}
|
||
|
||
const resetFilters = () => setFilters({ ...INITIAL_FILTERS })
|
||
|
||
const submitQuickCreate = async () => {
|
||
const title = (quickTitle || '').trim()
|
||
if (title.length < 3) {
|
||
alert('Titel: mindestens 3 Zeichen.')
|
||
return
|
||
}
|
||
const summaryRaw = (quickSummary || '').trim()
|
||
setQuickSaving(true)
|
||
try {
|
||
const created = await api.createExercise({
|
||
title,
|
||
summary: summaryRaw || null,
|
||
goal: QUICK_CREATE_GOAL_PLACEHOLDER,
|
||
execution: null,
|
||
visibility: 'private',
|
||
status: 'draft',
|
||
equipment: [],
|
||
focus_areas_multi: [],
|
||
training_styles_multi: [],
|
||
training_types_multi: [],
|
||
target_groups_multi: [],
|
||
age_groups: [],
|
||
skills: [],
|
||
club_id: null,
|
||
})
|
||
if (!created?.id) {
|
||
throw new Error('Anlegen fehlgeschlagen')
|
||
}
|
||
if (multiSelect && typeof onSelectExercises === 'function') {
|
||
await Promise.resolve(onSelectExercises([created]))
|
||
} else if (typeof onSelectExercise === 'function') {
|
||
await Promise.resolve(onSelectExercise(created))
|
||
}
|
||
onClose()
|
||
} catch (e) {
|
||
console.error(e)
|
||
alert(e.message || 'Übung konnte nicht angelegt werden')
|
||
} finally {
|
||
setQuickSaving(false)
|
||
}
|
||
}
|
||
|
||
if (!open) return null
|
||
|
||
return (
|
||
<div
|
||
className="admin-modal-backdrop"
|
||
role="presentation"
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) onClose()
|
||
}}
|
||
>
|
||
<div
|
||
className="admin-modal-sheet"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
style={{ maxWidth: '920px', width: '100%', maxHeight: '92vh', display: 'flex', flexDirection: 'column' }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className="admin-modal-sheet__header">
|
||
<h3 className="admin-modal-sheet__title">
|
||
{multiSelect ? 'Übungen auswählen' : 'Übung auswählen'}
|
||
</h3>
|
||
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
|
||
Schließen
|
||
</button>
|
||
</div>
|
||
|
||
{enableQuickCreateDraft ? (
|
||
<div
|
||
style={{
|
||
padding: '10px 1rem 12px',
|
||
borderBottom: '1px solid var(--border)',
|
||
flexShrink: 0,
|
||
background: 'var(--surface2)',
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
style={{ width: '100%' }}
|
||
onClick={() => setQuickOpen((v) => !v)}
|
||
aria-expanded={quickOpen}
|
||
>
|
||
{quickOpen ? 'Neue Übung ausblenden' : 'Neue Übung anlegen (Entwurf, privat)'}
|
||
</button>
|
||
{quickOpen ? (
|
||
<div style={{ marginTop: '12px', display: 'grid', gap: '10px' }}>
|
||
<p style={{ margin: 0, fontSize: '13px', color: 'var(--text2)', lineHeight: 1.45 }}>
|
||
Wird mit Sichtbarkeit <strong>privat</strong> und Status <strong>Entwurf</strong> gespeichert und
|
||
erscheint auf dem Dashboard zum Weiterbearbeiten. Nach dem Speichern wird die Übung direkt in den
|
||
Ablauf übernommen.
|
||
</p>
|
||
<div>
|
||
<label className="form-label" htmlFor="ex-picker-quick-title">
|
||
Titel
|
||
</label>
|
||
<input
|
||
id="ex-picker-quick-title"
|
||
type="text"
|
||
className="form-input"
|
||
value={quickTitle}
|
||
onChange={(e) => setQuickTitle(e.target.value)}
|
||
autoComplete="off"
|
||
minLength={3}
|
||
maxLength={300}
|
||
placeholder="z. B. Partnerübung Abwehr"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="form-label" htmlFor="ex-picker-quick-summary">
|
||
Kurzbeschreibung
|
||
</label>
|
||
<textarea
|
||
id="ex-picker-quick-summary"
|
||
className="form-input"
|
||
rows={3}
|
||
value={quickSummary}
|
||
onChange={(e) => setQuickSummary(e.target.value)}
|
||
placeholder="Optional: grobe Idee, Kontext aus der Planung …"
|
||
/>
|
||
</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', justifyContent: 'flex-end' }}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
disabled={quickSaving || (quickTitle || '').trim().length < 3}
|
||
onClick={submitQuickCreate}
|
||
>
|
||
{quickSaving ? 'Wird angelegt…' : 'Entwurf anlegen und übernehmen'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
<div style={{ padding: '0 1rem 0.75rem', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
||
<div style={{ display: 'grid', gap: '0.65rem' }}>
|
||
<div>
|
||
<label className="form-label">Volltextsuche</label>
|
||
<input
|
||
type="search"
|
||
className="form-input"
|
||
placeholder="Stichwort, Titelfragment…"
|
||
value={searchInput}
|
||
onChange={(e) => setSearchInput(e.target.value)}
|
||
autoComplete="off"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="form-label">
|
||
Semantisch /{' '}
|
||
<span title="aktuell gleiche Datenbanksuche wie Volltext; später KI-Verfeinerung möglich">KI-Feld</span>
|
||
</label>
|
||
<input
|
||
type="search"
|
||
className="form-input"
|
||
placeholder="zweites Suchkonzept oder Umschreibung…"
|
||
value={aiSearchInput}
|
||
onChange={(e) => setAiSearchInput(e.target.value)}
|
||
autoComplete="off"
|
||
/>
|
||
</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'center' }}>
|
||
<button type="button" className="btn btn-secondary" onClick={() => setFilterOpen(!filterOpen)}>
|
||
{filterOpen ? 'Filter ausblenden' : 'Erweiterte Filter'}
|
||
</button>
|
||
<button type="button" className="btn" onClick={resetFilters}>
|
||
Filter zurücksetzen
|
||
</button>
|
||
{loading && <span style={{ fontSize: '13px', color: 'var(--text2)' }}>Suche läuft…</span>}
|
||
</div>
|
||
|
||
{filterOpen && (
|
||
<div style={{ marginTop: '0.35rem', fontSize: '13px', color: 'var(--text2)' }}>
|
||
<p style={{ margin: '0 0 12px 0' }}>
|
||
Felder gelten mit <strong>UND</strong>. Kataloge: mehrere „+“ = alle zutreffend; „−“ schließt aus.
|
||
Sichtbarkeit/Status: mehrere „+“ = eine davon (ODER); „−“ blendet aus.
|
||
</p>
|
||
<ExerciseFocusRulePicker
|
||
focusOptions={focusOptions}
|
||
focusRules={filters.focus_rules}
|
||
focusOnlyWithout={filters.focus_only_without}
|
||
legacyFocusAreaIds={filters.focus_area_ids}
|
||
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||
/>
|
||
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--catalog" style={{ marginTop: 12 }}>
|
||
<CatalogRulePicker
|
||
label="Stilrichtung"
|
||
hint="+ alle nötig (UND). − verbietet Zuordnung."
|
||
options={styleOptions}
|
||
rules={filters.style_direction_rules}
|
||
rulesFieldName="style_direction_rules"
|
||
placeholder="Stil …"
|
||
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||
/>
|
||
<CatalogRulePicker
|
||
label="Trainingsstil"
|
||
hint="+ alle nötig (UND). − verbietet Zuordnung."
|
||
options={trainingTypeOptions}
|
||
rules={filters.training_type_rules}
|
||
rulesFieldName="training_type_rules"
|
||
placeholder="Trainingsstil …"
|
||
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||
/>
|
||
<CatalogRulePicker
|
||
label="Zielgruppe"
|
||
hint="+ alle nötig (UND). − verbietet Zuordnung."
|
||
options={targetGroupOptions}
|
||
rules={filters.target_group_rules}
|
||
rulesFieldName="target_group_rules"
|
||
placeholder="Gruppe …"
|
||
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||
/>
|
||
</div>
|
||
<div style={{ marginTop: 12 }}>
|
||
<label className="form-label">Fähigkeit</label>
|
||
<MultiSelectCombo
|
||
value={filters.skill_ids}
|
||
onChange={(v) => setFilters((f) => ({ ...f, skill_ids: v }))}
|
||
options={skillOptions}
|
||
placeholder="Fähigkeit …"
|
||
/>
|
||
<div className="exercise-filter-skill-levels-row" style={{ marginTop: 8 }}>
|
||
<select
|
||
className="form-input exercise-filter-level-select"
|
||
title="von"
|
||
value={filters.skill_min_level}
|
||
onChange={(e) => setFilters((f) => ({ ...f, skill_min_level: e.target.value }))}
|
||
>
|
||
<option value="">Stufe von</option>
|
||
{LEVEL_FILTER_OPTS.map((o) => (
|
||
<option key={o.value} value={String(o.level)}>
|
||
{o.level}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
className="form-input exercise-filter-level-select"
|
||
title="bis"
|
||
value={filters.skill_max_level}
|
||
onChange={(e) => setFilters((f) => ({ ...f, skill_max_level: e.target.value }))}
|
||
>
|
||
<option value="">Stufe bis</option>
|
||
{LEVEL_FILTER_OPTS.map((o) => (
|
||
<option key={`m-${o.value}`} value={String(o.level)}>
|
||
{o.level}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two exercise-filters-modal-grid--catalog" style={{ marginTop: 12 }}>
|
||
<CatalogRulePicker
|
||
label="Sichtbarkeit"
|
||
options={visibilityOptions}
|
||
rules={filters.visibility_rules}
|
||
rulesFieldName="visibility_rules"
|
||
idKind="string"
|
||
placeholder="Sichtbarkeit …"
|
||
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||
/>
|
||
<CatalogRulePicker
|
||
label="Status"
|
||
options={statusOptions}
|
||
rules={filters.status_rules}
|
||
rulesFieldName="status_rules"
|
||
idKind="string"
|
||
placeholder="Status …"
|
||
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
||
/>
|
||
</div>
|
||
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||
<label
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
cursor: filters.focus_only_without ? 'not-allowed' : 'pointer',
|
||
opacity: filters.focus_only_without ? 0.55 : 1,
|
||
}}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
disabled={!!filters.focus_only_without}
|
||
checked={!!filters.exclude_without_focus}
|
||
onChange={(e) =>
|
||
setFilters((f) => ({
|
||
...f,
|
||
exclude_without_focus: e.target.checked,
|
||
...(e.target.checked ? { focus_only_without: false } : {}),
|
||
}))
|
||
}
|
||
/>
|
||
<span>Ohne Fokus ausblenden</span>
|
||
</label>
|
||
<p style={{ margin: 0, fontSize: '12px', color: 'var(--text2)' }}>
|
||
Hinweis: Für die Planung werden archivierte Übungen bei der Suche immer mit eingeschlossen (bestehende
|
||
Zuordnungen).
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '12px 1rem' }}>
|
||
{!catalogsReady || (loading && list.length === 0) ? (
|
||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
||
<div className="spinner" />
|
||
</div>
|
||
) : list.length === 0 ? (
|
||
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>Keine Treffer.</p>
|
||
) : (
|
||
<>
|
||
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: 10 }}>
|
||
{list.length} angezeigt{hasMore ? ' · weiter unten „Mehr laden“' : ''}
|
||
</p>
|
||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||
{list.map((ex) => {
|
||
const picked = multiPicked.some((p) => p.id === ex.id)
|
||
const rowInner = (
|
||
<>
|
||
<strong style={{ display: 'block' }}>{ex.title}</strong>
|
||
{(ex.summary || '').trim().length > 0 && (
|
||
<span style={{ fontSize: '12px', color: 'var(--text2)' }}>
|
||
{(ex.summary || '').length > 120
|
||
? `${(ex.summary || '').slice(0, 120)}…`
|
||
: ex.summary}
|
||
</span>
|
||
)}
|
||
{ex.focus_area && (
|
||
<span className="exercise-tag exercise-tag--accent" style={{ marginTop: 6 }}>
|
||
{ex.focus_area}
|
||
</span>
|
||
)}
|
||
{(ex.exercise_kind || '').toLowerCase().trim() === 'combination' ? (
|
||
<span
|
||
className="exercise-tag"
|
||
style={{
|
||
marginTop: 6,
|
||
marginLeft: 6,
|
||
background: 'var(--accent-soft)',
|
||
color: 'var(--accent-dark)',
|
||
}}
|
||
>
|
||
Kombination
|
||
</span>
|
||
) : null}
|
||
</>
|
||
)
|
||
if (multiSelect) {
|
||
return (
|
||
<li key={ex.id}>
|
||
<label
|
||
className="tu-ex-picker-multi-row"
|
||
style={{
|
||
display: 'flex',
|
||
gap: '10px',
|
||
alignItems: 'flex-start',
|
||
width: '100%',
|
||
textAlign: 'left',
|
||
padding: '10px 12px',
|
||
marginBottom: 8,
|
||
borderRadius: '8px',
|
||
border: picked ? '2px solid var(--accent)' : '1px solid var(--border)',
|
||
background: 'var(--surface2)',
|
||
cursor: 'pointer',
|
||
boxSizing: 'border-box',
|
||
}}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={picked}
|
||
onChange={() => toggleMultiPick(ex)}
|
||
style={{ marginTop: '0.35rem', flexShrink: 0 }}
|
||
aria-label={ex.title ? `Auswahl: ${ex.title}` : 'Auswahl'}
|
||
/>
|
||
<div style={{ flex: 1, minWidth: 0 }}>{rowInner}</div>
|
||
</label>
|
||
</li>
|
||
)
|
||
}
|
||
return (
|
||
<li key={ex.id}>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
onSelectExercise(ex)
|
||
onClose()
|
||
}}
|
||
style={{
|
||
width: '100%',
|
||
textAlign: 'left',
|
||
padding: '10px 12px',
|
||
marginBottom: 8,
|
||
borderRadius: '8px',
|
||
border: '1px solid var(--border)',
|
||
background: 'var(--surface2)',
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
{rowInner}
|
||
</button>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
{hasMore && (
|
||
<div style={{ textAlign: 'center', marginTop: 12 }}>
|
||
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
|
||
{loadingMore ? 'Laden…' : 'Mehr laden'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
{multiSelect && typeof onSelectExercises === 'function' ? (
|
||
<div
|
||
className="exercise-picker-multi-footer"
|
||
style={{
|
||
position: 'sticky',
|
||
bottom: 0,
|
||
marginTop: 16,
|
||
paddingTop: 12,
|
||
borderTop: '1px solid var(--border)',
|
||
background: 'var(--surface)',
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: '10px',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
}}
|
||
>
|
||
<span style={{ fontSize: '0.92rem', color: 'var(--text2)' }}>
|
||
{multiPicked.length} ausgewählt
|
||
</span>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
onClick={() => setMultiPicked([])}
|
||
disabled={!multiPicked.length}
|
||
>
|
||
Auswahl leeren
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
disabled={!multiPicked.length}
|
||
onClick={() => {
|
||
onSelectExercises([...multiPicked])
|
||
onClose()
|
||
}}
|
||
>
|
||
Übernehmen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|