/**
* Ü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, useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
import {
INITIAL_EXERCISE_LIST_FILTERS,
mergeExerciseListPrefsFromApi,
splitMnCatalogRules,
splitScalarCatalogRules,
} from '../constants/exerciseListFilters'
import SkillTreeMultiSelect from './SkillTreeMultiSelect'
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
import CatalogRulePicker from './CatalogRulePicker'
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
import ExerciseAiQuickCreateOffer, { ExerciseAiQuickCreateTeaser } from './ExerciseAiQuickCreateOffer'
import { useExerciseAiQuickCreateFields } from '../hooks/useExerciseAiQuickCreateFields'
import {
buildQuickCreateAiPreview,
buildQuickCreateExercisePayloadFromDraft,
aiPreviewToQuickCreateDraft,
} from '../utils/exerciseAiQuickCreate'
import { resolveExercisePickVariantId } from '../utils/exercisePlanningPick'
import { buildPickerPlanningContextForAi } from '../utils/planningContextForExerciseAi'
const PAGE_SIZE = 100
/** Backend POST /api/planning/exercise-suggest erlaubt max. 50 */
const PLANNING_SUGGEST_LIMIT = 50
/** Client-Hinweis — Backend entscheidet final über LLM-Gates (max. 1 Call). */
const PLANNING_LLM_INTENT_MIN_CHARS = 10
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS }
export default function ExercisePickerModal({
open,
onClose,
onSelectExercise,
multiSelect = false,
onSelectExercises = null,
enableQuickCreateDraft = false,
/** Gespeicherte training_units.id — aktiviert Planungs-KI (robuster als nur planningContext). */
planningUnitId = null,
/** 'planning' = Planungs-KI-API; 'library' = Volltext-Bibliothek */
pickerMode = 'library',
/** Planungs-KI auch ohne gespeicherte unit_id (Client-Kontext / Freitext). */
enableFreePlanningSearch = false,
/** true auf TrainingUnitEditPage: Hinweis wenn Planungs-KI ohne Einheit und ohne Freitext. */
expectPlanningSearch = false,
/** Planungs-Kontext für KI-Suche (Abschnitt, Anker, Plan …) */
planningContext = null,
/** 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 [hasMore, setHasMore] = useState(false)
const [multiPicked, setMultiPicked] = useState([])
const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('')
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
const [quickCreateExpanded, setQuickCreateExpanded] = useState(false)
const [planningContextSummary, setPlanningContextSummary] = useState(null)
const [planningTargetProfileSummary, setPlanningTargetProfileSummary] = useState(null)
const [planningLlmRankApplied, setPlanningLlmRankApplied] = useState(false)
const [planningLlmIntentApplied, setPlanningLlmIntentApplied] = useState(false)
const [planningRetrievalPhase, setPlanningRetrievalPhase] = useState('')
const [planningQueryIntentSummary, setPlanningQueryIntentSummary] = useState(null)
const [planningIntentResolved, setPlanningIntentResolved] = useState(null)
const [planningHasSearched, setPlanningHasSearched] = useState(false)
const [planningSubmittedQuery, setPlanningSubmittedQuery] = useState('')
const [planningSearchTick, setPlanningSearchTick] = useState(0)
const [variantPickByExerciseId, setVariantPickByExerciseId] = useState({})
const pickerScrollRef = useRef(null)
const resolvedPlanningUnitId = useMemo(() => {
const raw = planningUnitId ?? planningContext?.unitId
const id = Number(raw)
return Number.isFinite(id) && id > 0 ? id : null
}, [planningUnitId, planningContext?.unitId])
const activePlanningContext = useMemo(() => {
if (pickerMode !== 'planning') return null
const groupIdRaw = planningContext?.groupId
const groupId = Number(groupIdRaw)
const base = {
groupId: Number.isFinite(groupId) && groupId > 0 ? groupId : null,
sectionOrderIndex: planningContext?.sectionOrderIndex ?? 0,
sectionTitle: planningContext?.sectionTitle ?? null,
sectionGuidanceNotes: planningContext?.sectionGuidanceNotes ?? null,
sectionPlannedExerciseIds: Array.isArray(planningContext?.sectionPlannedExerciseIds)
? planningContext.sectionPlannedExerciseIds
: [],
sectionExerciseCount: planningContext?.sectionExerciseCount ?? null,
lastExerciseTitle: planningContext?.lastExerciseTitle ?? null,
phaseOrderIndex: planningContext?.phaseOrderIndex ?? null,
parallelStreamOrderIndex: planningContext?.parallelStreamOrderIndex ?? null,
anchorExerciseId: planningContext?.anchorExerciseId ?? null,
anchorExerciseVariantId: planningContext?.anchorExerciseVariantId ?? null,
progressionGraphId: planningContext?.progressionGraphId ?? null,
plannedExerciseIds: Array.isArray(planningContext?.plannedExerciseIds)
? planningContext.plannedExerciseIds
: [],
intentHint: planningContext?.intentHint ?? null,
}
if (!resolvedPlanningUnitId) {
if (!enableFreePlanningSearch && !planningContext) return null
return { unitId: null, ...base }
}
return {
unitId: resolvedPlanningUnitId,
...base,
}
}, [pickerMode, resolvedPlanningUnitId, enableFreePlanningSearch, planningContext])
const usePlanningSearch = pickerMode === 'planning' && activePlanningContext != null
const useFreePlanningSearch = usePlanningSearch && !resolvedPlanningUnitId
const planningSearchBlocked = Boolean(
pickerMode === 'planning' &&
expectPlanningSearch &&
!resolvedPlanningUnitId &&
!enableFreePlanningSearch
)
/** Gemeinsamer Suchtext — Planung: nur nach Button; Bibliothek: debounced live. */
const effectivePickerQuery = useMemo(() => {
if (usePlanningSearch) {
return planningSubmittedQuery
}
return [debouncedSearch, debouncedAi].filter(Boolean).join(' ').trim()
}, [usePlanningSearch, planningSubmittedQuery, debouncedSearch, debouncedAi])
const submitPlanningSearch = useCallback((queryOverride) => {
const q =
queryOverride !== undefined && queryOverride !== null
? String(queryOverride).trim()
: (searchInput || aiSearchInput).trim()
setPlanningSubmittedQuery(q)
setPlanningHasSearched(true)
setQuickCreateExpanded(false)
setList([])
setPlanningSearchTick((t) => t + 1)
}, [searchInput, aiSearchInput])
const {
title: quickTitle,
sketch: quickSketch,
focusAreaId: quickFocusAreaId,
setTitle: setQuickTitle,
setSketch: setQuickSketch,
setFocusAreaId: setQuickFocusAreaId,
resetQuickCreateFields,
} = useExerciseAiQuickCreateFields(effectivePickerQuery, { enabled: open && enableQuickCreateDraft })
const toggleMultiPick = (ex) => {
setMultiPicked((prev) => {
if (prev.some((p) => p.id === ex.id)) return prev.filter((p) => p.id !== ex.id)
const vid = resolveExercisePickVariantId(ex, variantPickByExerciseId[ex.id])
return [...prev, { ...ex, exercise_variant_id: vid }]
})
}
const buildExercisePickPayload = (ex) => {
const vid = resolveExercisePickVariantId(ex, variantPickByExerciseId[ex.id])
return {
...ex,
exercise_variant_id: vid,
suggested_variant_id: vid ?? ex.suggested_variant_id ?? null,
}
}
const setVariantPickForExercise = (exerciseId, variantId) => {
const eid = Number(exerciseId)
if (!Number.isFinite(eid) || eid < 1) return
setVariantPickByExerciseId((prev) => ({
...prev,
[eid]: variantId === '' || variantId == null ? null : Number(variantId),
}))
setMultiPicked((prev) =>
prev.map((p) =>
Number(p.id) === eid
? {
...p,
exercise_variant_id: resolveExercisePickVariantId(p, variantId === '' ? null : Number(variantId)),
}
: p,
),
)
}
useEffect(() => {
if (!usePlanningSearch) setQuickCreateExpanded(false)
}, [effectivePickerQuery, usePlanningSearch])
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 350)
return () => clearTimeout(t)
}, [searchInput])
useEffect(() => {
const t = setTimeout(() => setDebouncedAi(aiSearchInput.trim()), 350)
return () => clearTimeout(t)
}, [aiSearchInput])
const canOfferQuickCreate =
enableQuickCreateDraft &&
catalogsReady &&
!loading &&
(usePlanningSearch ? planningHasSearched : effectivePickerQuery.length >= 3)
const showQuickCreateFull = canOfferQuickCreate && (list.length === 0 || quickCreateExpanded)
const showQuickCreateTeaser = canOfferQuickCreate && list.length > 0 && !quickCreateExpanded
const quickCreateHeadline = usePlanningSearch
? 'Nichts Richtiges dabei?'
: list.length > 0
? 'Neue Übung anlegen'
: undefined
const quickCreateHint = usePlanningSearch
? effectivePickerQuery
? `Aus Planungsanfrage „${effectivePickerQuery}“ oder eigener Idee — KI schlägt Texte vor, danach bearbeiten und übernehmen.`
: 'Aus Planungskontext oder eigener Idee — KI schlägt Texte vor, danach bearbeiten und übernehmen.'
: undefined
const renderQuickCreateOffer = () => (
)
const renderPlanningVariantPick = (ex) => {
if (!usePlanningSearch || !ex?.id) return null
const variants = Array.isArray(ex.variants) ? ex.variants : []
const resolved = resolveExercisePickVariantId(ex, variantPickByExerciseId[ex.id])
if (ex.suggested_variant_name && !variants.length) {
return (
Variante: {ex.suggested_variant_name}
)
}
if (variants.length === 0) return null
return (
e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
Variante
{
const v = e.target.value
setVariantPickForExercise(ex.id, v === '' ? null : Number(v))
}}
>
— Standard —
{variants.map((v) => (
{v.variant_name || `Variante #${v.id}`}
))}
{ex.suggested_variant_id && Number(ex.suggested_variant_id) === Number(resolved) ? (
Progressionsgraph
) : null}
)
}
useEffect(() => {
if (!open) setVariantPickByExerciseId({})
}, [open])
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.listSkillsCatalog(),
])
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)
setMultiPicked([])
resetQuickCreateFields()
setQuickSaving(false)
setQuickAiError('')
setQuickCreateDraft(null)
setQuickCreateExpanded(false)
setPlanningContextSummary(null)
setPlanningTargetProfileSummary(null)
setPlanningLlmRankApplied(false)
setPlanningLlmIntentApplied(false)
setPlanningRetrievalPhase('')
setPlanningQueryIntentSummary(null)
setPlanningIntentResolved(null)
setPlanningHasSearched(false)
setPlanningSubmittedQuery('')
setPlanningSearchTick(0)
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 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 (effectivePickerQuery) q.search = effectivePickerQuery
if (
Array.isArray(exerciseKindAny) &&
exerciseKindAny.length > 0
) {
q.exercise_kind_any = exerciseKindAny
}
return q
}, [filters, effectivePickerQuery, exerciseKindAny])
const reloadLibrary = useCallback(async () => {
if (!open || !catalogsReady || usePlanningSearch) return
setLoading(true)
try {
setPlanningContextSummary(null)
setPlanningTargetProfileSummary(null)
setPlanningLlmRankApplied(false)
setPlanningLlmIntentApplied(false)
setPlanningRetrievalPhase('')
setPlanningQueryIntentSummary(null)
setPlanningIntentResolved(null)
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)
} catch (e) {
console.error(e)
alert(e.message || 'Laden fehlgeschlagen')
setList([])
setHasMore(false)
} finally {
setLoading(false)
}
}, [open, catalogsReady, usePlanningSearch, queryBase])
const reloadPlanning = useCallback(async () => {
if (!open || !catalogsReady || !usePlanningSearch || planningSearchTick === 0) return
if (planningSearchBlocked || !activePlanningContext) {
setList([])
setHasMore(false)
setPlanningContextSummary(null)
setPlanningTargetProfileSummary(null)
setPlanningLlmRankApplied(false)
setPlanningLlmIntentApplied(false)
setPlanningRetrievalPhase('')
setPlanningQueryIntentSummary(null)
setPlanningIntentResolved(null)
setLoading(false)
return
}
setLoading(true)
try {
const query = planningSubmittedQuery
const requestBody = {
section_order_index:
activePlanningContext.sectionOrderIndex != null
? Number(activePlanningContext.sectionOrderIndex)
: null,
phase_order_index:
activePlanningContext.phaseOrderIndex != null
? Number(activePlanningContext.phaseOrderIndex)
: null,
parallel_stream_order_index:
activePlanningContext.parallelStreamOrderIndex != null
? Number(activePlanningContext.parallelStreamOrderIndex)
: null,
anchor_exercise_id:
activePlanningContext.anchorExerciseId != null
? Number(activePlanningContext.anchorExerciseId)
: null,
anchor_exercise_variant_id:
activePlanningContext.anchorExerciseVariantId != null
? Number(activePlanningContext.anchorExerciseVariantId)
: undefined,
progression_graph_id:
activePlanningContext.progressionGraphId != null
? Number(activePlanningContext.progressionGraphId)
: null,
planned_exercise_ids:
Array.isArray(activePlanningContext.plannedExerciseIds) &&
activePlanningContext.plannedExerciseIds.length > 0
? activePlanningContext.plannedExerciseIds
.map((x) => Number(x))
.filter((x) => Number.isFinite(x) && x > 0)
: undefined,
include_llm_intent:
query.length >= PLANNING_LLM_INTENT_MIN_CHARS || !(query || '').trim(),
include_llm_rank: true,
query,
intent_hint:
activePlanningContext.intentHint || (useFreePlanningSearch && query ? 'free_search' : null),
limit: PLANNING_SUGGEST_LIMIT,
exercise_kind_any:
Array.isArray(exerciseKindAny) && exerciseKindAny.length > 0 ? exerciseKindAny : undefined,
}
if (resolvedPlanningUnitId) {
requestBody.unit_id = Number(resolvedPlanningUnitId)
}
if (activePlanningContext.groupId) {
requestBody.group_id = Number(activePlanningContext.groupId)
}
if (activePlanningContext.sectionTitle) {
requestBody.section_title = String(activePlanningContext.sectionTitle)
}
if (activePlanningContext.sectionGuidanceNotes) {
requestBody.section_guidance_notes = String(activePlanningContext.sectionGuidanceNotes)
}
if (
Array.isArray(activePlanningContext.sectionPlannedExerciseIds) &&
activePlanningContext.sectionPlannedExerciseIds.length > 0
) {
requestBody.section_planned_exercise_ids = activePlanningContext.sectionPlannedExerciseIds
.map((x) => Number(x))
.filter((x) => Number.isFinite(x) && x > 0)
}
const res = await api.suggestPlanningExercises(requestBody)
setPlanningContextSummary(res?.context_summary || null)
setPlanningTargetProfileSummary(res?.target_profile_summary || null)
setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied))
setPlanningLlmIntentApplied(Boolean(res?.profile_llm_applied ?? res?.llm_intent_applied))
setPlanningRetrievalPhase(res?.retrieval_phase || '')
setPlanningQueryIntentSummary(res?.query_intent_summary || null)
setPlanningIntentResolved(res?.intent_resolved || null)
const hits = (Array.isArray(res?.hits) ? res.hits : []).map((h) => ({
id: h.id,
title: h.title,
summary: h.summary,
focus_area: h.focus_area,
variants: Array.isArray(h.variants) ? h.variants : [],
suggested_variant_id: h.suggested_variant_id ?? null,
suggested_variant_name: h.suggested_variant_name ?? null,
_planningScore: h.score,
_planningReasons: Array.isArray(h.reasons) ? h.reasons : [],
updated_at: new Date().toISOString(),
}))
const initialVariants = {}
for (const h of hits) {
if (h.suggested_variant_id) initialVariants[h.id] = Number(h.suggested_variant_id)
}
setVariantPickByExerciseId(initialVariants)
setList(hits)
setHasMore(false)
} catch (e) {
console.error(e)
alert(e.message || 'Laden fehlgeschlagen')
setList([])
setHasMore(false)
setPlanningContextSummary(null)
setPlanningTargetProfileSummary(null)
setPlanningLlmRankApplied(false)
setPlanningLlmIntentApplied(false)
setPlanningRetrievalPhase('')
setPlanningQueryIntentSummary(null)
setPlanningIntentResolved(null)
} finally {
setLoading(false)
}
}, [
open,
catalogsReady,
usePlanningSearch,
planningSearchTick,
planningSearchBlocked,
activePlanningContext,
planningSubmittedQuery,
exerciseKindAny,
resolvedPlanningUnitId,
useFreePlanningSearch,
])
useEffect(() => {
reloadLibrary()
}, [reloadLibrary])
useEffect(() => {
reloadPlanning()
}, [reloadPlanning])
const loadMore = async () => {
if (!hasMore || loadingMore || loading) return
const last = list[list.length - 1]
if (!last?.id || last.updated_at == null) return
setLoadingMore(true)
try {
const batch = await api.listExercises({
...queryBase,
include_archived: true,
include_variants: true,
limit: PAGE_SIZE,
cursor_updated_at:
typeof last.updated_at === 'string'
? last.updated_at
: new Date(last.updated_at).toISOString(),
cursor_id: last.id,
})
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)
}
}
const rowVirtualizer = useVirtualizer({
count: list.length,
getScrollElement: () => pickerScrollRef.current,
estimateSize: (index) => {
const ex = list[index]
const rc = ex?._planningReasons?.length || 0
const hasVariant = usePlanningSearch && Array.isArray(ex?.variants) && ex.variants.length > 0
return rc > 0 || hasVariant ? 96 + Math.min(rc, 3) * 14 + (hasVariant ? 36 : 0) : 88
},
overscan: 8,
getItemKey: (index) => String(list[index]?.id ?? index),
})
const resetFilters = () => setFilters({ ...INITIAL_FILTERS })
const adoptExistingExercise = async (ex) => {
if (!ex?.id) return
const payload = buildExercisePickPayload(ex)
if (multiSelect && typeof onSelectExercises === 'function') {
await Promise.resolve(onSelectExercises([payload]))
} else if (typeof onSelectExercise === 'function') {
await Promise.resolve(onSelectExercise(payload))
}
onClose()
}
const runQuickCreateAiSuggest = async () => {
const title = (quickTitle || '').trim()
if (title.length < 3) {
alert('Titel: mindestens 3 Zeichen.')
return
}
const sketch = (quickSketch || '').trim()
const focusId = parseInt(String(quickFocusAreaId).trim(), 10)
if (!Number.isFinite(focusId) || focusId < 1) {
alert('Bitte einen Fokusbereich wählen.')
return
}
const focusRow = (catalogs.focusAreas || []).find((x) => Number(x.id) === focusId)
const focusHint = (focusRow?.name || '').trim()
setQuickAiError('')
setQuickCreateDraft(null)
const planningContextPayload = buildPickerPlanningContextForAi({
planningContextSummary,
planningContext,
searchQuery: planningSubmittedQuery || searchInput || aiSearchInput,
})
setQuickSaving(true)
try {
const aiRes = await api.suggestExerciseAi({
title,
goal: sketch || undefined,
execution: '',
preparation: '',
trainer_notes: '',
focus_area_hint: focusHint || undefined,
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
planning_context: planningContextPayload || undefined,
include_summary: true,
include_skills: true,
include_instructions: true,
})
const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: sketch })
if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) {
throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.')
}
setQuickCreateDraft(
aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }),
)
} catch (e) {
console.error(e)
const msg = e?.message || String(e)
setQuickAiError(msg)
alert(msg || 'KI-Vorschlag fehlgeschlagen')
} finally {
setQuickSaving(false)
}
}
const applyQuickCreateDraft = async () => {
if (!quickCreateDraft) return
setQuickSaving(true)
setQuickAiError('')
try {
const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft)
const created = await api.createExercise(payload)
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
setQuickCreateDraft(null)
await adoptExistingExercise(created)
} catch (e) {
console.error(e)
const msg = e?.message || String(e)
setQuickAiError(msg)
alert(msg || 'Übung konnte nicht angelegt werden')
} finally {
setQuickSaving(false)
}
}
if (!open) return null
return (
{
if (e.target === e.currentTarget) onClose()
}}
>
e.stopPropagation()}
>
{usePlanningSearch
? multiSelect
? 'Planungs-KI: Übungen vorschlagen'
: 'Planungs-KI: Übung vorschlagen'
: multiSelect
? 'Übungen auswählen'
: 'Übung auswählen'}
Schließen
{usePlanningSearch && planningContextSummary ? (
Planungskontext
{planningContextSummary.group_name ? (
{planningContextSummary.group_name}
) : null}
{planningContextSummary.unit_title ? (
{planningContextSummary.unit_title}
) : null}
{planningContextSummary.section_title ? (
{planningContextSummary.section_title}
) : null}
{planningContextSummary.section_exercise_count != null ? (
{planningContextSummary.section_exercise_count} Übungen im Abschnitt
) : null}
{planningContextSummary.last_section_exercise_title ? (
Letzte: {planningContextSummary.last_section_exercise_title}
) : null}
{planningContextSummary.planned_count != null ? (
{planningContextSummary.planned_count} Übungen im Plan
) : null}
{planningContextSummary.anchor_title ? (
Anker: {planningContextSummary.anchor_title}
) : null}
{planningContextSummary.progression_graph_name ? (
Graph: {planningContextSummary.progression_graph_name}
{planningContextSummary.progression_graph_auto_resolved ? ' (auto)' : ''}
) : null}
{Array.isArray(planningTargetProfileSummary?.focus_areas) &&
planningTargetProfileSummary.focus_areas.length > 0
? planningTargetProfileSummary.focus_areas.map((fa) => (
Fokus: {fa}
))
: null}
{Array.isArray(planningTargetProfileSummary?.top_skills) &&
planningTargetProfileSummary.top_skills.length > 0
? planningTargetProfileSummary.top_skills.slice(0, 3).map((sk) => (
{sk.name}
))
: null}
{planningContextSummary.section_guidance_notes ? (
Abschnitt: {planningContextSummary.section_guidance_notes}
) : null}
{planningContextSummary.expectation_mode ? (
Erwartungsprofil:{' '}
{planningContextSummary.expectation_mode === 'query_only'
? 'nur Suchtext'
: 'Planung + optional Suchtext'}
) : null}
{planningTargetProfileSummary?.has_skill_gap ? (
Skill-Lücke zum bisherigen Plan berücksichtigt
) : null}
{planningQueryIntentSummary?.rationale ? (
{planningQueryIntentSummary.rationale}
) : null}
{planningIntentResolved ? (
Modus: {planningIntentResolved.replace(/_/g, ' ')}
{planningQueryIntentSummary?.scenario
? ` · ${String(planningQueryIntentSummary.scenario).replace(/_/g, ' ')}`
: null}
{planningLlmRankApplied ? ' · KI-Ranking aktiv' : null}
{planningLlmIntentApplied
? planningQueryIntentSummary?.llm_expectation_applied
? ' · KI-Erwartungsprofil aktiv'
: ' · KI-Intent aktiv'
: null}
{!planningLlmRankApplied && !planningLlmIntentApplied && usePlanningSearch
? ' · ohne LLM (Profil/Hybrid)'
: null}
{planningRetrievalPhase ? ` · ${planningRetrievalPhase}` : null}
) : null}
) : null}
{planningSearchBlocked ? (
Planungs-KI noch nicht verfügbar. Bitte zuerst Speichern oder den
Menüpunkt Planungs-KI: Übung vorschlagen nutzen (Freitext ohne gespeicherte Einheit).
) : null}
{pickerMode === 'library' && expectPlanningSearch ? (
Bibliothekssuche (Volltext) — für Planungs-KI mit
Kontext oder Freitext-Anfrage den Menüpunkt{' '}
Planungs-KI: Übung vorschlagen … unter dem + wählen.
) : null}
{useFreePlanningSearch ? (
Freie Planungs-KI — Anker und bisherige Übungen aus
dem Formular; nach Speichern kommen Gruppe, Historie und Rahmen dazu.
) : null}
{!usePlanningSearch && !planningSearchBlocked && !expectPlanningSearch ? (
Bibliothekssuche (Volltext)
) : null}
{usePlanningSearch ? (
) : (
<>
Volltextsuche
setSearchInput(e.target.value)}
autoComplete="off"
/>
Ergänzung /{' '}
zweites Suchfeld
setAiSearchInput(e.target.value)}
autoComplete="off"
/>
>
)}
setFilterOpen(!filterOpen)}>
{filterOpen ? 'Filter ausblenden' : 'Erweiterte Filter'}
Filter zurücksetzen
{loading && Suche läuft… }
{filterOpen && (
Felder gelten mit UND . Kataloge: mehrere „+“ = alle zutreffend; „−“ schließt aus.
Freigabelevel/Status: mehrere „+“ = eine davon (ODER); „−“ blendet aus.
setFilters((f) => ({ ...f, ...patch }))}
/>
setFilters((f) => ({ ...f, ...patch }))}
/>
setFilters((f) => ({ ...f, ...patch }))}
/>
setFilters((f) => ({ ...f, ...patch }))}
/>
Fähigkeit
setFilters((f) => ({ ...f, skill_ids: v }))}
skills={catalogs.skills}
placeholder="Fähigkeit …"
/>
setFilters((f) => ({ ...f, skill_min_level: e.target.value }))}
>
Stufe von
{LEVEL_FILTER_OPTS.map((o) => (
{o.level}
))}
setFilters((f) => ({ ...f, skill_max_level: e.target.value }))}
>
Stufe bis
{LEVEL_FILTER_OPTS.map((o) => (
{o.level}
))}
setFilters((f) => ({ ...f, ...patch }))}
/>
setFilters((f) => ({ ...f, ...patch }))}
/>
)}
{!catalogsReady || (loading && list.length === 0) ? (
) : list.length === 0 ? (
showQuickCreateFull ? (
renderQuickCreateOffer()
) : (
{usePlanningSearch
? !planningHasSearched
? 'Anfrage formulieren und „Vorschläge laden“ klicken — oder „Nächste aus Kontext“ ohne Freitext.'
: effectivePickerQuery
? 'Keine KI-Vorschläge für diese Anfrage.'
: 'Keine Vorschläge aus dem Planungskontext — Anker, Plan oder Profil prüfen.'
: effectivePickerQuery.length >= 3
? 'Keine Treffer.'
: 'Suchbegriff eingeben (mind. 3 Zeichen) …'}
)
) : (
<>
{usePlanningSearch ? `${list.length} KI-Vorschläge` : `${list.length} angezeigt`}
{hasMore ? ' · weiter unten „Mehr laden“' : ''}
{rowVirtualizer.getVirtualItems().map((vi) => {
const ex = list[vi.index]
if (!ex) return null
const picked = multiPicked.some((p) => p.id === ex.id)
const rowInner = (
<>
{ex.title}
{(ex.summary || '').trim().length > 0 && (
{(ex.summary || '').length > 120
? `${(ex.summary || '').slice(0, 120)}…`
: ex.summary}
)}
{ex.focus_area && (
{ex.focus_area}
)}
{(ex.exercise_kind || '').toLowerCase().trim() === 'combination' ? (
Kombination
) : null}
{Array.isArray(ex._planningReasons) && ex._planningReasons.length > 0 ? (
{ex._planningReasons.slice(0, 3).map((r) => (
{r}
))}
) : null}
{renderPlanningVariantPick(ex)}
>
)
return (
{multiSelect ? (
toggleMultiPick(ex)}
style={{ marginTop: '0.35rem', flexShrink: 0 }}
aria-label={ex.title ? `Auswahl: ${ex.title}` : 'Auswahl'}
/>
{rowInner}
) : (
adoptExistingExercise(ex)}
style={{
width: '100%',
textAlign: 'left',
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
cursor: 'pointer',
}}
>
{rowInner}
)}
)
})}
{hasMore && (
{loadingMore ? 'Laden…' : 'Mehr laden'}
)}
{showQuickCreateTeaser ? (
setQuickCreateExpanded(true)}
/>
) : null}
{showQuickCreateFull && quickCreateExpanded ? (
{renderQuickCreateOffer()}
) : null}
{multiSelect && typeof onSelectExercises === 'function' ? (
{multiPicked.length} ausgewählt
setMultiPicked([])}
disabled={!multiPicked.length}
>
Auswahl leeren
{
onSelectExercises(multiPicked.map((p) => buildExercisePickPayload(p)))
onClose()
}}
>
Übernehmen
) : null}
>
)}
setQuickCreateDraft(null)}
onApply={applyQuickCreateDraft}
focusAreas={catalogs.focusAreas}
skillsCatalog={catalogs.skills}
dialogTitle="Neue Übung — KI-Entwurf bearbeiten"
hint="Texte sind formatiert — passe Titel, Kurzfassung, Anleitung und Fähigkeiten an, dann speichern und übernehmen."
applyLabel={quickSaving ? 'Wird angelegt…' : 'Anlegen und übernehmen'}
applyDisabled={quickSaving}
zIndex={2100}
/>
)
}