shinkan-jinkendo/frontend/src/components/ExercisePickerModal.jsx
Lars 905bce198f
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 40s
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
Refactor ExercisePickerModal to Utilize Effective Query for AI Suggestions
- Introduced `effectivePickerQuery` to streamline search input handling, combining `debouncedSearch` and `debouncedAi` for improved query accuracy.
- Updated the `useExerciseAiQuickCreateFields` hook to use the new effective query, enhancing the quick create functionality.
- Modified conditional checks to utilize `effectivePickerQuery`, ensuring better user feedback based on search input.
- Improved placeholder text and labels for clarity in the search fields, enhancing user experience during exercise selection.
2026-05-22 22:21:06 +02:00

1001 lines
40 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 from './ExerciseAiQuickCreateOffer'
import { useExerciseAiQuickCreateFields } from '../hooks/useExerciseAiQuickCreateFields'
import {
buildQuickCreateAiPreview,
buildQuickCreateExercisePayloadFromDraft,
aiPreviewToQuickCreateDraft,
} from '../utils/exerciseAiQuickCreate'
const PAGE_SIZE = 100
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,
/** Planungs-Kontext für KI-Suche (TrainingUnitEditPage o. ä.) */
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 [planningContextSummary, setPlanningContextSummary] = useState(null)
const [planningTargetProfileSummary, setPlanningTargetProfileSummary] = useState(null)
const [planningLlmRankApplied, setPlanningLlmRankApplied] = useState(false)
const [planningQueryIntentSummary, setPlanningQueryIntentSummary] = useState(null)
const [planningIntentResolved, setPlanningIntentResolved] = useState(null)
const pickerScrollRef = useRef(null)
const usePlanningSearch = Boolean(planningContext?.unitId && Number(planningContext.unitId) > 0)
const effectivePickerQuery = useMemo(
() => [debouncedSearch, debouncedAi].filter(Boolean).join(' ').trim(),
[debouncedSearch, debouncedAi]
)
const {
title: quickTitle,
sketch: quickSketch,
focusAreaId: quickFocusAreaId,
setTitle: setQuickTitle,
setSketch: setQuickSketch,
setFocusAreaId: setQuickFocusAreaId,
resetQuickCreateFields,
} = useExerciseAiQuickCreateFields(effectivePickerQuery, { enabled: open && enableQuickCreateDraft })
const toggleMultiPick = (ex) => {
setMultiPicked((prev) =>
prev.some((p) => p.id === ex.id) ? prev.filter((p) => p.id !== ex.id) : [...prev, ex]
)
}
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 showQuickCreateOffer =
enableQuickCreateDraft &&
catalogsReady &&
!loading &&
list.length === 0 &&
(usePlanningSearch || effectivePickerQuery.length >= 3)
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)
setPlanningContextSummary(null)
setPlanningTargetProfileSummary(null)
setPlanningLlmRankApplied(false)
setPlanningQueryIntentSummary(null)
setPlanningIntentResolved(null)
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 (debouncedSearch) q.search = debouncedSearch
if (debouncedAi) q.ai_search = debouncedAi
if (!debouncedSearch && debouncedAi) q.search = debouncedAi
if (
Array.isArray(exerciseKindAny) &&
exerciseKindAny.length > 0
) {
q.exercise_kind_any = exerciseKindAny
}
return q
}, [filters, debouncedSearch, debouncedAi, exerciseKindAny])
const reload = useCallback(async () => {
if (!open || !catalogsReady) return
setLoading(true)
try {
if (usePlanningSearch) {
const query = effectivePickerQuery
const res = await api.suggestPlanningExercises({
unit_id: Number(planningContext.unitId),
section_order_index:
planningContext.sectionOrderIndex != null ? Number(planningContext.sectionOrderIndex) : null,
phase_order_index:
planningContext.phaseOrderIndex != null ? Number(planningContext.phaseOrderIndex) : null,
parallel_stream_order_index:
planningContext.parallelStreamOrderIndex != null
? Number(planningContext.parallelStreamOrderIndex)
: null,
anchor_exercise_id:
planningContext.anchorExerciseId != null ? Number(planningContext.anchorExerciseId) : null,
progression_graph_id:
planningContext.progressionGraphId != null ? Number(planningContext.progressionGraphId) : null,
planned_exercise_ids:
Array.isArray(planningContext.plannedExerciseIds) && planningContext.plannedExerciseIds.length > 0
? planningContext.plannedExerciseIds.map((x) => Number(x)).filter((x) => Number.isFinite(x) && x > 0)
: undefined,
include_llm_intent: Boolean(query),
include_llm_rank: true,
query,
intent_hint: planningContext.intentHint || null,
limit: PAGE_SIZE,
exercise_kind_any:
Array.isArray(exerciseKindAny) && exerciseKindAny.length > 0 ? exerciseKindAny : undefined,
})
setPlanningContextSummary(res?.context_summary || null)
setPlanningTargetProfileSummary(res?.target_profile_summary || null)
setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied))
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,
_planningScore: h.score,
_planningReasons: Array.isArray(h.reasons) ? h.reasons : [],
updated_at: new Date().toISOString(),
}))
setList(hits)
setHasMore(false)
} else {
setPlanningContextSummary(null)
setPlanningTargetProfileSummary(null)
setPlanningLlmRankApplied(false)
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)
setPlanningContextSummary(null)
setPlanningTargetProfileSummary(null)
setPlanningLlmRankApplied(false)
setPlanningQueryIntentSummary(null)
setPlanningIntentResolved(null)
} finally {
setLoading(false)
}
}, [
open,
catalogsReady,
queryBase,
usePlanningSearch,
planningContext,
effectivePickerQuery,
debouncedSearch,
debouncedAi,
exerciseKindAny,
])
useEffect(() => {
reload()
}, [reload])
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
return rc > 0 ? 96 + Math.min(rc, 3) * 14 : 88
},
overscan: 8,
getItemKey: (index) => String(list[index]?.id ?? index),
})
const resetFilters = () => setFilters({ ...INITIAL_FILTERS })
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 () => {
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)
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 }],
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">
{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.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}
{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>
{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}
{planningQueryIntentSummary?.llm_applied ? ' · KI-Intent aktiv' : null}
</p>
) : null}
</div>
) : null}
<div style={{ display: 'grid', gap: '0.65rem' }}>
<div>
<label className="form-label">
{usePlanningSearch ? 'Planungs-Anfrage' : 'Volltextsuche'}
</label>
<input
type="search"
className="form-input"
placeholder={
usePlanningSearch
? 'z. B. Schlage mir die nächste Übung vor, Vertiefung, Reaktion mit Partner …'
: 'Stichwort, Titelfragment…'
}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
autoComplete="off"
/>
</div>
<div>
<label className="form-label">
{usePlanningSearch ? 'Planungs-Anfrage (Zusatz, optional)' : 'Semantisch / '}
{!usePlanningSearch ? (
<span title="aktuell gleiche Datenbanksuche wie Volltext; später KI-Verfeinerung möglich">
KI-Feld
</span>
) : null}
</label>
<input
type="search"
className="form-input"
placeholder={
usePlanningSearch
? 'Alternative Formulierung — wird mit oben kombiniert'
: 'zweites Suchkonzept oder Umschreibung…'
}
value={aiSearchInput}
onChange={(e) => setAiSearchInput(e.target.value)}
autoComplete="off"
/>
{usePlanningSearch ? (
<p style={{ margin: '4px 0 0', fontSize: '11px', color: 'var(--text3)' }}>
Beide Felder bilden eine gemeinsame Planungs-Anfrage.
</p>
) : null}
</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 ? (
showQuickCreateOffer ? (
<ExerciseAiQuickCreateOffer
searchLabel={effectivePickerQuery}
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' }}>
{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) …'}
</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}
</>
)
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={() => {
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>
)
})}
</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>
)}
{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}
</>
)}
</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>
)
}