2026-04-29 06:15:26 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Übungssuche mit Volltext-, KI-/Semantikfeld (aktuell gleiche Engine wie Suche) und erweiterten Filtern.
|
|
|
|
|
|
* Paginierung bis max. 100 Treffer pro Request (API-Limit).
|
|
|
|
|
|
*/
|
2026-05-14 08:59:06 +02:00
|
|
|
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'
|
|
|
|
|
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
2026-04-29 06:15:26 +02:00
|
|
|
|
import api from '../utils/api'
|
2026-05-06 13:52:24 +02:00
|
|
|
|
import { useAuth } from '../context/AuthContext'
|
2026-04-29 06:15:26 +02:00
|
|
|
|
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
|
2026-05-21 15:08:35 +02:00
|
|
|
|
import { EXERCISE_VISIBILITY_FIELD_LABEL } from '../constants/exerciseGovernanceLabels'
|
2026-05-06 13:52:24 +02:00
|
|
|
|
import {
|
|
|
|
|
|
INITIAL_EXERCISE_LIST_FILTERS,
|
|
|
|
|
|
mergeExerciseListPrefsFromApi,
|
2026-05-06 21:20:19 +02:00
|
|
|
|
splitMnCatalogRules,
|
|
|
|
|
|
splitScalarCatalogRules,
|
2026-05-06 13:52:24 +02:00
|
|
|
|
} from '../constants/exerciseListFilters'
|
2026-05-20 10:59:17 +02:00
|
|
|
|
import SkillTreeMultiSelect from './SkillTreeMultiSelect'
|
2026-05-06 17:15:44 +02:00
|
|
|
|
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
|
2026-05-06 21:20:19 +02:00
|
|
|
|
import CatalogRulePicker from './CatalogRulePicker'
|
2026-05-22 19:10:16 +02:00
|
|
|
|
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
|
2026-05-22 19:24:36 +02:00
|
|
|
|
import ExerciseAiQuickCreateOffer from './ExerciseAiQuickCreateOffer'
|
2026-05-22 19:36:22 +02:00
|
|
|
|
import { useExerciseAiQuickCreateFields } from '../hooks/useExerciseAiQuickCreateFields'
|
2026-05-22 19:10:16 +02:00
|
|
|
|
import {
|
|
|
|
|
|
buildQuickCreateAiPreview,
|
2026-05-22 19:24:36 +02:00
|
|
|
|
buildQuickCreateExercisePayloadFromDraft,
|
|
|
|
|
|
aiPreviewToQuickCreateDraft,
|
2026-05-22 19:10:16 +02:00
|
|
|
|
} from '../utils/exerciseAiQuickCreate'
|
2026-04-29 06:15:26 +02:00
|
|
|
|
|
|
|
|
|
|
const PAGE_SIZE = 100
|
|
|
|
|
|
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
|
|
|
|
|
|
|
2026-05-06 13:52:24 +02:00
|
|
|
|
const INITIAL_FILTERS = { ...INITIAL_EXERCISE_LIST_FILTERS }
|
2026-04-29 06:15:26 +02:00
|
|
|
|
|
2026-05-05 14:42:46 +02:00
|
|
|
|
export default function ExercisePickerModal({
|
|
|
|
|
|
open,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
onSelectExercise,
|
|
|
|
|
|
multiSelect = false,
|
|
|
|
|
|
onSelectExercises = null,
|
2026-05-07 09:20:19 +02:00
|
|
|
|
enableQuickCreateDraft = false,
|
2026-05-22 22:30:29 +02:00
|
|
|
|
/** Gespeicherte training_units.id — aktiviert Planungs-KI (robuster als nur planningContext). */
|
|
|
|
|
|
planningUnitId = null,
|
|
|
|
|
|
/** true auf TrainingUnitEditPage: Hinweis wenn Einheit noch keine ID hat. */
|
|
|
|
|
|
expectPlanningSearch = false,
|
|
|
|
|
|
/** Planungs-Kontext für KI-Suche (Abschnitt, Anker, Plan …) */
|
2026-05-22 21:52:18 +02:00
|
|
|
|
planningContext = null,
|
2026-05-13 06:30:53 +02:00
|
|
|
|
/** Wenn gesetzt: z. B. ['simple'] oder ['combination'] — sonst alle Übungsarten */
|
|
|
|
|
|
exerciseKindAny = undefined,
|
2026-05-05 14:42:46 +02:00
|
|
|
|
}) {
|
2026-05-06 13:52:24 +02:00
|
|
|
|
const { user } = useAuth()
|
2026-04-29 06:15:26 +02:00
|
|
|
|
const [catalogs, setCatalogs] = useState({
|
|
|
|
|
|
focusAreas: [],
|
|
|
|
|
|
styleDirections: [],
|
|
|
|
|
|
trainingTypes: [],
|
|
|
|
|
|
targetGroups: [],
|
|
|
|
|
|
skills: [],
|
|
|
|
|
|
})
|
|
|
|
|
|
const [catalogsReady, setCatalogsReady] = useState(false)
|
|
|
|
|
|
const [searchInput, setSearchInput] = useState('')
|
|
|
|
|
|
const [aiSearchInput, setAiSearchInput] = useState('')
|
|
|
|
|
|
const [debouncedSearch, setDebouncedSearch] = useState('')
|
|
|
|
|
|
const [debouncedAi, setDebouncedAi] = useState('')
|
|
|
|
|
|
const [filters, setFilters] = useState(() => ({ ...INITIAL_FILTERS }))
|
|
|
|
|
|
const [filterOpen, setFilterOpen] = useState(false)
|
|
|
|
|
|
const [list, setList] = useState([])
|
|
|
|
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
|
|
const [loadingMore, setLoadingMore] = useState(false)
|
|
|
|
|
|
const [hasMore, setHasMore] = useState(false)
|
2026-05-05 14:42:46 +02:00
|
|
|
|
const [multiPicked, setMultiPicked] = useState([])
|
2026-05-07 09:20:19 +02:00
|
|
|
|
const [quickSaving, setQuickSaving] = useState(false)
|
2026-05-22 19:01:01 +02:00
|
|
|
|
const [quickAiError, setQuickAiError] = useState('')
|
2026-05-22 19:24:36 +02:00
|
|
|
|
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
|
2026-05-22 21:52:18 +02:00
|
|
|
|
const [planningContextSummary, setPlanningContextSummary] = useState(null)
|
2026-05-22 22:04:34 +02:00
|
|
|
|
const [planningTargetProfileSummary, setPlanningTargetProfileSummary] = useState(null)
|
2026-05-22 22:09:28 +02:00
|
|
|
|
const [planningLlmRankApplied, setPlanningLlmRankApplied] = useState(false)
|
2026-05-22 22:15:19 +02:00
|
|
|
|
const [planningQueryIntentSummary, setPlanningQueryIntentSummary] = useState(null)
|
2026-05-22 21:52:18 +02:00
|
|
|
|
const [planningIntentResolved, setPlanningIntentResolved] = useState(null)
|
2026-05-14 08:59:06 +02:00
|
|
|
|
const pickerScrollRef = useRef(null)
|
2026-05-05 14:42:46 +02:00
|
|
|
|
|
2026-05-22 22:30:29 +02:00
|
|
|
|
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 (!resolvedPlanningUnitId) return null
|
|
|
|
|
|
return {
|
|
|
|
|
|
unitId: resolvedPlanningUnitId,
|
|
|
|
|
|
sectionOrderIndex: planningContext?.sectionOrderIndex ?? 0,
|
|
|
|
|
|
phaseOrderIndex: planningContext?.phaseOrderIndex ?? null,
|
|
|
|
|
|
parallelStreamOrderIndex: planningContext?.parallelStreamOrderIndex ?? null,
|
|
|
|
|
|
anchorExerciseId: planningContext?.anchorExerciseId ?? null,
|
|
|
|
|
|
progressionGraphId: planningContext?.progressionGraphId ?? null,
|
|
|
|
|
|
plannedExerciseIds: Array.isArray(planningContext?.plannedExerciseIds)
|
|
|
|
|
|
? planningContext.plannedExerciseIds
|
|
|
|
|
|
: [],
|
|
|
|
|
|
intentHint: planningContext?.intentHint ?? null,
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [resolvedPlanningUnitId, planningContext])
|
|
|
|
|
|
|
|
|
|
|
|
const usePlanningSearch = resolvedPlanningUnitId != null
|
|
|
|
|
|
const planningSearchBlocked = Boolean(expectPlanningSearch && !usePlanningSearch)
|
2026-05-22 21:52:18 +02:00
|
|
|
|
|
2026-05-22 22:24:49 +02:00
|
|
|
|
/** Gemeinsamer Suchtext — in Planung nur ein Feld; in Bibliothek beide Felder kombiniert. */
|
|
|
|
|
|
const effectivePickerQuery = useMemo(() => {
|
|
|
|
|
|
if (usePlanningSearch) {
|
|
|
|
|
|
return (debouncedSearch || debouncedAi).trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
return [debouncedSearch, debouncedAi].filter(Boolean).join(' ').trim()
|
|
|
|
|
|
}, [usePlanningSearch, debouncedSearch, debouncedAi])
|
2026-05-22 22:21:06 +02:00
|
|
|
|
|
2026-05-22 19:36:22 +02:00
|
|
|
|
const {
|
|
|
|
|
|
title: quickTitle,
|
|
|
|
|
|
sketch: quickSketch,
|
|
|
|
|
|
focusAreaId: quickFocusAreaId,
|
|
|
|
|
|
setTitle: setQuickTitle,
|
|
|
|
|
|
setSketch: setQuickSketch,
|
|
|
|
|
|
setFocusAreaId: setQuickFocusAreaId,
|
|
|
|
|
|
resetQuickCreateFields,
|
2026-05-22 22:21:06 +02:00
|
|
|
|
} = useExerciseAiQuickCreateFields(effectivePickerQuery, { enabled: open && enableQuickCreateDraft })
|
2026-05-22 19:36:22 +02:00
|
|
|
|
|
2026-05-05 14:42:46 +02:00
|
|
|
|
const toggleMultiPick = (ex) => {
|
|
|
|
|
|
setMultiPicked((prev) =>
|
|
|
|
|
|
prev.some((p) => p.id === ex.id) ? prev.filter((p) => p.id !== ex.id) : [...prev, ex]
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-04-29 06:15:26 +02:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 350)
|
|
|
|
|
|
return () => clearTimeout(t)
|
|
|
|
|
|
}, [searchInput])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const t = setTimeout(() => setDebouncedAi(aiSearchInput.trim()), 350)
|
|
|
|
|
|
return () => clearTimeout(t)
|
|
|
|
|
|
}, [aiSearchInput])
|
|
|
|
|
|
|
2026-05-22 19:24:36 +02:00
|
|
|
|
const showQuickCreateOffer =
|
|
|
|
|
|
enableQuickCreateDraft &&
|
|
|
|
|
|
catalogsReady &&
|
|
|
|
|
|
!loading &&
|
2026-05-22 21:52:18 +02:00
|
|
|
|
list.length === 0 &&
|
2026-05-22 22:21:06 +02:00
|
|
|
|
(usePlanningSearch || effectivePickerQuery.length >= 3)
|
2026-05-22 19:10:16 +02:00
|
|
|
|
|
2026-04-29 06:15:26 +02:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!open) return
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
;(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [fa, sd, tt, tg, sk] = await Promise.all([
|
|
|
|
|
|
api.listFocusAreas(),
|
|
|
|
|
|
api.listStyleDirections(),
|
|
|
|
|
|
api.listTrainingTypes(),
|
|
|
|
|
|
api.listTargetGroups(),
|
2026-05-20 10:59:17 +02:00
|
|
|
|
api.listSkillsCatalog(),
|
2026-04-29 06:15:26 +02:00
|
|
|
|
])
|
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
|
setCatalogs({
|
|
|
|
|
|
focusAreas: fa,
|
|
|
|
|
|
styleDirections: sd,
|
|
|
|
|
|
trainingTypes: tt,
|
|
|
|
|
|
targetGroups: tg,
|
|
|
|
|
|
skills: sk,
|
|
|
|
|
|
})
|
|
|
|
|
|
setCatalogsReady(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error(e)
|
|
|
|
|
|
if (!cancelled) setCatalogsReady(true)
|
|
|
|
|
|
}
|
|
|
|
|
|
})()
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [open])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!open) {
|
|
|
|
|
|
setSearchInput('')
|
|
|
|
|
|
setAiSearchInput('')
|
|
|
|
|
|
setDebouncedSearch('')
|
|
|
|
|
|
setDebouncedAi('')
|
|
|
|
|
|
setFilters({ ...INITIAL_FILTERS })
|
|
|
|
|
|
setFilterOpen(false)
|
|
|
|
|
|
setList([])
|
|
|
|
|
|
setHasMore(false)
|
2026-05-05 14:42:46 +02:00
|
|
|
|
setMultiPicked([])
|
2026-05-22 19:36:22 +02:00
|
|
|
|
resetQuickCreateFields()
|
2026-05-07 09:20:19 +02:00
|
|
|
|
setQuickSaving(false)
|
2026-05-22 19:01:01 +02:00
|
|
|
|
setQuickAiError('')
|
2026-05-22 19:24:36 +02:00
|
|
|
|
setQuickCreateDraft(null)
|
2026-05-22 21:52:18 +02:00
|
|
|
|
setPlanningContextSummary(null)
|
2026-05-22 22:04:34 +02:00
|
|
|
|
setPlanningTargetProfileSummary(null)
|
2026-05-22 22:09:28 +02:00
|
|
|
|
setPlanningLlmRankApplied(false)
|
2026-05-22 22:15:19 +02:00
|
|
|
|
setPlanningQueryIntentSummary(null)
|
2026-05-22 21:52:18 +02:00
|
|
|
|
setPlanningIntentResolved(null)
|
2026-05-06 13:52:24 +02:00
|
|
|
|
return
|
2026-04-29 06:15:26 +02:00
|
|
|
|
}
|
2026-05-06 13:52:24 +02:00
|
|
|
|
setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs))
|
|
|
|
|
|
}, [open, user?.exercise_list_prefs])
|
2026-04-29 06:15:26 +02:00
|
|
|
|
|
|
|
|
|
|
const focusOptions = useMemo(
|
|
|
|
|
|
() => catalogs.focusAreas.map((fa) => ({ id: fa.id, label: `${fa.icon || ''} ${fa.name || ''}`.trim() })),
|
|
|
|
|
|
[catalogs.focusAreas]
|
|
|
|
|
|
)
|
|
|
|
|
|
const styleOptions = useMemo(
|
|
|
|
|
|
() => catalogs.styleDirections.map((s) => ({ id: s.id, label: s.name || String(s.id) })),
|
|
|
|
|
|
[catalogs.styleDirections]
|
|
|
|
|
|
)
|
|
|
|
|
|
const trainingTypeOptions = useMemo(
|
|
|
|
|
|
() => catalogs.trainingTypes.map((t) => ({ id: t.id, label: t.name || String(t.id) })),
|
|
|
|
|
|
[catalogs.trainingTypes]
|
|
|
|
|
|
)
|
|
|
|
|
|
const targetGroupOptions = useMemo(
|
|
|
|
|
|
() => catalogs.targetGroups.map((g) => ({ id: g.id, label: g.name || String(g.id) })),
|
|
|
|
|
|
[catalogs.targetGroups]
|
|
|
|
|
|
)
|
|
|
|
|
|
const visibilityOptions = useMemo(
|
|
|
|
|
|
() => [
|
|
|
|
|
|
{ id: 'private', label: 'Privat' },
|
|
|
|
|
|
{ id: 'club', label: 'Verein' },
|
|
|
|
|
|
{ id: 'official', label: 'Offiziell' },
|
|
|
|
|
|
],
|
|
|
|
|
|
[]
|
|
|
|
|
|
)
|
|
|
|
|
|
const statusOptions = useMemo(
|
|
|
|
|
|
() => [
|
|
|
|
|
|
{ id: 'draft', label: 'Entwurf' },
|
|
|
|
|
|
{ id: 'in_review', label: 'In Prüfung' },
|
|
|
|
|
|
{ id: 'approved', label: 'Freigegeben' },
|
|
|
|
|
|
{ id: 'archived', label: 'Archiviert' },
|
|
|
|
|
|
],
|
|
|
|
|
|
[]
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const queryBase = useMemo(() => {
|
|
|
|
|
|
const q = {}
|
|
|
|
|
|
const n = (v) => (v === '' || v == null ? undefined : Number(v))
|
|
|
|
|
|
const ids = (arr) =>
|
|
|
|
|
|
Array.isArray(arr) && arr.length ? arr.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) : undefined
|
2026-05-06 21:20:19 +02:00
|
|
|
|
const fMn = splitMnCatalogRules(filters.focus_rules)
|
|
|
|
|
|
if (fMn.includeIds.length) q.focus_area_must_include_ids = fMn.includeIds
|
|
|
|
|
|
if (fMn.excludeIds.length) q.focus_area_must_exclude_ids = fMn.excludeIds
|
2026-05-06 17:15:44 +02:00
|
|
|
|
if (filters.focus_only_without) q.focus_only_without_focus_areas = true
|
|
|
|
|
|
|
2026-04-29 06:15:26 +02:00
|
|
|
|
const fa = ids(filters.focus_area_ids)
|
|
|
|
|
|
if (fa?.length) q.focus_area_ids = fa
|
2026-05-06 21:20:19 +02:00
|
|
|
|
|
|
|
|
|
|
const sdMn = splitMnCatalogRules(filters.style_direction_rules)
|
|
|
|
|
|
if (sdMn.includeIds.length) q.style_direction_must_include_ids = sdMn.includeIds
|
|
|
|
|
|
if (sdMn.excludeIds.length) q.style_direction_must_exclude_ids = sdMn.excludeIds
|
|
|
|
|
|
const sdLegacy = ids(filters.style_direction_ids)
|
|
|
|
|
|
if (sdLegacy?.length) q.style_direction_ids = sdLegacy
|
|
|
|
|
|
|
|
|
|
|
|
const ttMn = splitMnCatalogRules(filters.training_type_rules)
|
|
|
|
|
|
if (ttMn.includeIds.length) q.training_type_must_include_ids = ttMn.includeIds
|
|
|
|
|
|
if (ttMn.excludeIds.length) q.training_type_must_exclude_ids = ttMn.excludeIds
|
|
|
|
|
|
const ttLegacy = ids(filters.training_type_ids)
|
|
|
|
|
|
if (ttLegacy?.length) q.training_type_ids = ttLegacy
|
|
|
|
|
|
|
|
|
|
|
|
const tgMn = splitMnCatalogRules(filters.target_group_rules)
|
|
|
|
|
|
if (tgMn.includeIds.length) q.target_group_must_include_ids = tgMn.includeIds
|
|
|
|
|
|
if (tgMn.excludeIds.length) q.target_group_must_exclude_ids = tgMn.excludeIds
|
|
|
|
|
|
const tgLegacy = ids(filters.target_group_ids)
|
|
|
|
|
|
if (tgLegacy?.length) q.target_group_ids = tgLegacy
|
|
|
|
|
|
|
|
|
|
|
|
const visMn = splitScalarCatalogRules(filters.visibility_rules)
|
|
|
|
|
|
if (visMn.includeVals.length) q.visibility_any = visMn.includeVals
|
|
|
|
|
|
if (visMn.excludeVals.length) q.visibility_exclude_any = visMn.excludeVals
|
|
|
|
|
|
|
|
|
|
|
|
const stMn = splitScalarCatalogRules(filters.status_rules)
|
|
|
|
|
|
if (stMn.includeVals.length) q.status_any = stMn.includeVals
|
|
|
|
|
|
if (stMn.excludeVals.length) q.status_exclude_any = stMn.excludeVals
|
|
|
|
|
|
|
2026-04-29 06:15:26 +02:00
|
|
|
|
const sk = ids(filters.skill_ids)
|
|
|
|
|
|
if (sk?.length) q.skill_ids = sk
|
|
|
|
|
|
if (filters.skill_min_level) q.skill_min_level = n(filters.skill_min_level)
|
|
|
|
|
|
if (filters.skill_max_level) q.skill_max_level = n(filters.skill_max_level)
|
2026-05-06 13:52:24 +02:00
|
|
|
|
if (filters.exclude_without_focus) q.exclude_without_focus = true
|
|
|
|
|
|
if (filters.include_archived) q.include_archived = true
|
2026-05-22 22:24:49 +02:00
|
|
|
|
if (effectivePickerQuery) q.search = effectivePickerQuery
|
2026-05-13 06:30:53 +02:00
|
|
|
|
if (
|
|
|
|
|
|
Array.isArray(exerciseKindAny) &&
|
|
|
|
|
|
exerciseKindAny.length > 0
|
|
|
|
|
|
) {
|
|
|
|
|
|
q.exercise_kind_any = exerciseKindAny
|
|
|
|
|
|
}
|
2026-04-29 06:15:26 +02:00
|
|
|
|
return q
|
2026-05-22 22:24:49 +02:00
|
|
|
|
}, [filters, effectivePickerQuery, exerciseKindAny])
|
2026-04-29 06:15:26 +02:00
|
|
|
|
|
|
|
|
|
|
const reload = useCallback(async () => {
|
|
|
|
|
|
if (!open || !catalogsReady) return
|
2026-05-22 22:30:29 +02:00
|
|
|
|
if (planningSearchBlocked) {
|
|
|
|
|
|
setList([])
|
|
|
|
|
|
setHasMore(false)
|
|
|
|
|
|
setPlanningContextSummary(null)
|
|
|
|
|
|
setPlanningTargetProfileSummary(null)
|
|
|
|
|
|
setPlanningLlmRankApplied(false)
|
|
|
|
|
|
setPlanningQueryIntentSummary(null)
|
|
|
|
|
|
setPlanningIntentResolved(null)
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-04-29 06:15:26 +02:00
|
|
|
|
setLoading(true)
|
|
|
|
|
|
try {
|
2026-05-22 22:30:29 +02:00
|
|
|
|
if (usePlanningSearch && activePlanningContext) {
|
2026-05-22 22:21:06 +02:00
|
|
|
|
const query = effectivePickerQuery
|
2026-05-22 21:52:18 +02:00
|
|
|
|
const res = await api.suggestPlanningExercises({
|
2026-05-22 22:30:29 +02:00
|
|
|
|
unit_id: Number(activePlanningContext.unitId),
|
2026-05-22 21:52:18 +02:00
|
|
|
|
section_order_index:
|
2026-05-22 22:30:29 +02:00
|
|
|
|
activePlanningContext.sectionOrderIndex != null
|
|
|
|
|
|
? Number(activePlanningContext.sectionOrderIndex)
|
|
|
|
|
|
: null,
|
2026-05-22 21:52:18 +02:00
|
|
|
|
phase_order_index:
|
2026-05-22 22:30:29 +02:00
|
|
|
|
activePlanningContext.phaseOrderIndex != null
|
|
|
|
|
|
? Number(activePlanningContext.phaseOrderIndex)
|
|
|
|
|
|
: null,
|
2026-05-22 21:52:18 +02:00
|
|
|
|
parallel_stream_order_index:
|
2026-05-22 22:30:29 +02:00
|
|
|
|
activePlanningContext.parallelStreamOrderIndex != null
|
|
|
|
|
|
? Number(activePlanningContext.parallelStreamOrderIndex)
|
2026-05-22 21:52:18 +02:00
|
|
|
|
: null,
|
|
|
|
|
|
anchor_exercise_id:
|
2026-05-22 22:30:29 +02:00
|
|
|
|
activePlanningContext.anchorExerciseId != null
|
|
|
|
|
|
? Number(activePlanningContext.anchorExerciseId)
|
|
|
|
|
|
: null,
|
2026-05-22 21:52:18 +02:00
|
|
|
|
progression_graph_id:
|
2026-05-22 22:30:29 +02:00
|
|
|
|
activePlanningContext.progressionGraphId != null
|
|
|
|
|
|
? Number(activePlanningContext.progressionGraphId)
|
|
|
|
|
|
: null,
|
2026-05-22 22:09:28 +02:00
|
|
|
|
planned_exercise_ids:
|
2026-05-22 22:30:29 +02:00
|
|
|
|
Array.isArray(activePlanningContext.plannedExerciseIds) &&
|
|
|
|
|
|
activePlanningContext.plannedExerciseIds.length > 0
|
|
|
|
|
|
? activePlanningContext.plannedExerciseIds
|
|
|
|
|
|
.map((x) => Number(x))
|
|
|
|
|
|
.filter((x) => Number.isFinite(x) && x > 0)
|
2026-05-22 22:09:28 +02:00
|
|
|
|
: undefined,
|
2026-05-22 22:15:19 +02:00
|
|
|
|
include_llm_intent: Boolean(query),
|
2026-05-22 22:09:28 +02:00
|
|
|
|
include_llm_rank: true,
|
2026-05-22 21:52:18 +02:00
|
|
|
|
query,
|
2026-05-22 22:30:29 +02:00
|
|
|
|
intent_hint: activePlanningContext.intentHint || null,
|
2026-05-22 21:52:18 +02:00
|
|
|
|
limit: PAGE_SIZE,
|
|
|
|
|
|
exercise_kind_any:
|
|
|
|
|
|
Array.isArray(exerciseKindAny) && exerciseKindAny.length > 0 ? exerciseKindAny : undefined,
|
|
|
|
|
|
})
|
|
|
|
|
|
setPlanningContextSummary(res?.context_summary || null)
|
2026-05-22 22:04:34 +02:00
|
|
|
|
setPlanningTargetProfileSummary(res?.target_profile_summary || null)
|
2026-05-22 22:09:28 +02:00
|
|
|
|
setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied))
|
2026-05-22 22:15:19 +02:00
|
|
|
|
setPlanningQueryIntentSummary(res?.query_intent_summary || null)
|
2026-05-22 21:52:18 +02:00
|
|
|
|
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,
|
|
|
|
|
|
_planningScore: h.score,
|
|
|
|
|
|
_planningReasons: Array.isArray(h.reasons) ? h.reasons : [],
|
|
|
|
|
|
updated_at: new Date().toISOString(),
|
|
|
|
|
|
}))
|
|
|
|
|
|
setList(hits)
|
|
|
|
|
|
setHasMore(false)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setPlanningContextSummary(null)
|
2026-05-22 22:04:34 +02:00
|
|
|
|
setPlanningTargetProfileSummary(null)
|
2026-05-22 22:09:28 +02:00
|
|
|
|
setPlanningLlmRankApplied(false)
|
2026-05-22 22:15:19 +02:00
|
|
|
|
setPlanningQueryIntentSummary(null)
|
2026-05-22 21:52:18 +02:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-04-29 06:15:26 +02:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error(e)
|
|
|
|
|
|
alert(e.message || 'Laden fehlgeschlagen')
|
|
|
|
|
|
setList([])
|
|
|
|
|
|
setHasMore(false)
|
2026-05-22 21:52:18 +02:00
|
|
|
|
setPlanningContextSummary(null)
|
2026-05-22 22:04:34 +02:00
|
|
|
|
setPlanningTargetProfileSummary(null)
|
2026-05-22 22:09:28 +02:00
|
|
|
|
setPlanningLlmRankApplied(false)
|
2026-05-22 22:15:19 +02:00
|
|
|
|
setPlanningQueryIntentSummary(null)
|
2026-05-22 21:52:18 +02:00
|
|
|
|
setPlanningIntentResolved(null)
|
2026-04-29 06:15:26 +02:00
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
}
|
2026-05-22 21:52:18 +02:00
|
|
|
|
}, [
|
|
|
|
|
|
open,
|
|
|
|
|
|
catalogsReady,
|
|
|
|
|
|
queryBase,
|
|
|
|
|
|
usePlanningSearch,
|
2026-05-22 22:30:29 +02:00
|
|
|
|
planningSearchBlocked,
|
|
|
|
|
|
activePlanningContext,
|
2026-05-22 22:21:06 +02:00
|
|
|
|
effectivePickerQuery,
|
2026-05-22 21:52:18 +02:00
|
|
|
|
exerciseKindAny,
|
|
|
|
|
|
])
|
2026-04-29 06:15:26 +02:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
reload()
|
|
|
|
|
|
}, [reload])
|
|
|
|
|
|
|
|
|
|
|
|
const loadMore = async () => {
|
|
|
|
|
|
if (!hasMore || loadingMore || loading) return
|
2026-05-14 08:24:47 +02:00
|
|
|
|
const last = list[list.length - 1]
|
|
|
|
|
|
if (!last?.id || last.updated_at == null) return
|
2026-04-29 06:15:26 +02:00
|
|
|
|
setLoadingMore(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
const batch = await api.listExercises({
|
|
|
|
|
|
...queryBase,
|
2026-05-06 13:52:24 +02:00
|
|
|
|
include_archived: true,
|
2026-04-29 06:15:26 +02:00
|
|
|
|
include_variants: true,
|
|
|
|
|
|
limit: PAGE_SIZE,
|
2026-05-14 08:24:47 +02:00
|
|
|
|
cursor_updated_at:
|
|
|
|
|
|
typeof last.updated_at === 'string'
|
|
|
|
|
|
? last.updated_at
|
|
|
|
|
|
: new Date(last.updated_at).toISOString(),
|
|
|
|
|
|
cursor_id: last.id,
|
2026-04-29 06:15:26 +02:00
|
|
|
|
})
|
|
|
|
|
|
setList((prev) => [...prev, ...(Array.isArray(batch) ? batch : [])])
|
|
|
|
|
|
setHasMore(batch?.length === PAGE_SIZE)
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error(e)
|
|
|
|
|
|
alert(e.message || 'Mehr laden fehlgeschlagen')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoadingMore(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-14 08:59:06 +02:00
|
|
|
|
const rowVirtualizer = useVirtualizer({
|
|
|
|
|
|
count: list.length,
|
|
|
|
|
|
getScrollElement: () => pickerScrollRef.current,
|
2026-05-22 21:52:18 +02:00
|
|
|
|
estimateSize: (index) => {
|
|
|
|
|
|
const ex = list[index]
|
|
|
|
|
|
const rc = ex?._planningReasons?.length || 0
|
|
|
|
|
|
return rc > 0 ? 96 + Math.min(rc, 3) * 14 : 88
|
|
|
|
|
|
},
|
2026-05-14 08:59:06 +02:00
|
|
|
|
overscan: 8,
|
|
|
|
|
|
getItemKey: (index) => String(list[index]?.id ?? index),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-29 06:15:26 +02:00
|
|
|
|
const resetFilters = () => setFilters({ ...INITIAL_FILTERS })
|
|
|
|
|
|
|
2026-05-22 19:10:16 +02:00
|
|
|
|
const adoptExistingExercise = async (ex) => {
|
|
|
|
|
|
if (!ex?.id) return
|
|
|
|
|
|
if (multiSelect && typeof onSelectExercises === 'function') {
|
|
|
|
|
|
await Promise.resolve(onSelectExercises([ex]))
|
|
|
|
|
|
} else if (typeof onSelectExercise === 'function') {
|
|
|
|
|
|
await Promise.resolve(onSelectExercise(ex))
|
|
|
|
|
|
}
|
|
|
|
|
|
onClose()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const runQuickCreateAiSuggest = async () => {
|
2026-05-07 09:20:19 +02:00
|
|
|
|
const title = (quickTitle || '').trim()
|
|
|
|
|
|
if (title.length < 3) {
|
|
|
|
|
|
alert('Titel: mindestens 3 Zeichen.')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-05-22 19:01:01 +02:00
|
|
|
|
const sketch = (quickSketch || '').trim()
|
2026-05-22 19:36:22 +02:00
|
|
|
|
|
2026-05-22 19:01:01 +02:00
|
|
|
|
const focusId = parseInt(String(quickFocusAreaId).trim(), 10)
|
|
|
|
|
|
if (!Number.isFinite(focusId) || focusId < 1) {
|
2026-05-22 19:10:16 +02:00
|
|
|
|
alert('Bitte einen Fokusbereich wählen.')
|
2026-05-22 19:01:01 +02:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const focusRow = (catalogs.focusAreas || []).find((x) => Number(x.id) === focusId)
|
|
|
|
|
|
const focusHint = (focusRow?.name || '').trim()
|
|
|
|
|
|
|
|
|
|
|
|
setQuickAiError('')
|
2026-05-22 19:24:36 +02:00
|
|
|
|
setQuickCreateDraft(null)
|
2026-05-07 09:20:19 +02:00
|
|
|
|
setQuickSaving(true)
|
|
|
|
|
|
try {
|
2026-05-22 19:01:01 +02:00
|
|
|
|
const aiRes = await api.suggestExerciseAi({
|
|
|
|
|
|
title,
|
2026-05-22 19:36:22 +02:00
|
|
|
|
goal: sketch || undefined,
|
2026-05-22 19:01:01 +02:00
|
|
|
|
execution: '',
|
|
|
|
|
|
preparation: '',
|
|
|
|
|
|
trainer_notes: '',
|
|
|
|
|
|
focus_area_hint: focusHint || undefined,
|
|
|
|
|
|
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
|
|
|
|
|
|
include_summary: true,
|
|
|
|
|
|
include_skills: true,
|
|
|
|
|
|
include_instructions: true,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-22 19:10:16 +02:00
|
|
|
|
const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: sketch })
|
|
|
|
|
|
if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) {
|
|
|
|
|
|
throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.')
|
|
|
|
|
|
}
|
2026-05-22 19:24:36 +02:00
|
|
|
|
setQuickCreateDraft(
|
|
|
|
|
|
aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }),
|
|
|
|
|
|
)
|
2026-05-22 19:10:16 +02:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error(e)
|
|
|
|
|
|
const msg = e?.message || String(e)
|
|
|
|
|
|
setQuickAiError(msg)
|
|
|
|
|
|
alert(msg || 'KI-Vorschlag fehlgeschlagen')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setQuickSaving(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 19:24:36 +02:00
|
|
|
|
const applyQuickCreateDraft = async () => {
|
|
|
|
|
|
if (!quickCreateDraft) return
|
2026-05-22 19:10:16 +02:00
|
|
|
|
|
|
|
|
|
|
setQuickSaving(true)
|
|
|
|
|
|
setQuickAiError('')
|
|
|
|
|
|
try {
|
2026-05-22 19:24:36 +02:00
|
|
|
|
const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft)
|
2026-05-22 19:01:01 +02:00
|
|
|
|
const created = await api.createExercise(payload)
|
2026-05-22 19:10:16 +02:00
|
|
|
|
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
|
2026-05-22 19:24:36 +02:00
|
|
|
|
setQuickCreateDraft(null)
|
2026-05-22 19:10:16 +02:00
|
|
|
|
await adoptExistingExercise(created)
|
2026-05-07 09:20:19 +02:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error(e)
|
2026-05-22 19:01:01 +02:00
|
|
|
|
const msg = e?.message || String(e)
|
|
|
|
|
|
setQuickAiError(msg)
|
|
|
|
|
|
alert(msg || 'Übung konnte nicht angelegt werden')
|
2026-05-07 09:20:19 +02:00
|
|
|
|
} finally {
|
|
|
|
|
|
setQuickSaving(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-29 06:15:26 +02:00
|
|
|
|
if (!open) return null
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="admin-modal-backdrop"
|
|
|
|
|
|
role="presentation"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
if (e.target === e.currentTarget) onClose()
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="admin-modal-sheet"
|
|
|
|
|
|
role="dialog"
|
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
|
style={{ maxWidth: '920px', width: '100%', maxHeight: '92vh', display: 'flex', flexDirection: 'column' }}
|
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="admin-modal-sheet__header">
|
2026-05-05 14:42:46 +02:00
|
|
|
|
<h3 className="admin-modal-sheet__title">
|
2026-05-22 22:24:49 +02:00
|
|
|
|
{usePlanningSearch
|
|
|
|
|
|
? multiSelect
|
|
|
|
|
|
? 'Planungs-KI: Übungen vorschlagen'
|
|
|
|
|
|
: 'Planungs-KI: Übung vorschlagen'
|
|
|
|
|
|
: multiSelect
|
|
|
|
|
|
? 'Übungen auswählen'
|
|
|
|
|
|
: 'Übung auswählen'}
|
2026-05-05 14:42:46 +02:00
|
|
|
|
</h3>
|
2026-04-29 06:15:26 +02:00
|
|
|
|
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
|
|
|
|
|
|
Schließen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ padding: '0 1rem 0.75rem', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
|
2026-05-22 21:52:18 +02:00
|
|
|
|
{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.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}
|
2026-05-22 22:04:34 +02:00
|
|
|
|
{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}
|
2026-05-22 21:52:18 +02:00
|
|
|
|
</div>
|
2026-05-22 22:04:34 +02:00
|
|
|
|
{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}
|
2026-05-22 22:15:19 +02:00
|
|
|
|
{planningQueryIntentSummary?.rationale ? (
|
|
|
|
|
|
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text2)' }}>
|
|
|
|
|
|
{planningQueryIntentSummary.rationale}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
2026-05-22 21:52:18 +02:00
|
|
|
|
{planningIntentResolved ? (
|
|
|
|
|
|
<p style={{ margin: '6px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
|
|
|
|
|
|
Modus: {planningIntentResolved.replace(/_/g, ' ')}
|
2026-05-22 22:15:19 +02:00
|
|
|
|
{planningQueryIntentSummary?.scenario
|
|
|
|
|
|
? ` · ${String(planningQueryIntentSummary.scenario).replace(/_/g, ' ')}`
|
|
|
|
|
|
: null}
|
2026-05-22 22:09:28 +02:00
|
|
|
|
{planningLlmRankApplied ? ' · KI-Ranking aktiv' : null}
|
2026-05-22 22:15:19 +02:00
|
|
|
|
{planningQueryIntentSummary?.llm_applied ? ' · KI-Intent aktiv' : null}
|
2026-05-22 21:52:18 +02:00
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
2026-05-22 22:30:29 +02:00
|
|
|
|
{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> Die Einheit hat noch keine gespeicherte ID —
|
|
|
|
|
|
bitte zuerst <strong>Speichern</strong>, dann den Übungspicker erneut öffnen. Bis dahin gilt nur die
|
|
|
|
|
|
Bibliothekssuche (Volltext).
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
{!usePlanningSearch && !planningSearchBlocked ? (
|
2026-05-22 22:24:49 +02:00
|
|
|
|
<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> — Planungs-KI mit
|
|
|
|
|
|
Kontext (Einheit, Plan, Anker) gibt es in der{' '}
|
|
|
|
|
|
<strong>Trainingseinheit bearbeiten</strong>, nach dem Speichern der Einheit.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : null}
|
2026-04-29 06:15:26 +02:00
|
|
|
|
<div style={{ display: 'grid', gap: '0.65rem' }}>
|
2026-05-22 22:24:49 +02:00
|
|
|
|
{usePlanningSearch ? (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="form-label">Planungs-Anfrage (KI)</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="search"
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
placeholder="z. B. Schlage mir die nächste Übung vor, baut auf dem Plan auf und trainiert Schnellkraft …"
|
|
|
|
|
|
value={searchInput || aiSearchInput}
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
const v = e.target.value
|
|
|
|
|
|
setSearchInput(v)
|
|
|
|
|
|
setAiSearchInput(v)
|
|
|
|
|
|
}}
|
|
|
|
|
|
autoComplete="off"
|
|
|
|
|
|
/>
|
2026-05-22 22:21:06 +02:00
|
|
|
|
<p style={{ margin: '4px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
|
2026-05-22 22:24:49 +02:00
|
|
|
|
Leer lassen = nächste Übung aus Planungskontext. Mit Text = KI-Intent + Profil + Ranking.
|
2026-05-22 22:21:06 +02:00
|
|
|
|
</p>
|
2026-05-22 22:24:49 +02:00
|
|
|
|
</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>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-04-29 06:15:26 +02:00
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'center' }}>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => setFilterOpen(!filterOpen)}>
|
|
|
|
|
|
{filterOpen ? 'Filter ausblenden' : 'Erweiterte Filter'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" className="btn" onClick={resetFilters}>
|
|
|
|
|
|
Filter zurücksetzen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{loading && <span style={{ fontSize: '13px', color: 'var(--text2)' }}>Suche läuft…</span>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{filterOpen && (
|
|
|
|
|
|
<div style={{ marginTop: '0.35rem', fontSize: '13px', color: 'var(--text2)' }}>
|
|
|
|
|
|
<p style={{ margin: '0 0 12px 0' }}>
|
2026-05-06 21:20:19 +02:00
|
|
|
|
Felder gelten mit <strong>UND</strong>. Kataloge: mehrere „+“ = alle zutreffend; „−“ schließt aus.
|
2026-05-21 15:08:35 +02:00
|
|
|
|
Freigabelevel/Status: mehrere „+“ = eine davon (ODER); „−“ blendet aus.
|
2026-04-29 06:15:26 +02:00
|
|
|
|
</p>
|
2026-05-06 17:15:44 +02:00
|
|
|
|
<ExerciseFocusRulePicker
|
|
|
|
|
|
focusOptions={focusOptions}
|
|
|
|
|
|
focusRules={filters.focus_rules}
|
|
|
|
|
|
focusOnlyWithout={filters.focus_only_without}
|
|
|
|
|
|
legacyFocusAreaIds={filters.focus_area_ids}
|
|
|
|
|
|
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
|
|
|
|
|
/>
|
2026-05-06 21:20:19 +02:00
|
|
|
|
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--catalog" style={{ marginTop: 12 }}>
|
|
|
|
|
|
<CatalogRulePicker
|
|
|
|
|
|
label="Stilrichtung"
|
|
|
|
|
|
hint="+ alle nötig (UND). − verbietet Zuordnung."
|
|
|
|
|
|
options={styleOptions}
|
|
|
|
|
|
rules={filters.style_direction_rules}
|
|
|
|
|
|
rulesFieldName="style_direction_rules"
|
|
|
|
|
|
placeholder="Stil …"
|
|
|
|
|
|
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<CatalogRulePicker
|
|
|
|
|
|
label="Trainingsstil"
|
|
|
|
|
|
hint="+ alle nötig (UND). − verbietet Zuordnung."
|
|
|
|
|
|
options={trainingTypeOptions}
|
|
|
|
|
|
rules={filters.training_type_rules}
|
|
|
|
|
|
rulesFieldName="training_type_rules"
|
|
|
|
|
|
placeholder="Trainingsstil …"
|
|
|
|
|
|
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<CatalogRulePicker
|
|
|
|
|
|
label="Zielgruppe"
|
|
|
|
|
|
hint="+ alle nötig (UND). − verbietet Zuordnung."
|
|
|
|
|
|
options={targetGroupOptions}
|
|
|
|
|
|
rules={filters.target_group_rules}
|
|
|
|
|
|
rulesFieldName="target_group_rules"
|
|
|
|
|
|
placeholder="Gruppe …"
|
|
|
|
|
|
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
|
|
|
|
|
/>
|
2026-04-29 06:15:26 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ marginTop: 12 }}>
|
|
|
|
|
|
<label className="form-label">Fähigkeit</label>
|
2026-05-20 10:59:17 +02:00
|
|
|
|
<SkillTreeMultiSelect
|
2026-04-29 06:15:26 +02:00
|
|
|
|
value={filters.skill_ids}
|
|
|
|
|
|
onChange={(v) => setFilters((f) => ({ ...f, skill_ids: v }))}
|
2026-05-20 10:59:17 +02:00
|
|
|
|
skills={catalogs.skills}
|
2026-04-29 06:15:26 +02:00
|
|
|
|
placeholder="Fähigkeit …"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="exercise-filter-skill-levels-row" style={{ marginTop: 8 }}>
|
|
|
|
|
|
<select
|
|
|
|
|
|
className="form-input exercise-filter-level-select"
|
|
|
|
|
|
title="von"
|
|
|
|
|
|
value={filters.skill_min_level}
|
|
|
|
|
|
onChange={(e) => setFilters((f) => ({ ...f, skill_min_level: e.target.value }))}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">Stufe von</option>
|
|
|
|
|
|
{LEVEL_FILTER_OPTS.map((o) => (
|
|
|
|
|
|
<option key={o.value} value={String(o.level)}>
|
|
|
|
|
|
{o.level}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
<select
|
|
|
|
|
|
className="form-input exercise-filter-level-select"
|
|
|
|
|
|
title="bis"
|
|
|
|
|
|
value={filters.skill_max_level}
|
|
|
|
|
|
onChange={(e) => setFilters((f) => ({ ...f, skill_max_level: e.target.value }))}
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">Stufe bis</option>
|
|
|
|
|
|
{LEVEL_FILTER_OPTS.map((o) => (
|
|
|
|
|
|
<option key={`m-${o.value}`} value={String(o.level)}>
|
|
|
|
|
|
{o.level}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-06 21:20:19 +02:00
|
|
|
|
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--two exercise-filters-modal-grid--catalog" style={{ marginTop: 12 }}>
|
|
|
|
|
|
<CatalogRulePicker
|
2026-05-21 15:08:35 +02:00
|
|
|
|
label={EXERCISE_VISIBILITY_FIELD_LABEL}
|
2026-05-06 21:20:19 +02:00
|
|
|
|
options={visibilityOptions}
|
|
|
|
|
|
rules={filters.visibility_rules}
|
|
|
|
|
|
rulesFieldName="visibility_rules"
|
|
|
|
|
|
idKind="string"
|
2026-05-21 15:08:35 +02:00
|
|
|
|
placeholder="Freigabelevel …"
|
2026-05-06 21:20:19 +02:00
|
|
|
|
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<CatalogRulePicker
|
|
|
|
|
|
label="Status"
|
|
|
|
|
|
options={statusOptions}
|
|
|
|
|
|
rules={filters.status_rules}
|
|
|
|
|
|
rulesFieldName="status_rules"
|
|
|
|
|
|
idKind="string"
|
|
|
|
|
|
placeholder="Status …"
|
|
|
|
|
|
onPatch={(patch) => setFilters((f) => ({ ...f, ...patch }))}
|
|
|
|
|
|
/>
|
2026-05-06 13:52:24 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
2026-05-06 17:15:44 +02:00
|
|
|
|
<label
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
gap: 8,
|
|
|
|
|
|
cursor: filters.focus_only_without ? 'not-allowed' : 'pointer',
|
|
|
|
|
|
opacity: filters.focus_only_without ? 0.55 : 1,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-05-06 13:52:24 +02:00
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
2026-05-06 17:15:44 +02:00
|
|
|
|
disabled={!!filters.focus_only_without}
|
2026-05-06 13:52:24 +02:00
|
|
|
|
checked={!!filters.exclude_without_focus}
|
2026-05-06 17:15:44 +02:00
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setFilters((f) => ({
|
|
|
|
|
|
...f,
|
|
|
|
|
|
exclude_without_focus: e.target.checked,
|
|
|
|
|
|
...(e.target.checked ? { focus_only_without: false } : {}),
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
2026-05-06 13:52:24 +02:00
|
|
|
|
/>
|
|
|
|
|
|
<span>Ohne Fokus ausblenden</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<p style={{ margin: 0, fontSize: '12px', color: 'var(--text2)' }}>
|
|
|
|
|
|
Hinweis: Für die Planung werden archivierte Übungen bei der Suche immer mit eingeschlossen (bestehende
|
|
|
|
|
|
Zuordnungen).
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2026-04-29 06:15:26 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-14 08:59:06 +02:00
|
|
|
|
<div
|
|
|
|
|
|
ref={pickerScrollRef}
|
|
|
|
|
|
data-testid="exercise-picker-scroll"
|
|
|
|
|
|
style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '12px 1rem' }}
|
|
|
|
|
|
>
|
2026-04-29 06:15:26 +02:00
|
|
|
|
{!catalogsReady || (loading && list.length === 0) ? (
|
|
|
|
|
|
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
|
|
|
|
|
<div className="spinner" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : list.length === 0 ? (
|
2026-05-22 19:24:36 +02:00
|
|
|
|
showQuickCreateOffer ? (
|
|
|
|
|
|
<ExerciseAiQuickCreateOffer
|
2026-05-22 22:21:06 +02:00
|
|
|
|
searchLabel={effectivePickerQuery}
|
2026-05-22 19:24:36 +02:00
|
|
|
|
title={quickTitle}
|
|
|
|
|
|
onTitleChange={setQuickTitle}
|
|
|
|
|
|
sketch={quickSketch}
|
|
|
|
|
|
onSketchChange={setQuickSketch}
|
|
|
|
|
|
focusAreaId={quickFocusAreaId}
|
|
|
|
|
|
onFocusAreaChange={setQuickFocusAreaId}
|
|
|
|
|
|
focusAreas={catalogs.focusAreas}
|
|
|
|
|
|
catalogsReady={catalogsReady}
|
|
|
|
|
|
busy={quickSaving}
|
|
|
|
|
|
error={quickAiError}
|
|
|
|
|
|
onRunAi={runQuickCreateAiSuggest}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
|
2026-05-22 22:21:06 +02:00
|
|
|
|
{usePlanningSearch
|
|
|
|
|
|
? effectivePickerQuery
|
|
|
|
|
|
? 'Keine KI-Vorschläge für diese Anfrage.'
|
|
|
|
|
|
: 'Keine Vorschläge — Einheit speichern und Planungskontext prüfen, oder Anfrage eingeben.'
|
|
|
|
|
|
: effectivePickerQuery.length >= 3
|
|
|
|
|
|
? 'Keine Treffer.'
|
|
|
|
|
|
: 'Suchbegriff eingeben (mind. 3 Zeichen) …'}
|
2026-05-22 19:24:36 +02:00
|
|
|
|
</p>
|
|
|
|
|
|
)
|
2026-04-29 06:15:26 +02:00
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: 10 }}>
|
2026-05-22 21:52:18 +02:00
|
|
|
|
{usePlanningSearch ? `${list.length} KI-Vorschläge` : `${list.length} angezeigt`}
|
|
|
|
|
|
{hasMore ? ' · weiter unten „Mehr laden“' : ''}
|
2026-04-29 06:15:26 +02:00
|
|
|
|
</p>
|
2026-05-14 08:59:06 +02:00
|
|
|
|
<div
|
|
|
|
|
|
role="list"
|
|
|
|
|
|
aria-label="Übungstreffer"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
position: 'relative',
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
height: rowVirtualizer.getTotalSize(),
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{rowVirtualizer.getVirtualItems().map((vi) => {
|
|
|
|
|
|
const ex = list[vi.index]
|
|
|
|
|
|
if (!ex) return null
|
2026-05-05 14:42:46 +02:00
|
|
|
|
const picked = multiPicked.some((p) => p.id === ex.id)
|
|
|
|
|
|
const rowInner = (
|
|
|
|
|
|
<>
|
2026-04-29 06:15:26 +02:00
|
|
|
|
<strong style={{ display: 'block' }}>{ex.title}</strong>
|
|
|
|
|
|
{(ex.summary || '').trim().length > 0 && (
|
|
|
|
|
|
<span style={{ fontSize: '12px', color: 'var(--text2)' }}>
|
2026-05-05 14:42:46 +02:00
|
|
|
|
{(ex.summary || '').length > 120
|
|
|
|
|
|
? `${(ex.summary || '').slice(0, 120)}…`
|
|
|
|
|
|
: ex.summary}
|
2026-04-29 06:15:26 +02:00
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{ex.focus_area && (
|
|
|
|
|
|
<span className="exercise-tag exercise-tag--accent" style={{ marginTop: 6 }}>
|
|
|
|
|
|
{ex.focus_area}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2026-05-13 06:30:53 +02:00
|
|
|
|
{(ex.exercise_kind || '').toLowerCase().trim() === 'combination' ? (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className="exercise-tag"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
marginTop: 6,
|
|
|
|
|
|
marginLeft: 6,
|
|
|
|
|
|
background: 'var(--accent-soft)',
|
|
|
|
|
|
color: 'var(--accent-dark)',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Kombination
|
|
|
|
|
|
</span>
|
|
|
|
|
|
) : null}
|
2026-05-22 21:52:18 +02:00
|
|
|
|
{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}
|
2026-05-05 14:42:46 +02:00
|
|
|
|
</>
|
|
|
|
|
|
)
|
2026-05-14 08:59:06 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={vi.key}
|
|
|
|
|
|
role="listitem"
|
|
|
|
|
|
data-index={vi.index}
|
|
|
|
|
|
ref={rowVirtualizer.measureElement}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
top: 0,
|
|
|
|
|
|
left: 0,
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
transform: `translateY(${vi.start}px)`,
|
|
|
|
|
|
paddingBottom: 8,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{multiSelect ? (
|
2026-05-05 14:42:46 +02:00
|
|
|
|
<label
|
|
|
|
|
|
className="tu-ex-picker-multi-row"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
gap: '10px',
|
|
|
|
|
|
alignItems: 'flex-start',
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
textAlign: 'left',
|
|
|
|
|
|
padding: '10px 12px',
|
|
|
|
|
|
borderRadius: '8px',
|
|
|
|
|
|
border: picked ? '2px solid var(--accent)' : '1px solid var(--border)',
|
|
|
|
|
|
background: 'var(--surface2)',
|
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
|
boxSizing: 'border-box',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={picked}
|
|
|
|
|
|
onChange={() => toggleMultiPick(ex)}
|
|
|
|
|
|
style={{ marginTop: '0.35rem', flexShrink: 0 }}
|
|
|
|
|
|
aria-label={ex.title ? `Auswahl: ${ex.title}` : 'Auswahl'}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div style={{ flex: 1, minWidth: 0 }}>{rowInner}</div>
|
|
|
|
|
|
</label>
|
2026-05-14 08:59:06 +02:00
|
|
|
|
) : (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
onSelectExercise(ex)
|
|
|
|
|
|
onClose()
|
|
|
|
|
|
}}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
textAlign: 'left',
|
|
|
|
|
|
padding: '10px 12px',
|
|
|
|
|
|
borderRadius: '8px',
|
|
|
|
|
|
border: '1px solid var(--border)',
|
|
|
|
|
|
background: 'var(--surface2)',
|
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{rowInner}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-05-05 14:42:46 +02:00
|
|
|
|
)
|
|
|
|
|
|
})}
|
2026-05-14 08:59:06 +02:00
|
|
|
|
</div>
|
2026-04-29 06:15:26 +02:00
|
|
|
|
{hasMore && (
|
|
|
|
|
|
<div style={{ textAlign: 'center', marginTop: 12 }}>
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" disabled={loadingMore} onClick={loadMore}>
|
|
|
|
|
|
{loadingMore ? 'Laden…' : 'Mehr laden'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-05-05 14:42:46 +02:00
|
|
|
|
{multiSelect && typeof onSelectExercises === 'function' ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="exercise-picker-multi-footer"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
position: 'sticky',
|
|
|
|
|
|
bottom: 0,
|
|
|
|
|
|
marginTop: 16,
|
|
|
|
|
|
paddingTop: 12,
|
|
|
|
|
|
borderTop: '1px solid var(--border)',
|
|
|
|
|
|
background: 'var(--surface)',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
flexWrap: 'wrap',
|
|
|
|
|
|
gap: '10px',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span style={{ fontSize: '0.92rem', color: 'var(--text2)' }}>
|
|
|
|
|
|
{multiPicked.length} ausgewählt
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
onClick={() => setMultiPicked([])}
|
|
|
|
|
|
disabled={!multiPicked.length}
|
|
|
|
|
|
>
|
|
|
|
|
|
Auswahl leeren
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-primary"
|
|
|
|
|
|
disabled={!multiPicked.length}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
onSelectExercises([...multiPicked])
|
|
|
|
|
|
onClose()
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
Übernehmen
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
2026-04-29 06:15:26 +02:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-22 19:10:16 +02:00
|
|
|
|
|
|
|
|
|
|
<ExerciseAiSuggestPreviewModal
|
2026-05-22 19:24:36 +02:00
|
|
|
|
draft={quickCreateDraft}
|
|
|
|
|
|
onDraftChange={setQuickCreateDraft}
|
|
|
|
|
|
onDiscard={() => setQuickCreateDraft(null)}
|
|
|
|
|
|
onApply={applyQuickCreateDraft}
|
|
|
|
|
|
focusAreas={catalogs.focusAreas}
|
2026-05-22 19:10:16 +02:00
|
|
|
|
skillsCatalog={catalogs.skills}
|
2026-05-22 19:24:36 +02:00
|
|
|
|
dialogTitle="Neue Übung — KI-Entwurf bearbeiten"
|
|
|
|
|
|
hint="Texte sind formatiert — passe Titel, Kurzfassung, Anleitung und Fähigkeiten an, dann speichern und übernehmen."
|
2026-05-22 19:10:16 +02:00
|
|
|
|
applyLabel={quickSaving ? 'Wird angelegt…' : 'Anlegen und übernehmen'}
|
|
|
|
|
|
applyDisabled={quickSaving}
|
|
|
|
|
|
zIndex={2100}
|
|
|
|
|
|
/>
|
2026-04-29 06:15:26 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|