2026-04-29 06:15:26 +02:00
/ * *
* Übungssuche mit Volltext - , KI - / S e m a n t i k f e l d ( a k t u e l l g l e i c h e E n g i n e w i e S u c h e ) u n d e r w e i t e r t e n F i l t e r n .
* Paginierung bis max . 100 Treffer pro Request ( API - Limit ) .
* /
2026-05-14 08:59:06 +02:00
import React , { useState , useEffect , useMemo , useCallback , useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
2026-04-29 06:15:26 +02:00
import api from '../utils/api'
2026-05-06 13:52:24 +02:00
import { useAuth } from '../context/AuthContext'
2026-04-29 06:15:26 +02:00
import { SKILL _LEVEL _OPTIONS } from '../constants/skillLevels'
2026-05-21 15:08:35 +02:00
import { EXERCISE _VISIBILITY _FIELD _LABEL } from '../constants/exerciseGovernanceLabels'
2026-05-06 13:52:24 +02:00
import {
INITIAL _EXERCISE _LIST _FILTERS ,
mergeExerciseListPrefsFromApi ,
2026-05-06 21:20:19 +02:00
splitMnCatalogRules ,
splitScalarCatalogRules ,
2026-05-06 13:52:24 +02:00
} from '../constants/exerciseListFilters'
2026-05-20 10:59:17 +02:00
import SkillTreeMultiSelect from './SkillTreeMultiSelect'
2026-05-06 17:15:44 +02:00
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
2026-05-06 21:20:19 +02:00
import CatalogRulePicker from './CatalogRulePicker'
2026-05-22 19:10:16 +02:00
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
import {
buildQuickCreateAiPreview ,
buildQuickCreateExercisePayloadFromPreview ,
} from '../utils/exerciseAiQuickCreate'
2026-04-29 06:15:26 +02:00
const PAGE _SIZE = 100
const LEVEL _FILTER _OPTS = SKILL _LEVEL _OPTIONS . filter ( ( o ) => o . level != null )
2026-05-06 13:52:24 +02:00
const INITIAL _FILTERS = { ... INITIAL _EXERCISE _LIST _FILTERS }
2026-04-29 06:15:26 +02:00
2026-05-05 14:42:46 +02:00
export default function ExercisePickerModal ( {
open ,
onClose ,
onSelectExercise ,
multiSelect = false ,
onSelectExercises = null ,
2026-05-07 09:20:19 +02:00
enableQuickCreateDraft = false ,
2026-05-13 06:30:53 +02:00
/** Wenn gesetzt: z. B. ['simple'] oder ['combination'] — sonst alle Übungsarten */
exerciseKindAny = undefined ,
2026-05-05 14:42:46 +02:00
} ) {
2026-05-06 13:52:24 +02:00
const { user } = useAuth ( )
2026-04-29 06:15:26 +02:00
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 [ hasMore , setHasMore ] = useState ( false )
2026-05-05 14:42:46 +02:00
const [ multiPicked , setMultiPicked ] = useState ( [ ] )
2026-05-07 09:20:19 +02:00
const [ quickOpen , setQuickOpen ] = useState ( false )
const [ quickTitle , setQuickTitle ] = useState ( '' )
2026-05-22 19:01:01 +02:00
const [ quickSketch , setQuickSketch ] = useState ( '' )
const [ quickFocusAreaId , setQuickFocusAreaId ] = useState ( '' )
2026-05-07 09:20:19 +02:00
const [ quickSaving , setQuickSaving ] = useState ( false )
2026-05-22 19:01:01 +02:00
const [ quickAiError , setQuickAiError ] = useState ( '' )
2026-05-22 19:10:16 +02:00
const [ quickCreatePreview , setQuickCreatePreview ] = useState ( null )
const [ quickLibraryHits , setQuickLibraryHits ] = useState ( [ ] )
const [ quickLibraryLoading , setQuickLibraryLoading ] = useState ( false )
const [ debouncedQuickLibraryQ , setDebouncedQuickLibraryQ ] = useState ( '' )
2026-05-14 08:59:06 +02:00
const pickerScrollRef = useRef ( null )
2026-05-05 14:42:46 +02:00
const toggleMultiPick = ( ex ) => {
setMultiPicked ( ( prev ) =>
prev . some ( ( p ) => p . id === ex . id ) ? prev . filter ( ( p ) => p . id !== ex . id ) : [ ... prev , ex ]
)
}
2026-04-29 06:15:26 +02:00
useEffect ( ( ) => {
const t = setTimeout ( ( ) => setDebouncedSearch ( searchInput . trim ( ) ) , 350 )
return ( ) => clearTimeout ( t )
} , [ searchInput ] )
useEffect ( ( ) => {
const t = setTimeout ( ( ) => setDebouncedAi ( aiSearchInput . trim ( ) ) , 350 )
return ( ) => clearTimeout ( t )
} , [ aiSearchInput ] )
2026-05-22 19:10:16 +02:00
const quickLibraryQuery = useMemo ( ( ) => {
const t = ( quickTitle || '' ) . trim ( )
const s = ( quickSketch || '' ) . trim ( )
return [ t , s ] . filter ( Boolean ) . join ( ' ' ) . trim ( )
} , [ quickTitle , quickSketch ] )
useEffect ( ( ) => {
const t = setTimeout ( ( ) => setDebouncedQuickLibraryQ ( quickLibraryQuery ) , 400 )
return ( ) => clearTimeout ( t )
} , [ quickLibraryQuery ] )
useEffect ( ( ) => {
if ( ! open || ! quickOpen || ! catalogsReady ) {
setQuickLibraryHits ( [ ] )
return
}
if ( debouncedQuickLibraryQ . length < 3 ) {
setQuickLibraryHits ( [ ] )
return
}
let cancelled = false
; ( async ( ) => {
setQuickLibraryLoading ( true )
try {
const q = { search : debouncedQuickLibraryQ , include _archived : true , limit : 8 , offset : 0 }
if ( Array . isArray ( exerciseKindAny ) && exerciseKindAny . length > 0 ) {
q . exercise _kind _any = exerciseKindAny
}
const batch = await api . listExercises ( q )
if ( ! cancelled ) setQuickLibraryHits ( Array . isArray ( batch ) ? batch : [ ] )
} catch ( e ) {
console . error ( e )
if ( ! cancelled ) setQuickLibraryHits ( [ ] )
} finally {
if ( ! cancelled ) setQuickLibraryLoading ( false )
}
} ) ( )
return ( ) => {
cancelled = true
}
} , [ open , quickOpen , catalogsReady , debouncedQuickLibraryQ , exerciseKindAny ] )
2026-04-29 06:15:26 +02:00
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 ( ) ,
2026-05-20 10:59:17 +02:00
api . listSkillsCatalog ( ) ,
2026-04-29 06:15:26 +02:00
] )
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 ( [ ] )
setHasMore ( false )
2026-05-05 14:42:46 +02:00
setMultiPicked ( [ ] )
2026-05-07 09:20:19 +02:00
setQuickOpen ( false )
setQuickTitle ( '' )
2026-05-22 19:01:01 +02:00
setQuickSketch ( '' )
setQuickFocusAreaId ( '' )
2026-05-07 09:20:19 +02:00
setQuickSaving ( false )
2026-05-22 19:01:01 +02:00
setQuickAiError ( '' )
2026-05-22 19:10:16 +02:00
setQuickCreatePreview ( null )
setQuickLibraryHits ( [ ] )
setDebouncedQuickLibraryQ ( '' )
2026-05-06 13:52:24 +02:00
return
2026-04-29 06:15:26 +02:00
}
2026-05-06 13:52:24 +02:00
setFilters ( mergeExerciseListPrefsFromApi ( user ? . exercise _list _prefs ) )
} , [ open , user ? . exercise _list _prefs ] )
2026-04-29 06:15:26 +02:00
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 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
2026-05-06 21:20:19 +02:00
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
2026-05-06 17:15:44 +02:00
if ( filters . focus _only _without ) q . focus _only _without _focus _areas = true
2026-04-29 06:15:26 +02:00
const fa = ids ( filters . focus _area _ids )
if ( fa ? . length ) q . focus _area _ids = fa
2026-05-06 21:20:19 +02:00
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
2026-04-29 06:15:26 +02:00
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 )
2026-05-06 13:52:24 +02:00
if ( filters . exclude _without _focus ) q . exclude _without _focus = true
if ( filters . include _archived ) q . include _archived = true
2026-04-29 06:15:26 +02:00
if ( debouncedSearch ) q . search = debouncedSearch
if ( debouncedAi ) q . ai _search = debouncedAi
2026-05-13 06:30:53 +02:00
if (
Array . isArray ( exerciseKindAny ) &&
exerciseKindAny . length > 0
) {
q . exercise _kind _any = exerciseKindAny
}
2026-04-29 06:15:26 +02:00
return q
2026-05-13 06:30:53 +02:00
} , [ filters , debouncedSearch , debouncedAi , exerciseKindAny ] )
2026-04-29 06:15:26 +02:00
const reload = useCallback ( async ( ) => {
if ( ! open || ! catalogsReady ) return
setLoading ( true )
try {
const batch = await api . listExercises ( {
... queryBase ,
2026-05-06 13:52:24 +02:00
include _archived : true ,
2026-04-29 06:15:26 +02:00
include _variants : true ,
limit : PAGE _SIZE ,
offset : 0 ,
} )
setList ( Array . isArray ( batch ) ? batch : [ ] )
setHasMore ( batch ? . length === PAGE _SIZE )
} 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
2026-05-14 08:24:47 +02:00
const last = list [ list . length - 1 ]
if ( ! last ? . id || last . updated _at == null ) return
2026-04-29 06:15:26 +02:00
setLoadingMore ( true )
try {
const batch = await api . listExercises ( {
... queryBase ,
2026-05-06 13:52:24 +02:00
include _archived : true ,
2026-04-29 06:15:26 +02:00
include _variants : true ,
limit : PAGE _SIZE ,
2026-05-14 08:24:47 +02:00
cursor _updated _at :
typeof last . updated _at === 'string'
? last . updated _at
: new Date ( last . updated _at ) . toISOString ( ) ,
cursor _id : last . id ,
2026-04-29 06:15:26 +02:00
} )
setList ( ( prev ) => [ ... prev , ... ( Array . isArray ( batch ) ? batch : [ ] ) ] )
setHasMore ( batch ? . length === PAGE _SIZE )
} catch ( e ) {
console . error ( e )
alert ( e . message || 'Mehr laden fehlgeschlagen' )
} finally {
setLoadingMore ( false )
}
}
2026-05-14 08:59:06 +02:00
const rowVirtualizer = useVirtualizer ( {
count : list . length ,
getScrollElement : ( ) => pickerScrollRef . current ,
estimateSize : ( ) => 88 ,
overscan : 8 ,
getItemKey : ( index ) => String ( list [ index ] ? . id ? ? index ) ,
} )
2026-04-29 06:15:26 +02:00
const resetFilters = ( ) => setFilters ( { ... INITIAL _FILTERS } )
2026-05-22 19:10:16 +02:00
const adoptExistingExercise = async ( ex ) => {
if ( ! ex ? . id ) return
if ( multiSelect && typeof onSelectExercises === 'function' ) {
await Promise . resolve ( onSelectExercises ( [ ex ] ) )
} else if ( typeof onSelectExercise === 'function' ) {
await Promise . resolve ( onSelectExercise ( ex ) )
}
onClose ( )
}
const runQuickCreateAiSuggest = async ( ) => {
2026-05-07 09:20:19 +02:00
const title = ( quickTitle || '' ) . trim ( )
if ( title . length < 3 ) {
alert ( 'Titel: mindestens 3 Zeichen.' )
return
}
2026-05-22 19:01:01 +02:00
const sketch = ( quickSketch || '' ) . trim ( )
if ( ! sketch ) {
2026-05-22 19:10:16 +02:00
alert ( 'Bitte eine kurze Skizze / Idee eingeben.' )
2026-05-22 19:01:01 +02:00
return
}
const focusId = parseInt ( String ( quickFocusAreaId ) . trim ( ) , 10 )
if ( ! Number . isFinite ( focusId ) || focusId < 1 ) {
2026-05-22 19:10:16 +02:00
alert ( 'Bitte einen Fokusbereich wählen.' )
2026-05-22 19:01:01 +02:00
return
}
const focusRow = ( catalogs . focusAreas || [ ] ) . find ( ( x ) => Number ( x . id ) === focusId )
const focusHint = ( focusRow ? . name || '' ) . trim ( )
setQuickAiError ( '' )
2026-05-22 19:10:16 +02:00
setQuickCreatePreview ( null )
2026-05-07 09:20:19 +02:00
setQuickSaving ( true )
try {
2026-05-22 19:01:01 +02:00
const aiRes = await api . suggestExerciseAi ( {
title ,
goal : sketch ,
execution : '' ,
preparation : '' ,
trainer _notes : '' ,
focus _area _hint : focusHint || undefined ,
focus _areas _context : [ { focus _area _id : focusId , is _primary : true } ] ,
include _summary : true ,
include _skills : true ,
include _instructions : true ,
} )
2026-05-22 19:10:16 +02:00
const preview = buildQuickCreateAiPreview ( aiRes , { sketchPlain : sketch } )
if ( ! preview . hasSummaryProposal && ! preview . hasInstructionChoices && ! preview . hasSkillChoices ) {
throw new Error ( 'Die KI lieferte keinen verwertbaren Vorschlag.' )
}
setQuickCreatePreview ( preview )
} catch ( e ) {
console . error ( e )
const msg = e ? . message || String ( e )
setQuickAiError ( msg )
alert ( msg || 'KI-Vorschlag fehlgeschlagen' )
} finally {
setQuickSaving ( false )
}
}
const applyQuickCreatePreview = async ( ) => {
const title = ( quickTitle || '' ) . trim ( )
const sketch = ( quickSketch || '' ) . trim ( )
const focusId = parseInt ( String ( quickFocusAreaId ) . trim ( ) , 10 )
if ( ! quickCreatePreview ) return
setQuickSaving ( true )
setQuickAiError ( '' )
try {
const payload = buildQuickCreateExercisePayloadFromPreview ( quickCreatePreview , {
2026-05-07 09:20:19 +02:00
title ,
2026-05-22 19:01:01 +02:00
focusAreaId : focusId ,
sketchPlain : sketch ,
2026-05-07 09:20:19 +02:00
} )
2026-05-22 19:01:01 +02:00
const created = await api . createExercise ( payload )
2026-05-22 19:10:16 +02:00
if ( ! created ? . id ) throw new Error ( 'Anlegen fehlgeschlagen' )
await adoptExistingExercise ( created )
2026-05-07 09:20:19 +02:00
} catch ( e ) {
console . error ( e )
2026-05-22 19:01:01 +02:00
const msg = e ? . message || String ( e )
setQuickAiError ( msg )
alert ( msg || 'Übung konnte nicht angelegt werden' )
2026-05-07 09:20:19 +02:00
} finally {
setQuickSaving ( false )
}
}
2026-04-29 06:15:26 +02:00
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" >
2026-05-05 14:42:46 +02:00
< h3 className = "admin-modal-sheet__title" >
{ multiSelect ? 'Übungen auswählen' : 'Übung auswählen' }
< / h3 >
2026-04-29 06:15:26 +02:00
< button type = "button" className = "btn btn-secondary admin-modal-sheet__close" onClick = { onClose } >
Schließen
< / button >
< / div >
2026-05-07 09:20:19 +02:00
{ 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 }
>
2026-05-22 19:10:16 +02:00
{ quickOpen ? 'Neue Übung ausblenden' : 'Bibliothek prüfen / neu anlegen' }
2026-05-07 09:20:19 +02:00
< / button >
2026-05-22 19:01:01 +02:00
{ quickOpen ? (
2026-05-07 09:20:19 +02:00
< div style = { { marginTop : '12px' , display : 'grid' , gap : '10px' } } >
< p style = { { margin : 0 , fontSize : '13px' , color : 'var(--text2)' , lineHeight : 1.45 } } >
2026-05-22 19:10:16 +02:00
Zuerst prüft die Suche die < strong > Bibliothek < / strong > ( Titel + Skizze ) . Passt eine Übung , direkt
übernehmen . Sonst erzeugt die KI einen Vorschlag — Anleitung , Kurzbeschreibung , Fähigkeiten — den du
im Dialog prüfst , bevor gespeichert wird .
2026-05-07 09:20:19 +02:00
< / p >
< div >
< label className = "form-label" htmlFor = "ex-picker-quick-title" >
2026-05-22 19:01:01 +02:00
Titel *
2026-05-07 09:20:19 +02:00
< / 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 >
2026-05-22 19:01:01 +02:00
< label className = "form-label" htmlFor = "ex-picker-quick-focus" >
Fokusbereich *
< / label >
< select
id = "ex-picker-quick-focus"
className = "form-input"
value = { quickFocusAreaId }
onChange = { ( e ) => setQuickFocusAreaId ( e . target . value ) }
disabled = { ! catalogsReady }
>
< option value = "" > — wählen — < / option >
{ ( catalogs . focusAreas || [ ] ) . map ( ( fa ) => (
< option key = { fa . id } value = { String ( fa . id ) } >
{ ` ${ fa . icon ? ` ${ fa . icon } ` : '' } ${ fa . name || ` # ${ fa . id } ` } ` . trim ( ) }
< / option >
) ) }
< / select >
< / div >
< div >
< label className = "form-label" htmlFor = "ex-picker-quick-sketch" >
Skizze / Idee *
2026-05-07 09:20:19 +02:00
< / label >
< textarea
2026-05-22 19:01:01 +02:00
id = "ex-picker-quick-sketch"
2026-05-07 09:20:19 +02:00
className = "form-input"
2026-05-22 19:01:01 +02:00
rows = { 4 }
value = { quickSketch }
onChange = { ( e ) => setQuickSketch ( e . target . value ) }
placeholder = "Kurze Beschreibung: Ablauf, Material, Ziel der Übung …"
2026-05-07 09:20:19 +02:00
/ >
< / div >
2026-05-22 19:10:16 +02:00
< div
style = { {
borderTop : '1px solid var(--border)' ,
paddingTop : '12px' ,
marginTop : '4px' ,
} }
>
< strong style = { { fontSize : '13px' } } > Passende Übungen in der Bibliothek < / strong >
{ quickLibraryQuery . length < 3 ? (
< p style = { { margin : '8px 0 0' , fontSize : '12px' , color : 'var(--text3)' } } >
Titel oder Skizze ( mind . 3 Zeichen ) — dann erscheinen Treffer .
< / p >
) : quickLibraryLoading ? (
< p style = { { margin : '8px 0 0' , fontSize : '12px' , color : 'var(--text3)' } } > Suche läuft … < / p >
) : quickLibraryHits . length === 0 ? (
< p style = { { margin : '8px 0 0' , fontSize : '12px' , color : 'var(--text3)' } } >
Keine Treffer — unten KI - Vorschlag erzeugen .
< / p >
) : (
< ul style = { { listStyle : 'none' , padding : 0 , margin : '10px 0 0' } } >
{ quickLibraryHits . map ( ( ex ) => (
< li key = { ex . id } style = { { marginBottom : '8px' } } >
< button
type = "button"
className = "btn btn-secondary"
style = { {
width : '100%' ,
textAlign : 'left' ,
padding : '10px 12px' ,
display : 'block' ,
} }
onClick = { ( ) => adoptExistingExercise ( ex ) }
>
< strong style = { { display : 'block' } } > { ex . title } < / strong >
{ ( ex . summary || '' ) . trim ( ) ? (
< span style = { { fontSize : '12px' , color : 'var(--text2)' } } >
{ ( ex . summary || '' ) . trim ( ) . slice ( 0 , 100 ) }
{ ( ex . summary || '' ) . trim ( ) . length > 100 ? '…' : '' }
< / span >
) : null }
< / button >
< / li >
) ) }
< / ul >
) }
< / div >
2026-05-22 19:01:01 +02:00
{ quickAiError ? (
< p style = { { margin : 0 , fontSize : '13px' , color : 'var(--danger)' } } > { quickAiError } < / p >
) : null }
2026-05-07 09:20:19 +02:00
< div style = { { display : 'flex' , flexWrap : 'wrap' , gap : '8px' , justifyContent : 'flex-end' } } >
< button
type = "button"
className = "btn btn-primary"
2026-05-22 19:01:01 +02:00
disabled = {
quickSaving ||
( quickTitle || '' ) . trim ( ) . length < 3 ||
! ( quickSketch || '' ) . trim ( ) ||
! quickFocusAreaId
}
2026-05-22 19:10:16 +02:00
onClick = { runQuickCreateAiSuggest }
2026-05-07 09:20:19 +02:00
>
2026-05-22 19:10:16 +02:00
{ quickSaving ? 'KI erzeugt Vorschlag…' : 'KI-Vorschlag erzeugen' }
2026-05-07 09:20:19 +02:00
< / button >
< / div >
< / div >
) : null }
< / div >
) : null }
2026-04-29 06:15:26 +02:00
< 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' } } >
2026-05-06 21:20:19 +02:00
Felder gelten mit < strong > UND < / strong > . Kataloge : mehrere „ + “ = alle zutreffend ; „ − “ schließt aus .
2026-05-21 15:08:35 +02:00
Freigabelevel / Status : mehrere „ + “ = eine davon ( ODER ) ; „ − “ blendet aus .
2026-04-29 06:15:26 +02:00
< / p >
2026-05-06 17:15:44 +02:00
< ExerciseFocusRulePicker
focusOptions = { focusOptions }
focusRules = { filters . focus _rules }
focusOnlyWithout = { filters . focus _only _without }
legacyFocusAreaIds = { filters . focus _area _ids }
onPatch = { ( patch ) => setFilters ( ( f ) => ( { ... f , ... patch } ) ) }
/ >
2026-05-06 21:20:19 +02:00
< 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 } ) ) }
/ >
2026-04-29 06:15:26 +02:00
< / div >
< div style = { { marginTop : 12 } } >
< label className = "form-label" > Fähigkeit < / label >
2026-05-20 10:59:17 +02:00
< SkillTreeMultiSelect
2026-04-29 06:15:26 +02:00
value = { filters . skill _ids }
onChange = { ( v ) => setFilters ( ( f ) => ( { ... f , skill _ids : v } ) ) }
2026-05-20 10:59:17 +02:00
skills = { catalogs . skills }
2026-04-29 06:15:26 +02:00
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 >
2026-05-06 21:20:19 +02:00
< div className = "exercise-filters-modal-grid exercise-filters-modal-grid--two exercise-filters-modal-grid--catalog" style = { { marginTop : 12 } } >
< CatalogRulePicker
2026-05-21 15:08:35 +02:00
label = { EXERCISE _VISIBILITY _FIELD _LABEL }
2026-05-06 21:20:19 +02:00
options = { visibilityOptions }
rules = { filters . visibility _rules }
rulesFieldName = "visibility_rules"
idKind = "string"
2026-05-21 15:08:35 +02:00
placeholder = "Freigabelevel …"
2026-05-06 21:20:19 +02:00
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 } ) ) }
/ >
2026-05-06 13:52:24 +02:00
< / div >
< div style = { { marginTop : 12 , display : 'flex' , flexDirection : 'column' , gap : 10 } } >
2026-05-06 17:15:44 +02:00
< label
style = { {
display : 'flex' ,
alignItems : 'center' ,
gap : 8 ,
cursor : filters . focus _only _without ? 'not-allowed' : 'pointer' ,
opacity : filters . focus _only _without ? 0.55 : 1 ,
} }
>
2026-05-06 13:52:24 +02:00
< input
type = "checkbox"
2026-05-06 17:15:44 +02:00
disabled = { ! ! filters . focus _only _without }
2026-05-06 13:52:24 +02:00
checked = { ! ! filters . exclude _without _focus }
2026-05-06 17:15:44 +02:00
onChange = { ( e ) =>
setFilters ( ( f ) => ( {
... f ,
exclude _without _focus : e . target . checked ,
... ( e . target . checked ? { focus _only _without : false } : { } ) ,
} ) )
}
2026-05-06 13:52:24 +02:00
/ >
< 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 >
2026-04-29 06:15:26 +02:00
< / div >
) }
< / div >
< / div >
2026-05-14 08:59:06 +02:00
< div
ref = { pickerScrollRef }
data - testid = "exercise-picker-scroll"
style = { { flex : 1 , minHeight : 0 , overflowY : 'auto' , padding : '12px 1rem' } }
>
2026-04-29 06:15:26 +02:00
{ ! 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 >
2026-05-14 08:59:06 +02:00
< div
role = "list"
aria - label = "Übungstreffer"
style = { {
position : 'relative' ,
width : '100%' ,
height : rowVirtualizer . getTotalSize ( ) ,
} }
>
{ rowVirtualizer . getVirtualItems ( ) . map ( ( vi ) => {
const ex = list [ vi . index ]
if ( ! ex ) return null
2026-05-05 14:42:46 +02:00
const picked = multiPicked . some ( ( p ) => p . id === ex . id )
const rowInner = (
< >
2026-04-29 06:15:26 +02:00
< strong style = { { display : 'block' } } > { ex . title } < / strong >
{ ( ex . summary || '' ) . trim ( ) . length > 0 && (
< span style = { { fontSize : '12px' , color : 'var(--text2)' } } >
2026-05-05 14:42:46 +02:00
{ ( ex . summary || '' ) . length > 120
? ` ${ ( ex . summary || '' ) . slice ( 0 , 120 ) } … `
: ex . summary }
2026-04-29 06:15:26 +02:00
< / span >
) }
{ ex . focus _area && (
< span className = "exercise-tag exercise-tag--accent" style = { { marginTop : 6 } } >
{ ex . focus _area }
< / span >
) }
2026-05-13 06:30:53 +02:00
{ ( 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 }
2026-05-05 14:42:46 +02:00
< / >
)
2026-05-14 08:59:06 +02:00
return (
< div
key = { vi . key }
role = "listitem"
data - index = { vi . index }
ref = { rowVirtualizer . measureElement }
style = { {
position : 'absolute' ,
top : 0 ,
left : 0 ,
width : '100%' ,
transform : ` translateY( ${ vi . start } px) ` ,
paddingBottom : 8 ,
} }
>
{ multiSelect ? (
2026-05-05 14:42:46 +02:00
< label
className = "tu-ex-picker-multi-row"
style = { {
display : 'flex' ,
gap : '10px' ,
alignItems : 'flex-start' ,
width : '100%' ,
textAlign : 'left' ,
padding : '10px 12px' ,
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 >
2026-05-14 08:59:06 +02:00
) : (
< button
type = "button"
onClick = { ( ) => {
onSelectExercise ( ex )
onClose ( )
} }
style = { {
width : '100%' ,
textAlign : 'left' ,
padding : '10px 12px' ,
borderRadius : '8px' ,
border : '1px solid var(--border)' ,
background : 'var(--surface2)' ,
cursor : 'pointer' ,
} }
>
{ rowInner }
< / button >
) }
< / div >
2026-05-05 14:42:46 +02:00
)
} ) }
2026-05-14 08:59:06 +02:00
< / div >
2026-04-29 06:15:26 +02:00
{ hasMore && (
< div style = { { textAlign : 'center' , marginTop : 12 } } >
< button type = "button" className = "btn btn-secondary" disabled = { loadingMore } onClick = { loadMore } >
{ loadingMore ? 'Laden…' : 'Mehr laden' }
< / button >
< / div >
) }
2026-05-05 14:42:46 +02:00
{ 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 }
2026-04-29 06:15:26 +02:00
< / >
) }
< / div >
< / div >
2026-05-22 19:10:16 +02:00
< ExerciseAiSuggestPreviewModal
preview = { quickCreatePreview }
onPreviewChange = { setQuickCreatePreview }
onDiscard = { ( ) => setQuickCreatePreview ( null ) }
onApply = { applyQuickCreatePreview }
skillsCatalog = { catalogs . skills }
dialogTitle = "Neue Übung — KI-Vorschlag prüfen"
hint = "Felder und Fähigkeiten per Checkbox wählen. Erst „Anlegen und übernehmen“ speichert die Übung (Entwurf, privat) und übernimmt sie in den Ablauf."
applyLabel = { quickSaving ? 'Wird angelegt…' : 'Anlegen und übernehmen' }
applyDisabled = { quickSaving }
zIndex = { 2100 }
/ >
2026-04-29 06:15:26 +02:00
< / div >
)
}