shinkan-jinkendo/frontend/src/components/ExercisePickerModal.jsx
Lars 779e2477ba
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s
Implement Planning Context Integration for Exercise AI Suggestions
- Added `planning_context` to the `suggestExerciseAi` endpoint, enabling structured planning context for new exercise creation.
- Updated relevant components and backend logic to handle the new planning context, enhancing the AI's exercise suggestion capabilities.
- Incremented application version to 0.8.208 to reflect these changes.
2026-06-08 15:15:03 +02:00

1431 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* Ü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 = () => (
<ExerciseAiQuickCreateOffer
searchLabel={effectivePickerQuery || undefined}
title={quickTitle}
onTitleChange={setQuickTitle}
sketch={quickSketch}
onSketchChange={setQuickSketch}
focusAreaId={quickFocusAreaId}
onFocusAreaChange={setQuickFocusAreaId}
focusAreas={catalogs.focusAreas}
catalogsReady={catalogsReady}
busy={quickSaving}
error={quickAiError}
onRunAi={runQuickCreateAiSuggest}
headline={quickCreateHeadline}
hint={quickCreateHint}
/>
)
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 (
<span className="exercise-tag" style={{ marginTop: 6, display: 'inline-block' }}>
Variante: {ex.suggested_variant_name}
</span>
)
}
if (variants.length === 0) return null
return (
<div
style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<label className="form-label" style={{ margin: 0, fontSize: '11px' }}>
Variante
</label>
<select
className="form-input"
style={{ fontSize: '12px', padding: '4px 8px', maxWidth: '100%', flex: '1 1 160px' }}
value={resolved ?? ''}
onChange={(e) => {
const v = e.target.value
setVariantPickForExercise(ex.id, v === '' ? null : Number(v))
}}
>
<option value=""> Standard </option>
{variants.map((v) => (
<option key={v.id} value={v.id}>
{v.variant_name || `Variante #${v.id}`}
</option>
))}
</select>
{ex.suggested_variant_id && Number(ex.suggested_variant_id) === Number(resolved) ? (
<span style={{ fontSize: '11px', color: 'var(--accent-dark)' }}>Progressionsgraph</span>
) : null}
</div>
)
}
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 (
<div
className="admin-modal-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
<div
className="admin-modal-sheet"
role="dialog"
aria-modal="true"
style={{ maxWidth: '920px', width: '100%', maxHeight: '92vh', display: 'flex', flexDirection: 'column' }}
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 className="admin-modal-sheet__title">
{usePlanningSearch
? multiSelect
? 'Planungs-KI: Übungen vorschlagen'
: 'Planungs-KI: Übung vorschlagen'
: multiSelect
? 'Übungen auswählen'
: 'Übung auswählen'}
</h3>
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
Schließen
</button>
</div>
<div style={{ padding: '0 1rem 0.75rem', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
{usePlanningSearch && planningContextSummary ? (
<div
style={{
marginBottom: '10px',
padding: '8px 10px',
borderRadius: '8px',
background: 'var(--surface2)',
border: '1px solid var(--border)',
fontSize: '12px',
color: 'var(--text2)',
lineHeight: 1.45,
}}
>
<strong style={{ color: 'var(--text1)', fontSize: '13px' }}>Planungskontext</strong>
<div style={{ marginTop: '4px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
{planningContextSummary.group_name ? (
<span className="exercise-tag">{planningContextSummary.group_name}</span>
) : null}
{planningContextSummary.unit_title ? (
<span className="exercise-tag">{planningContextSummary.unit_title}</span>
) : null}
{planningContextSummary.section_title ? (
<span className="exercise-tag">{planningContextSummary.section_title}</span>
) : null}
{planningContextSummary.section_exercise_count != null ? (
<span className="exercise-tag">
{planningContextSummary.section_exercise_count} Übungen im Abschnitt
</span>
) : null}
{planningContextSummary.last_section_exercise_title ? (
<span className="exercise-tag">
Letzte: {planningContextSummary.last_section_exercise_title}
</span>
) : null}
{planningContextSummary.planned_count != null ? (
<span className="exercise-tag">{planningContextSummary.planned_count} Übungen im Plan</span>
) : null}
{planningContextSummary.anchor_title ? (
<span className="exercise-tag exercise-tag--accent">
Anker: {planningContextSummary.anchor_title}
</span>
) : null}
{planningContextSummary.progression_graph_name ? (
<span className="exercise-tag">
Graph: {planningContextSummary.progression_graph_name}
{planningContextSummary.progression_graph_auto_resolved ? ' (auto)' : ''}
</span>
) : null}
{Array.isArray(planningTargetProfileSummary?.focus_areas) &&
planningTargetProfileSummary.focus_areas.length > 0
? planningTargetProfileSummary.focus_areas.map((fa) => (
<span key={fa} className="exercise-tag">
Fokus: {fa}
</span>
))
: null}
{Array.isArray(planningTargetProfileSummary?.top_skills) &&
planningTargetProfileSummary.top_skills.length > 0
? planningTargetProfileSummary.top_skills.slice(0, 3).map((sk) => (
<span key={sk.skill_id} className="exercise-tag">
{sk.name}
</span>
))
: null}
</div>
{planningContextSummary.section_guidance_notes ? (
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text2)' }}>
Abschnitt: {planningContextSummary.section_guidance_notes}
</p>
) : null}
{planningContextSummary.expectation_mode ? (
<p style={{ margin: '4px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
Erwartungsprofil:{' '}
{planningContextSummary.expectation_mode === 'query_only'
? 'nur Suchtext'
: 'Planung + optional Suchtext'}
</p>
) : null}
{planningTargetProfileSummary?.has_skill_gap ? (
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
Skill-Lücke zum bisherigen Plan berücksichtigt
</p>
) : null}
{planningQueryIntentSummary?.rationale ? (
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text2)' }}>
{planningQueryIntentSummary.rationale}
</p>
) : null}
{planningIntentResolved ? (
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
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}
</p>
) : null}
</div>
) : null}
{planningSearchBlocked ? (
<p
style={{
margin: '0 0 10px',
padding: '10px 12px',
borderRadius: '8px',
background: 'color-mix(in srgb, var(--danger) 12%, var(--surface2))',
border: '1px solid color-mix(in srgb, var(--danger) 35%, var(--border))',
fontSize: '13px',
color: 'var(--text1)',
lineHeight: 1.45,
}}
>
<strong>Planungs-KI noch nicht verfügbar.</strong> Bitte zuerst <strong>Speichern</strong> oder den
Menüpunkt <strong>Planungs-KI: Übung vorschlagen</strong> nutzen (Freitext ohne gespeicherte Einheit).
</p>
) : null}
{pickerMode === 'library' && expectPlanningSearch ? (
<p
style={{
margin: '0 0 10px',
padding: '8px 10px',
borderRadius: '8px',
background: 'var(--surface2)',
border: '1px solid var(--border)',
fontSize: '12px',
color: 'var(--text2)',
}}
>
<strong style={{ color: 'var(--text1)' }}>Bibliothekssuche (Volltext)</strong> für Planungs-KI mit
Kontext oder Freitext-Anfrage den Menüpunkt{' '}
<strong>Planungs-KI: Übung vorschlagen </strong> unter dem <strong>+</strong> wählen.
</p>
) : null}
{useFreePlanningSearch ? (
<p
style={{
margin: '0 0 10px',
padding: '8px 10px',
borderRadius: '8px',
background: 'var(--surface2)',
border: '1px solid var(--border)',
fontSize: '12px',
color: 'var(--text2)',
}}
>
<strong style={{ color: 'var(--text1)' }}>Freie Planungs-KI</strong> Anker und bisherige Übungen aus
dem Formular; nach Speichern kommen Gruppe, Historie und Rahmen dazu.
</p>
) : null}
{!usePlanningSearch && !planningSearchBlocked && !expectPlanningSearch ? (
<p
style={{
margin: '0 0 10px',
padding: '8px 10px',
borderRadius: '8px',
background: 'var(--surface2)',
border: '1px solid var(--border)',
fontSize: '12px',
color: 'var(--text2)',
}}
>
<strong style={{ color: 'var(--text1)' }}>Bibliothekssuche (Volltext)</strong>
</p>
) : null}
<div style={{ display: 'grid', gap: '0.65rem' }}>
{usePlanningSearch ? (
<div>
<label className="form-label">Planungs-Anfrage (KI)</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'stretch' }}>
<input
type="text"
className="form-input"
style={{ flex: '1 1 220px', minWidth: 0 }}
placeholder="z. B. Vertiefung Reaktion mit Partner, baut auf dem Plan auf …"
value={searchInput || aiSearchInput}
onChange={(e) => {
const v = e.target.value
setSearchInput(v)
setAiSearchInput(v)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
submitPlanningSearch()
}
}}
autoComplete="off"
/>
<button
type="button"
className="btn btn-primary"
disabled={loading || planningSearchBlocked}
onClick={() => submitPlanningSearch()}
>
{loading ? 'Suche …' : 'Vorschläge laden'}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={loading || planningSearchBlocked}
title="Leere Anfrage — nur Planungskontext (Anker, Plan, Profil), ohne LLM"
onClick={() => {
setSearchInput('')
setAiSearchInput('')
submitPlanningSearch('')
}}
>
Nächste aus Kontext
</button>
</div>
<p style={{ margin: '4px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
Suche startet erst per Button (oder Enter) nicht beim Tippen. LLM nur bei längeren Anfragen,
maximal ein KI-Call pro Suche.
</p>
</div>
) : (
<>
<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">
Ergänzung /{' '}
<span title="Wird mit Volltextsuche kombiniert">zweites Suchfeld</span>
</label>
<input
type="search"
className="form-input"
placeholder="zweites Suchkonzept oder Umschreibung…"
value={aiSearchInput}
onChange={(e) => setAiSearchInput(e.target.value)}
autoComplete="off"
/>
</div>
</>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'center' }}>
<button type="button" className="btn btn-secondary" onClick={() => setFilterOpen(!filterOpen)}>
{filterOpen ? 'Filter ausblenden' : 'Erweiterte Filter'}
</button>
<button type="button" className="btn" onClick={resetFilters}>
Filter zurücksetzen
</button>
{loading && <span style={{ fontSize: '13px', color: 'var(--text2)' }}>Suche läuft</span>}
</div>
{filterOpen && (
<div style={{ marginTop: '0.35rem', fontSize: '13px', color: 'var(--text2)' }}>
<p style={{ margin: '0 0 12px 0' }}>
Felder gelten mit <strong>UND</strong>. Kataloge: mehrere + = alle zutreffend; schließt aus.
Freigabelevel/Status: mehrere + = eine davon (ODER); blendet aus.
</p>
<ExerciseFocusRulePicker
focusOptions={focusOptions}
focusRules={filters.focus_rules}
focusOnlyWithout={filters.focus_only_without}
legacyFocusAreaIds={filters.focus_area_ids}
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
/>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--catalog" style={{ marginTop: 12 }}>
<CatalogRulePicker
label="Stilrichtung"
hint="+ alle nötig (UND). verbietet Zuordnung."
options={styleOptions}
rules={filters.style_direction_rules}
rulesFieldName="style_direction_rules"
placeholder="Stil …"
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
/>
<CatalogRulePicker
label="Trainingsstil"
hint="+ alle nötig (UND). verbietet Zuordnung."
options={trainingTypeOptions}
rules={filters.training_type_rules}
rulesFieldName="training_type_rules"
placeholder="Trainingsstil …"
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
/>
<CatalogRulePicker
label="Zielgruppe"
hint="+ alle nötig (UND). verbietet Zuordnung."
options={targetGroupOptions}
rules={filters.target_group_rules}
rulesFieldName="target_group_rules"
placeholder="Gruppe …"
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
/>
</div>
<div style={{ marginTop: 12 }}>
<label className="form-label">Fähigkeit</label>
<SkillTreeMultiSelect
value={filters.skill_ids}
onChange={(v) => setFilters((f) => ({ ...f, skill_ids: v }))}
skills={catalogs.skills}
placeholder="Fähigkeit …"
/>
<div className="exercise-filter-skill-levels-row" style={{ marginTop: 8 }}>
<select
className="form-input exercise-filter-level-select"
title="von"
value={filters.skill_min_level}
onChange={(e) => setFilters((f) => ({ ...f, skill_min_level: e.target.value }))}
>
<option value="">Stufe von</option>
{LEVEL_FILTER_OPTS.map((o) => (
<option key={o.value} value={String(o.level)}>
{o.level}
</option>
))}
</select>
<select
className="form-input exercise-filter-level-select"
title="bis"
value={filters.skill_max_level}
onChange={(e) => setFilters((f) => ({ ...f, skill_max_level: e.target.value }))}
>
<option value="">Stufe bis</option>
{LEVEL_FILTER_OPTS.map((o) => (
<option key={`m-${o.value}`} value={String(o.level)}>
{o.level}
</option>
))}
</select>
</div>
</div>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two exercise-filters-modal-grid--catalog" style={{ marginTop: 12 }}>
<CatalogRulePicker
label={EXERCISE_VISIBILITY_FIELD_LABEL}
options={visibilityOptions}
rules={filters.visibility_rules}
rulesFieldName="visibility_rules"
idKind="string"
placeholder="Freigabelevel …"
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
/>
<CatalogRulePicker
label="Status"
options={statusOptions}
rules={filters.status_rules}
rulesFieldName="status_rules"
idKind="string"
placeholder="Status …"
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
/>
</div>
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
cursor: filters.focus_only_without ? 'not-allowed' : 'pointer',
opacity: filters.focus_only_without ? 0.55 : 1,
}}
>
<input
type="checkbox"
disabled={!!filters.focus_only_without}
checked={!!filters.exclude_without_focus}
onChange={(e) =>
setFilters((f) => ({
...f,
exclude_without_focus: e.target.checked,
...(e.target.checked ? { focus_only_without: false } : {}),
}))
}
/>
<span>Ohne Fokus ausblenden</span>
</label>
<p style={{ margin: 0, fontSize: '12px', color: 'var(--text2)' }}>
Hinweis: Für die Planung werden archivierte Übungen bei der Suche immer mit eingeschlossen (bestehende
Zuordnungen).
</p>
</div>
</div>
)}
</div>
</div>
<div
ref={pickerScrollRef}
data-testid="exercise-picker-scroll"
style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '12px 1rem' }}
>
{!catalogsReady || (loading && list.length === 0) ? (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<div className="spinner" />
</div>
) : list.length === 0 ? (
showQuickCreateFull ? (
renderQuickCreateOffer()
) : (
<p style={{ color: 'var(--text2)', textAlign: 'center', lineHeight: 1.5 }}>
{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) …'}
</p>
)
) : (
<>
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: 10 }}>
{usePlanningSearch ? `${list.length} KI-Vorschläge` : `${list.length} angezeigt`}
{hasMore ? ' · weiter unten „Mehr laden“' : ''}
</p>
<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
const picked = multiPicked.some((p) => p.id === ex.id)
const rowInner = (
<>
<strong style={{ display: 'block' }}>{ex.title}</strong>
{(ex.summary || '').trim().length > 0 && (
<span style={{ fontSize: '12px', color: 'var(--text2)' }}>
{(ex.summary || '').length > 120
? `${(ex.summary || '').slice(0, 120)}`
: ex.summary}
</span>
)}
{ex.focus_area && (
<span className="exercise-tag exercise-tag--accent" style={{ marginTop: 6 }}>
{ex.focus_area}
</span>
)}
{(ex.exercise_kind || '').toLowerCase().trim() === 'combination' ? (
<span
className="exercise-tag"
style={{
marginTop: 6,
marginLeft: 6,
background: 'var(--accent-soft)',
color: 'var(--accent-dark)',
}}
>
Kombination
</span>
) : null}
{Array.isArray(ex._planningReasons) && ex._planningReasons.length > 0 ? (
<ul
style={{
margin: '6px 0 0',
paddingLeft: '16px',
fontSize: '11px',
color: 'var(--accent-dark)',
}}
>
{ex._planningReasons.slice(0, 3).map((r) => (
<li key={r}>{r}</li>
))}
</ul>
) : null}
{renderPlanningVariantPick(ex)}
</>
)
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 ? (
<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>
) : (
<button
type="button"
onClick={() => adoptExistingExercise(ex)}
style={{
width: '100%',
textAlign: 'left',
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
cursor: 'pointer',
}}
>
{rowInner}
</button>
)}
</div>
)
})}
</div>
{hasMore && (
<div style={{ textAlign: 'center', marginTop: 12 }}>
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
{loadingMore ? 'Laden…' : 'Mehr laden'}
</button>
</div>
)}
{showQuickCreateTeaser ? (
<ExerciseAiQuickCreateTeaser
disabled={quickSaving}
onExpand={() => setQuickCreateExpanded(true)}
/>
) : null}
{showQuickCreateFull && quickCreateExpanded ? (
<div style={{ marginTop: 4 }}>{renderQuickCreateOffer()}</div>
) : null}
{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.map((p) => buildExercisePickPayload(p)))
onClose()
}}
>
Übernehmen
</button>
</div>
</div>
) : null}
</>
)}
</div>
</div>
<ExerciseAiSuggestPreviewModal
draft={quickCreateDraft}
onDraftChange={setQuickCreateDraft}
onDiscard={() => 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}
/>
</div>
)
}