/** * Ü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()} > {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'}

{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 ? (
{ const v = e.target.value setSearchInput(v) setAiSearchInput(v) }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() submitPlanningSearch() } }} autoComplete="off" />

Suche startet erst per Button (oder Enter) — nicht beim Tippen. LLM nur bei längeren Anfragen, maximal ein KI-Call pro Suche.

) : ( <>
setSearchInput(e.target.value)} autoComplete="off" />
setAiSearchInput(e.target.value)} autoComplete="off" />
)}
{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 }))} />
setFilters((f) => ({ ...f, skill_ids: v }))} skills={catalogs.skills} placeholder="Fähigkeit …" />
setFilters((f) => ({ ...f, ...patch }))} /> setFilters((f) => ({ ...f, ...patch }))} />

Hinweis: Für die Planung werden archivierte Übungen bei der Suche immer mit eingeschlossen (bestehende Zuordnungen).

)}
{!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 ? ( ) : ( )}
) })}
{hasMore && (
)} {showQuickCreateTeaser ? ( setQuickCreateExpanded(true)} /> ) : null} {showQuickCreateFull && quickCreateExpanded ? (
{renderQuickCreateOffer()}
) : null} {multiSelect && typeof onSelectExercises === 'function' ? (
{multiPicked.length} ausgewählt
) : 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} />
) }