import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react'
import { useNavigate, useParams, Link, useLocation } from 'react-router-dom'
import api, { buildExerciseApiPayload } from '../../utils/api'
import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../../utils/exerciseMediaUrl'
import RichTextEditor from '../RichTextEditor'
import ExerciseProgressionGraphPanel from '../ExerciseProgressionGraphPanel'
import ExerciseMediaThumbTile from '../ExerciseMediaThumbTile'
import MediaPreviewModal from '../MediaPreviewModal'
import ReportContentModal from '../ReportContentModal'
import CombinationMethodProfileEditor from '../CombinationMethodProfileEditor'
import ExercisePickerModal from '../ExercisePickerModal'
import {
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
buildExerciseMediaDragPayload,
} from '../../utils/exerciseInlineMediaRefs'
import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll'
import { normalizeSkillLevelSlug, formatSkillLevelSlug } from '../../constants/skillLevels'
import { stripHtmlToText } from '../../utils/htmlUtils'
import ExerciseCatalogAssocEditor from './ExerciseCatalogAssocEditor'
import ExerciseSkillsEditor from './ExerciseSkillsEditor'
import { useAuth } from '../../context/AuthContext'
import FeatureUsageBadge from '../FeatureUsageBadge'
import { useToast } from '../../context/ToastContext'
import {
activeClubMemberships,
getDefaultClubIdForGovernanceForms,
getTenantClubDependencyKey,
} from '../../utils/activeClub'
import { COMBINATION_ARCHETYPE_OPTIONS, ARCHETYPE_DEFAULT_REP_SERIES_COUNT, defaultRepSeriesCountForArchetype } from '../../constants/combinationArchetypes'
import { readSlotProfilesV1, normalizeAdvanceMode, parseComboRepSeriesCountUi } from '../../utils/combinationMethodProfileUi'
import { GripVertical, FileText, BookOpen, Tags, Layers, GitBranch, Image as ImageIcon } from 'lucide-react'
import UnsavedChangesPrompt from '../UnsavedChangesPrompt'
import PageFormEditorChrome from '../PageFormEditorChrome'
import { ExerciseFormTabBar, ExerciseFormPanel } from './ExerciseFormLayout'
import { useNavReturn } from '../../hooks/useNavReturn'
import {
EXERCISES_LIST_PATH,
buildCurrentLocationReturnContext,
buildExercisesListReturnContext,
linkStateWithAppReturn,
preserveAppReturnOnNavigate,
} from '../../utils/navReturnContext'
import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../../hooks/useUnsavedChangesBlocker'
import {
EXERCISE_SKILL_INTENSITY_DEFAULT,
normalizeExerciseSkillIntensity,
formatExerciseSkillIntensityLabel,
} from '../../constants/exerciseSkillIntensity'
import {
EXERCISE_VISIBILITY_CLUB_FIELD_LABEL,
EXERCISE_VISIBILITY_FIELD_LABEL,
} from '../../constants/exerciseGovernanceLabels'
const VARIANT_DIFFICULTY = [
{ value: '', label: '—' },
{ value: 'easier', label: 'Einfacher' },
{ value: 'same', label: 'Gleich' },
{ value: 'harder', label: 'Schwerer' },
{ value: 'adapted', label: 'Angepasst' },
]
/** HTML5-DnD für Kombi-Stationen (Reihenfolge = Ablauf). */
const DND_EXERCISE_COMBO_STATION = 'application/x-shinkan-exercise-combo-station-v1'
/** Pro Station meist 1 Übung; bis zu 3 wenn kurzer Auswahl-Pool sinnvoll ist. */
const MAX_COMBO_CANDIDATES_PER_STATION = 3
const comboTinyNumberInputSx = {
width: '3.5rem',
maxWidth: '100%',
padding: '4px 6px',
fontSize: '0.8125rem',
textAlign: 'center',
}
function escapeHtmlText(s) {
return String(s)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
}
/** Plaintext fuer RichTextEditor: ein bis mehrere Absaetze, ohne bestehendes HTML zu zerstoeren. */
function aiPlainSummaryToMinimalHtml(text) {
const raw = String(text || '').trim()
if (!raw) return ''
const parts = raw.split(/\n+/).map((p) => p.trim()).filter(Boolean)
const paras = parts.length ? parts : [raw]
return paras.map((p) => `
${escapeHtmlText(p)}
`).join('')
}
const INSTRUCTION_AI_FIELD_DEFS = [
{ key: 'goal', label: 'Ziel' },
{ key: 'execution', label: 'Durchführung' },
{ key: 'preparation', label: 'Vorbereitung / Aufbau' },
{ key: 'trainer_notes', label: 'Hinweise für Trainer' },
]
function cloneExerciseSkillRows(rows) {
return Array.isArray(rows) ? rows.map((s) => ({ ...s })) : []
}
function buildNormalizedAiSkillRowFromApi(sug) {
const sid = Number(sug.skill_id)
if (!Number.isFinite(sid)) return null
return {
skill_id: sid,
intensity: normalizeExerciseSkillIntensity(sug.intensity),
required_level: normalizeSkillLevelSlug(sug.required_level) || 'grundlagen',
target_level:
normalizeSkillLevelSlug(sug.target_level) ||
normalizeSkillLevelSlug(sug.required_level) ||
'grundlagen',
is_primary: !!sug.is_primary,
ai_suggested: true,
}
}
function buildExerciseAiSuggestionPreview({
mode,
snapshotSummaryHtml,
snapshotSkills,
snapshotInstructions,
apiRes,
}) {
const summaryRequested = mode !== 'skills' && mode !== 'instructions'
const skillsRequested = mode !== 'summary' && mode !== 'instructions'
const instructionsRequested = mode === 'instructions'
let summaryAfterHtml = null
let summaryAfterPlain = ''
if (summaryRequested && apiRes.summary?.text) {
summaryAfterPlain = String(apiRes.summary.text).trim()
if (summaryAfterPlain) {
summaryAfterHtml = aiPlainSummaryToMinimalHtml(apiRes.summary.text)
}
}
const skillChoices = []
if (skillsRequested && Array.isArray(apiRes.skills)) {
for (const sug of apiRes.skills) {
const after = buildNormalizedAiSkillRowFromApi(sug)
if (!after) continue
const ix = snapshotSkills.findIndex((s) => Number(s.skill_id) === after.skill_id)
const before = ix >= 0 ? { ...snapshotSkills[ix] } : null
skillChoices.push({
key: String(after.skill_id),
skill_id: after.skill_id,
kind: before ? 'update' : 'add',
before,
after,
include: true,
})
}
}
const instructionChoices = []
if (instructionsRequested && apiRes.instructions?.fields) {
const fields = apiRes.instructions.fields
const snap = snapshotInstructions || {}
for (const def of INSTRUCTION_AI_FIELD_DEFS) {
const afterHtml = fields[def.key]
if (!afterHtml || !String(afterHtml).trim()) continue
const beforeHtml = snap[def.key] || ''
instructionChoices.push({
key: def.key,
field: def.key,
label: def.label,
beforePlain: stripHtmlToText(beforeHtml).trim(),
afterHtml: String(afterHtml),
afterPlain: stripHtmlToText(afterHtml).trim(),
include: true,
})
}
}
const hasSummaryProposal = !!(summaryRequested && summaryAfterHtml)
const hasSkillChoices = skillChoices.length > 0
const hasInstructionChoices = instructionChoices.length > 0
return {
mode,
applySummary: hasSummaryProposal,
summaryBeforePlain: stripHtmlToText(snapshotSummaryHtml || '').trim(),
summaryAfterPlain,
summaryAfterHtml,
skillChoices,
instructionChoices,
hasSummaryProposal,
hasSkillChoices,
hasInstructionChoices,
summaryRequested,
skillsRequested,
instructionsRequested,
}
}
function describeExerciseSkillRowForPreview(row, skillsCatalog) {
if (!row) return ''
const sk = skillsCatalog.find((x) => Number(x.id) === Number(row.skill_id))
const name = sk?.name || `Fähigkeit #${row.skill_id}`
const int = formatExerciseSkillIntensityLabel(row.intensity)
const from = formatSkillLevelSlug(row.required_level) || '—'
const to = formatSkillLevelSlug(row.target_level) || '—'
const prim = row.is_primary ? ' · Primär' : ''
return `${name}: Intensität ${int}, Niveau ${from} → ${to}${prim}`
}
function emptyComboSlotRow() {
return {
title: '',
candidate_exercise_ids: [],
exercise_title_by_id: {},
advance_mode: 'timed',
load_sec: '',
consecutive_reps: '',
rep_series_count: '1',
intra_rep_rest_sec: '',
transition_after_sec: '',
}
}
function comboSlotsFromDetail(exercise) {
const raw = exercise?.combination_slots
const arch = exercise?.method_archetype != null ? String(exercise.method_archetype).trim() : ''
const serienFallback = defaultRepSeriesCountForArchetype(arch)
const mp =
exercise?.method_profile &&
typeof exercise.method_profile === 'object' &&
!Array.isArray(exercise.method_profile)
? exercise.method_profile
: {}
const spvList = readSlotProfilesV1(mp)
const byIx = new Map(spvList.map((r) => [Number(r.slot_index), r]))
if (!Array.isArray(raw) || raw.length === 0) {
return [emptyComboSlotRow()]
}
const sorted = [...raw].sort((a, b) => (Number(a.slot_index) || 0) - (Number(b.slot_index) || 0))
return sorted.map((s) => {
const si = Number(s.slot_index)
const st = byIx.get(si) || {}
const cands = Array.isArray(s.candidate_exercise_ids)
? s.candidate_exercise_ids.map((x) => Number(x)).filter((n) => Number.isFinite(n))
: []
const mode = normalizeAdvanceMode(st.advance_mode)
let repSer = ''
if (st.rep_series_count != null) repSer = String(st.rep_series_count)
else if (mode === 'rep' || mode === 'manual') repSer = String(serienFallback)
else repSer = '1'
return {
title: s.title != null ? String(s.title) : '',
candidate_exercise_ids: cands,
exercise_title_by_id: {},
advance_mode: mode,
load_sec: st.load_sec != null ? String(st.load_sec) : '',
consecutive_reps: st.consecutive_reps != null ? String(st.consecutive_reps) : '',
rep_series_count: repSer,
intra_rep_rest_sec: st.intra_rep_rest_sec != null ? String(st.intra_rep_rest_sec) : '',
transition_after_sec: st.transition_after_sec != null ? String(st.transition_after_sec) : '',
}
})
}
function emptyVariantDraft() {
return {
variant_name: '',
description: '',
execution_changes: '',
duration_min: '',
duration_max: '',
equipment_lines: '',
difficulty_adjustment: '',
progression_level: 1,
prerequisite_variant_id: '',
}
}
function apiVariantToRow(v) {
let lines = ''
const eq = v.equipment_changes
if (Array.isArray(eq)) {
lines = eq.join('\n')
} else if (typeof eq === 'string' && eq.trim()) {
try {
const p = JSON.parse(eq)
lines = Array.isArray(p) ? p.join('\n') : eq
} catch {
lines = eq
}
}
return {
...v,
duration_min: v.duration_min ?? '',
duration_max: v.duration_max ?? '',
equipment_lines: lines,
progression_level: v.progression_level ?? 1,
prerequisite_variant_id: v.prerequisite_variant_id ?? '',
difficulty_adjustment: v.difficulty_adjustment ?? '',
}
}
function buildVariantPayloadFromRow(row) {
const lines = (row.equipment_lines || '')
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean)
const pl =
row.progression_level === '' || row.progression_level == null
? 1
: parseInt(row.progression_level, 10)
const so =
row.sequence_order === '' || row.sequence_order == null
? null
: parseInt(row.sequence_order, 10)
return {
variant_name: (row.variant_name || '').trim(),
description: (row.description || '').trim() || null,
execution_changes: (row.execution_changes || '').trim() || null,
duration_min: row.duration_min === '' || row.duration_min == null ? null : parseInt(row.duration_min, 10),
duration_max: row.duration_max === '' || row.duration_max == null ? null : parseInt(row.duration_max, 10),
equipment_changes: lines,
difficulty_adjustment: row.difficulty_adjustment || null,
progression_level: Number.isNaN(pl) ? 1 : pl,
sequence_order: so !== null && Number.isNaN(so) ? null : so,
prerequisite_variant_id:
row.prerequisite_variant_id === '' || row.prerequisite_variant_id == null
? null
: parseInt(row.prerequisite_variant_id, 10),
}
}
function snapshotVariantPayload(row) {
return JSON.stringify(buildVariantPayloadFromRow(row))
}
function variantDraftHasContent(draft) {
if (!draft) return false
const p = buildVariantPayloadFromRow(draft)
return (
p.variant_name.length > 0 ||
Boolean(p.description) ||
Boolean(p.execution_changes) ||
p.duration_min != null ||
p.duration_max != null ||
(Array.isArray(p.equipment_changes) && p.equipment_changes.length > 0) ||
Boolean(p.difficulty_adjustment) ||
(p.progression_level != null && p.progression_level !== 1) ||
p.prerequisite_variant_id != null
)
}
/** Gemeinsame Felder für „Variante bearbeiten“ und „Neue Variante“. */
function ExerciseVariantFields({
row,
onPatch,
prerequisiteOthers,
rteMinHeight = '110px',
inlineExerciseId,
linkedExerciseMedia = [],
onExerciseMediaListChanged,
}) {
return (
<>
Variantenname *
onPatch({ variant_name: e.target.value })}
minLength={3}
/>
Kurzbeschreibung
Abweichungen zur Durchführung
onPatch({ execution_changes: html })}
placeholder="Was unterscheidet diese Variante? (Listen über Symbolleiste)"
minHeight={rteMinHeight}
inlineExerciseId={inlineExerciseId}
linkedExerciseMedia={linkedExerciseMedia}
onExerciseMediaListChanged={onExerciseMediaListChanged}
/>
Materialänderungen (eine Zeile pro Eintrag)
Schwere relativ
onPatch({ difficulty_adjustment: e.target.value })}
>
{VARIANT_DIFFICULTY.map((o) => (
{o.label}
))}
Progressions-Stufe (1–10)
onPatch({
progression_level: e.target.value === '' ? '' : parseInt(e.target.value, 10),
})
}
/>
Voraussetzungs-Variante
onPatch({
prerequisite_variant_id: e.target.value === '' ? '' : parseInt(e.target.value, 10),
})
}
>
— keine —
{prerequisiteOthers.map((o) => (
{o.variant_name || `Variante #${o.id}`}
))}
>
)
}
function emptyForm() {
return {
title: '',
summary: '',
goal: '',
execution: '',
preparation: '',
trainer_notes: '',
equipmentLines: '',
duration_min: '',
duration_max: '',
group_size_min: '',
group_size_max: '',
focus_areas_multi: [],
training_styles_multi: [],
training_types_multi: [],
target_groups_multi: [],
visibility: 'private',
club_id: null,
status: 'draft',
skills: [],
exercise_kind: 'simple',
method_archetype: '',
method_profile_json: '{}',
combination_slots: [emptyComboSlotRow()],
}
}
function detailToForm(exercise) {
return {
title: exercise.title || '',
summary: exercise.summary || '',
goal: exercise.goal || '',
execution: exercise.execution || '',
preparation: exercise.preparation || '',
trainer_notes: exercise.trainer_notes || '',
equipmentLines: (exercise.equipment || []).join('\n'),
duration_min: exercise.duration_min ?? '',
duration_max: exercise.duration_max ?? '',
group_size_min: exercise.group_size_min ?? '',
group_size_max: exercise.group_size_max ?? '',
focus_areas_multi: (exercise.focus_areas || []).map((f) => ({
focus_area_id: f.focus_area_id,
is_primary: !!f.is_primary,
})),
training_styles_multi: (exercise.training_styles || []).map((t) => ({
training_style_id: t.training_style_id,
is_primary: !!t.is_primary,
})),
training_types_multi: (exercise.training_types || []).map((t) => ({
training_type_id: t.training_type_id,
is_primary: !!t.is_primary,
})),
target_groups_multi: (exercise.target_groups || []).map((g) => ({
target_group_id: g.target_group_id,
is_primary: !!g.is_primary,
})),
visibility: exercise.visibility || 'private',
club_id:
String(exercise.visibility || '').trim().toLowerCase() === 'club' &&
exercise.club_id != null &&
exercise.club_id !== ''
? Number(exercise.club_id)
: null,
status: exercise.status || 'draft',
skills:
exercise.skills?.map((s) => ({
skill_id: s.skill_id,
intensity: normalizeExerciseSkillIntensity(s.intensity),
required_level: normalizeSkillLevelSlug(s.required_level),
target_level: normalizeSkillLevelSlug(s.target_level),
is_primary: !!s.is_primary,
ai_suggested: !!s.ai_suggested,
})) || [],
exercise_kind:
String(exercise.exercise_kind || 'simple').toLowerCase() === 'combination'
? 'combination'
: 'simple',
method_archetype: exercise.method_archetype != null ? String(exercise.method_archetype) : '',
method_profile_json:
typeof exercise.method_profile === 'object' &&
exercise.method_profile != null &&
!Array.isArray(exercise.method_profile)
? JSON.stringify(exercise.method_profile, null, 2)
: '{}',
combination_slots: comboSlotsFromDetail(exercise),
}
}
function ExerciseFormPageRoot() {
const { id: routeId } = useParams()
const navigate = useNavigate()
const location = useLocation()
const exercisesListReturn = useMemo(() => buildExercisesListReturnContext(), [])
const { goBack } = useNavReturn(exercisesListReturn)
const { user } = useAuth()
const isSuperadmin = user?.role === 'superadmin'
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const [clubsForGovernanceForms, setClubsForGovernanceForms] = useState([])
useEffect(() => {
if (!isPlatformAdmin) {
setClubsForGovernanceForms([])
return undefined
}
let cancelled = false
;(async () => {
try {
const list = await api.listClubs()
if (!cancelled) setClubsForGovernanceForms(Array.isArray(list) ? list : [])
} catch {
if (!cancelled) setClubsForGovernanceForms([])
}
})()
return () => {
cancelled = true
}
}, [isPlatformAdmin, tenantClubDepKey])
const membershipClubRows = useMemo(() => activeClubMemberships(user?.clubs), [user?.clubs])
/** Plattform-Admin: alle Vereine; sonst nur Mitgliedschafts-Vereine. */
const visibilityClubChoices = useMemo(() => {
if (isPlatformAdmin && clubsForGovernanceForms.length > 0) {
return [...clubsForGovernanceForms].sort((a, b) =>
String(a.name || '').localeCompare(String(b.name || ''), 'de'),
)
}
return [...membershipClubRows].sort((a, b) =>
String(a.name || '').localeCompare(String(b.name || ''), 'de'),
)
}, [isPlatformAdmin, clubsForGovernanceForms, membershipClubRows])
const governanceDefaultClubId = useMemo(() => getDefaultClubIdForGovernanceForms(user), [user])
const exerciseId = routeId && !Number.isNaN(parseInt(routeId, 10)) ? parseInt(routeId, 10) : null
const isEdit = exerciseId != null
const [formData, setFormData] = useState(emptyForm)
const [skillsCatalog, setSkillsCatalog] = useState([])
const [focusAreas, setFocusAreas] = useState([])
const [styleDirections, setStyleDirections] = useState([])
const [trainingTypes, setTrainingTypes] = useState([])
const [targetGroups, setTargetGroups] = useState([])
const [mediaList, setMediaList] = useState([])
const [loading, setLoading] = useState(!!isEdit)
const [saving, setSaving] = useState(false)
const [formDirty, setFormDirty] = useState(false)
const [skillPick, setSkillPick] = useState('')
const toast = useToast()
const allowUnloadBlock = Boolean(formDirty && !loading && !saving)
useBeforeUnloadWhen(allowUnloadBlock)
const blocker = useUnsavedChangesBlocker(allowUnloadBlock)
const [variants, setVariants] = useState([])
const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft())
const [variantSavingId, setVariantSavingId] = useState(null)
const [variantBusy, setVariantBusy] = useState(false)
const [aiSuggestBusy, setAiSuggestBusy] = useState(false)
const [aiSuggestionPreview, setAiSuggestionPreview] = useState(null)
const [variantEditSelection, setVariantEditSelection] = useState(null)
const [activeFormTab, setActiveFormTab] = useState('stammdaten')
const variantsSavedSnapshotRef = useRef({})
const exerciseFormTabs = useMemo(() => {
const tabs = [
{ id: 'stammdaten', label: 'Stammdaten', icon: FileText },
{ id: 'anleitung', label: 'Anleitung', icon: BookOpen },
{ id: 'einordnung', label: 'Einordnung', icon: Tags },
]
if (formData.exercise_kind === 'combination') {
tabs.push({ id: 'kombination', label: 'Kombination', icon: Layers })
}
if (isEdit) {
if (formData.exercise_kind !== 'combination') {
tabs.push({
id: 'varianten',
label: variants.length > 0 ? `Varianten (${variants.length})` : 'Varianten',
icon: GitBranch,
})
}
tabs.push({ id: 'medien', label: 'Medien & Mehr', icon: ImageIcon })
} else {
tabs.push({ id: 'varianten', label: 'Varianten', icon: GitBranch, disabled: true })
tabs.push({ id: 'medien', label: 'Medien & Mehr', icon: ImageIcon, disabled: true })
}
return tabs
}, [formData.exercise_kind, isEdit, variants.length])
useEffect(() => {
const allowed = new Set(exerciseFormTabs.filter((t) => !t.disabled).map((t) => t.id))
if (!allowed.has(activeFormTab)) setActiveFormTab('stammdaten')
}, [exerciseFormTabs, activeFormTab])
useEffect(() => {
if (formData.exercise_kind === 'combination' && activeFormTab === 'varianten') {
setActiveFormTab('kombination')
}
}, [formData.exercise_kind, activeFormTab])
const syncVariantsSavedSnapshot = useCallback((rows) => {
const snap = {}
for (const v of rows || []) {
if (v?.id != null) snap[v.id] = snapshotVariantPayload(v)
}
variantsSavedSnapshotRef.current = snap
}, [])
const getDirtyVariantRows = useCallback((rows) => {
return (rows || []).filter((v) => {
if (v?.id == null) return false
const saved = variantsSavedSnapshotRef.current[v.id]
if (saved == null) return true
return snapshotVariantPayload(v) !== saved
})
}, [])
const [mediaFields, setMediaFields] = useState({})
const [mediaSavingId, setMediaSavingId] = useState(null)
const [archiveOpen, setArchiveOpen] = useState(false)
const [archiveQ, setArchiveQ] = useState('')
const [archiveLoading, setArchiveLoading] = useState(false)
const [archiveItems, setArchiveItems] = useState([])
const [archiveError, setArchiveError] = useState(null)
const [mediaPreview, setMediaPreview] = useState(null)
const [reportTarget, setReportTarget] = useState(null)
useEffect(() => {
const next = {}
for (const m of mediaList) {
next[m.id] = {
title: m.title || '',
}
}
setMediaFields(next)
}, [mediaList])
useEffect(() => {
const onDragOverDoc = (e) => {
const types = e.dataTransfer?.types ? Array.from(e.dataTransfer.types) : []
if (!types.includes(SHINKAN_EXERCISE_MEDIA_DRAG_MIME)) return
e.preventDefault()
autoScrollForDragNearEdges(e)
}
document.addEventListener('dragover', onDragOverDoc)
return () => document.removeEventListener('dragover', onDragOverDoc)
}, [])
useEffect(() => {
if (!archiveOpen) return undefined
let cancelled = false
const t = setTimeout(async () => {
setArchiveLoading(true)
setArchiveError(null)
try {
const res = await api.listMediaAssets({
q: archiveQ.trim() || undefined,
limit: 40,
})
if (!cancelled) setArchiveItems((res.items || []).filter(a => !a.legal_hold_active))
} catch (e) {
if (!cancelled) setArchiveError(e.message || String(e))
} finally {
if (!cancelled) setArchiveLoading(false)
}
}, 280)
return () => {
cancelled = true
clearTimeout(t)
}
}, [archiveOpen, archiveQ])
useEffect(() => {
let cancelled = false
const boot = async () => {
try {
const [skillsData, faData, sdData, ttData, tgData] = await Promise.all([
api.listSkillsCatalog(),
api.listFocusAreas(),
api.listTrainingStyles(),
api.listTrainingTypes(),
api.listTargetGroups(),
])
if (cancelled) return
setSkillsCatalog(skillsData)
setFocusAreas(faData)
setStyleDirections(sdData)
setTrainingTypes(ttData)
setTargetGroups(tgData)
} catch (e) {
if (!cancelled) {
console.error(e)
toast.error(
'Kataloge (Fokus, Stile, Zielgruppen, Fähigkeiten) konnten nicht geladen werden: ' +
(e.message || e),
)
}
}
}
boot()
return () => {
cancelled = true
}
}, [toast])
useEffect(() => {
if (!isEdit) {
setFormData(emptyForm())
setMediaList([])
setVariants([])
setVariantDraft(emptyVariantDraft())
setVariantEditSelection(null)
setFormDirty(false)
setLoading(false)
return
}
let cancelled = false
const load = async () => {
setLoading(true)
try {
const exercise = await api.getExercise(exerciseId)
if (cancelled) return
const variantRows = (exercise.variants || []).map(apiVariantToRow)
setFormData(detailToForm(exercise))
setMediaList(exercise.media || [])
setVariants(variantRows)
syncVariantsSavedSnapshot(variantRows)
setVariantDraft(emptyVariantDraft())
setVariantEditSelection(null)
setFormDirty(false)
} catch (err) {
if (!cancelled) {
toast.error(err.message || 'Übung nicht ladbar')
navigate('/exercises')
}
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => {
cancelled = true
}
}, [isEdit, exerciseId, navigate, toast])
useEffect(() => {
if (variantEditSelection == null || variantEditSelection === 'new') return
if (!variants.some((v) => v.id === variantEditSelection)) {
setVariantEditSelection(null)
}
}, [variants, variantEditSelection])
useEffect(() => {
if (variantEditSelection != null && isEdit && formData.exercise_kind !== 'combination') {
setActiveFormTab('varianten')
}
}, [variantEditSelection, isEdit, formData.exercise_kind])
const updateFormField = (field, value) => {
setFormDirty(true)
setFormData((prev) => ({ ...prev, [field]: value }))
}
useEffect(() => {
if (formData.visibility !== 'club') return
const choices = visibilityClubChoices
if (!choices.length) return
const id =
formData.club_id != null && formData.club_id !== '' ? Number(formData.club_id) : NaN
const hasValid =
Number.isFinite(id) && id > 0 && choices.some((c) => Number(c.id) === id)
if (hasValid) return
const fallback = governanceDefaultClubId
const next =
fallback != null &&
Number.isFinite(Number(fallback)) &&
choices.some((c) => Number(c.id) === Number(fallback))
? Number(fallback)
: Number(choices[0].id)
setFormData((prev) => {
if (prev.visibility !== 'club') return prev
if (prev.club_id != null && Number(prev.club_id) === next) return prev
return { ...prev, club_id: next }
})
}, [
formData.visibility,
formData.club_id,
visibilityClubChoices,
governanceDefaultClubId,
])
const [comboStationPickerIx, setComboStationPickerIx] = useState(null)
const [comboDropTargetIx, setComboDropTargetIx] = useState(null)
const reorderCombinationSlots = (fromI, toBeforeIx) => {
setFormDirty(true)
setFormData((prev) => {
const rows = [...(prev.combination_slots || [])]
if (fromI < 0 || fromI >= rows.length) return prev
const [moved] = rows.splice(fromI, 1)
let insertAt = toBeforeIx
if (fromI < toBeforeIx) insertAt = toBeforeIx - 1
insertAt = Math.max(0, Math.min(insertAt, rows.length))
rows.splice(insertAt, 0, moved)
return { ...prev, combination_slots: rows }
})
}
const patchComboSlotRow = (idx, patch) => {
setFormDirty(true)
setFormData((prev) => {
const rows = [...(prev.combination_slots || [])]
if (!rows[idx]) return prev
rows[idx] = { ...rows[idx], ...patch }
return { ...prev, combination_slots: rows }
})
}
const removeCandidateFromSlot = (slotIdx, exerciseId) => {
setFormDirty(true)
setFormData((prev) => {
const rows = [...(prev.combination_slots || [])]
const row = rows[slotIdx]
if (!row) return prev
const nextIds = (row.candidate_exercise_ids || []).filter((id) => Number(id) !== Number(exerciseId))
const labels = row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object' ? { ...row.exercise_title_by_id } : {}
delete labels[Number(exerciseId)]
rows[slotIdx] = { ...row, candidate_exercise_ids: nextIds, exercise_title_by_id: labels }
return { ...prev, combination_slots: rows }
})
}
const mergePickedExercisesIntoSlot = (slotIdx, pickedList) => {
if (!Array.isArray(pickedList) || !pickedList.length) return
const rowNow = (formData.combination_slots || [])[slotIdx] || emptyComboSlotRow()
const existingIds = Array.isArray(rowNow.candidate_exercise_ids)
? rowNow.candidate_exercise_ids.map((n) => Number(n)).filter((n) => Number.isFinite(n))
: []
const ordered = [...existingIds]
pickedList.forEach((ex) => {
if (ex?.id == null) return
const id = Number(ex.id)
if (!Number.isFinite(id)) return
if (!ordered.includes(id)) ordered.push(id)
})
let nextIds = ordered
if (nextIds.length > MAX_COMBO_CANDIDATES_PER_STATION) {
toast.info(
`Pro Station höchstens ${MAX_COMBO_CANDIDATES_PER_STATION} Übungen — üblich eine feste Übung; zwei bis drei nur als kleiner Wechsel‑Pool. Überschüssige Auswahl wurde abgeschnitten.`,
)
nextIds = nextIds.slice(0, MAX_COMBO_CANDIDATES_PER_STATION)
}
setFormDirty(true)
setFormData((prev) => {
const rows = [...(prev.combination_slots || [])]
const row = rows[slotIdx] || emptyComboSlotRow()
const labels =
row.exercise_title_by_id && typeof row.exercise_title_by_id === 'object' ? { ...row.exercise_title_by_id } : {}
pickedList.forEach((ex) => {
if (ex && ex.id != null) {
const id = Number(ex.id)
const t = (ex.title || '').trim()
if (t) labels[id] = t
}
})
rows[slotIdx] = { ...row, candidate_exercise_ids: nextIds, exercise_title_by_id: labels }
return { ...prev, combination_slots: rows }
})
}
const addSkillRow = () => {
const id = skillPick ? parseInt(skillPick, 10) : null
if (!id) {
toast.error('Fähigkeit wählen')
return
}
if (formData.skills.some((s) => s.skill_id === id)) {
toast.info('Bereits zugeordnet')
return
}
updateFormField('skills', [
...formData.skills,
{
skill_id: id,
intensity: EXERCISE_SKILL_INTENSITY_DEFAULT,
required_level: '',
target_level: '',
},
])
setSkillPick('')
}
const removeSkillRow = (idx) => {
updateFormField(
'skills',
formData.skills.filter((_, i) => i !== idx),
)
}
const updateSkillField = (idx, field, value) => {
updateFormField(
'skills',
formData.skills.map((s, i) => (i === idx ? { ...s, [field]: value } : s)),
)
}
const runExerciseAiSuggestion = async (mode) => {
const gPlain = stripHtmlToText(formData.goal || '').trim()
const ePlain = stripHtmlToText(formData.execution || '').trim()
if (!gPlain && !ePlain) {
toast.error('Ziel oder Durchführung ausfüllen — die KI benötigt Kontext.')
return
}
const summaryOn = mode !== 'skills'
const skillsOn = mode !== 'summary'
const focusHint = (formData.focus_areas_multi || [])
.map((row) => {
const id = row?.focus_area_id
const fa = focusAreas.find((x) => Number(x.id) === Number(id))
return (fa?.name || '').trim()
})
.filter(Boolean)
.join(', ')
const snapshotSummaryHtml = formData.summary || ''
const snapshotSkills = cloneExerciseSkillRows(formData.skills)
const focusAreasContext = [...(formData.focus_areas_multi || [])]
.map((row) => ({
focus_area_id: Number(row?.focus_area_id),
is_primary: !!row?.is_primary,
}))
.filter((x) => Number.isFinite(x.focus_area_id) && x.focus_area_id >= 1)
.sort((a, b) => {
const p = Number(!!b.is_primary) - Number(!!a.is_primary)
if (p !== 0) return p
return a.focus_area_id - b.focus_area_id
})
/* Vor jedem neuen Aufruf: Vorschau schließen; sonst bleiben die KI-Buttons wegen Modal-Zustand dauerhaft deaktiviert. */
setAiSuggestionPreview(null)
setAiSuggestBusy(true)
try {
const res = await api.suggestExerciseAi({
title: (formData.title || '').trim(),
goal: formData.goal || '',
execution: formData.execution || '',
focus_area_hint: focusHint || undefined,
focus_areas_context: focusAreasContext.length ? focusAreasContext : undefined,
include_summary: summaryOn,
include_skills: skillsOn,
})
const preview = buildExerciseAiSuggestionPreview({
mode,
snapshotSummaryHtml,
snapshotSkills,
apiRes: res,
})
const hasSomething = preview.hasSummaryProposal || preview.hasSkillChoices
if (!hasSomething) {
toast.info('Die KI lieferte keinen verwertbaren Vorschlag für die gewählten Bereiche.')
return
}
setAiSuggestionPreview(preview)
} catch (err) {
toast.error(err?.message || String(err))
} finally {
setAiSuggestBusy(false)
}
}
const runExerciseAiInstructionRewrite = async () => {
const title = (formData.title || '').trim()
const snapshotInstructions = {
goal: formData.goal || '',
execution: formData.execution || '',
preparation: formData.preparation || '',
trainer_notes: formData.trainer_notes || '',
}
const hasSource =
!!title ||
Object.values(snapshotInstructions).some((html) => stripHtmlToText(html || '').trim())
if (!hasSource) {
toast.error('Titel oder mindestens ein Anleitungsfeld ausfüllen.')
return
}
const focusHint = (formData.focus_areas_multi || [])
.map((row) => {
const id = row?.focus_area_id
const fa = focusAreas.find((x) => Number(x.id) === Number(id))
return (fa?.name || '').trim()
})
.filter(Boolean)
.join(', ')
const focusAreasContext = [...(formData.focus_areas_multi || [])]
.map((row) => ({
focus_area_id: Number(row?.focus_area_id),
is_primary: !!row?.is_primary,
}))
.filter((x) => Number.isFinite(x.focus_area_id) && x.focus_area_id >= 1)
.sort((a, b) => {
const p = Number(!!b.is_primary) - Number(!!a.is_primary)
if (p !== 0) return p
return a.focus_area_id - b.focus_area_id
})
setAiSuggestionPreview(null)
setAiSuggestBusy(true)
try {
const res = await api.suggestExerciseAi({
title,
goal: snapshotInstructions.goal,
execution: snapshotInstructions.execution,
preparation: snapshotInstructions.preparation,
trainer_notes: snapshotInstructions.trainer_notes,
focus_area_hint: focusHint || undefined,
focus_areas_context: focusAreasContext.length ? focusAreasContext : undefined,
include_summary: false,
include_skills: false,
include_instructions: true,
})
const preview = buildExerciseAiSuggestionPreview({
mode: 'instructions',
snapshotInstructions,
apiRes: res,
})
if (!preview.hasInstructionChoices) {
toast.info('Die KI lieferte keinen verwertbaren Anleitungs-Vorschlag.')
return
}
setAiSuggestionPreview(preview)
} catch (err) {
toast.error(err?.message || String(err))
} finally {
setAiSuggestBusy(false)
}
}
const applyExerciseAiSuggestionPreview = () => {
const p = aiSuggestionPreview
if (!p) return
const takeSummary = !!(p.applySummary && p.summaryAfterHtml)
const skillsToMerge = p.skillChoices.filter((c) => c.include).map((c) => c.after)
const instrToApply = (p.instructionChoices || []).filter((c) => c.include && c.afterHtml)
if (!takeSummary && skillsToMerge.length === 0 && instrToApply.length === 0) {
toast.error('Bitte mindestens einen Vorschlag zur Übernahme auswählen.')
return
}
if (takeSummary) {
updateFormField('summary', p.summaryAfterHtml)
}
for (const c of instrToApply) {
updateFormField(c.field, c.afterHtml)
}
if (skillsToMerge.length > 0) {
setFormDirty(true)
setFormData((prev) => {
const next = [...(prev.skills || [])]
for (const row of skillsToMerge) {
const sid = Number(row.skill_id)
const ix = next.findIndex((s) => Number(s.skill_id) === sid)
if (ix >= 0) next[ix] = { ...next[ix], ...row }
else next.push(row)
}
return { ...prev, skills: next }
})
}
toast.success('Ausgewählte KI-Vorschläge übernommen — bitte prüfen und speichern.')
setAiSuggestionPreview(null)
}
const discardExerciseAiSuggestionPreview = () => setAiSuggestionPreview(null)
useEffect(() => {
if (!aiSuggestionPreview) return undefined
const onKey = (e) => {
if (e.key === 'Escape') {
e.preventDefault()
setAiSuggestionPreview(null)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [aiSuggestionPreview])
const refreshVariants = useCallback(async () => {
if (!exerciseId) return
const ex = await api.getExercise(exerciseId)
const rows = (ex.variants || []).map(apiVariantToRow)
syncVariantsSavedSnapshot(rows)
setVariants(rows)
}, [exerciseId, syncVariantsSavedSnapshot])
const createVariantFromDraft = useCallback(
async ({ showSuccessToast = false } = {}) => {
if (!exerciseId) return false
if (!variantDraftHasContent(variantDraft)) return true
const payload = buildVariantPayloadFromRow(variantDraft)
if (payload.variant_name.length < 3) {
toast.error('Variantenname mindestens 3 Zeichen')
return false
}
setVariantBusy(true)
try {
const created = await api.createExerciseVariant(exerciseId, payload)
setVariantDraft(emptyVariantDraft())
if (created?.id != null) setVariantEditSelection(created.id)
await refreshVariants()
if (showSuccessToast) toast.success('Variante angelegt.')
return true
} catch (e) {
toast.error(e.message || String(e))
return false
} finally {
setVariantBusy(false)
}
},
[exerciseId, variantDraft, refreshVariants, toast],
)
const persistPendingVariantChanges = useCallback(async () => {
if (!exerciseId) return true
const dirtyRows = getDirtyVariantRows(variants)
if (dirtyRows.length > 0) {
setVariantBusy(true)
try {
for (const row of dirtyRows) {
const payload = buildVariantPayloadFromRow(row)
if (payload.variant_name.length < 3) {
toast.error(`Variante „${row.variant_name || `#${row.id}`}“: Name mindestens 3 Zeichen`)
return false
}
setVariantSavingId(row.id)
await api.updateExerciseVariant(exerciseId, row.id, payload)
}
await refreshVariants()
} catch (e) {
toast.error(e.message || String(e))
return false
} finally {
setVariantSavingId(null)
setVariantBusy(false)
}
}
const draftOk = await createVariantFromDraft()
return draftOk
}, [exerciseId, variants, getDirtyVariantRows, refreshVariants, toast, createVariantFromDraft])
const performSaveAttempt = useCallback(
async ({ fromUnsavedDialog = false, closeAfter = false } = {}) => {
if (!formData.title || formData.title.trim().length < 3) {
toast.error('Titel mindestens 3 Zeichen')
return false
}
if (isEdit && exerciseId) {
const variantsOk = await persistPendingVariantChanges()
if (!variantsOk) return false
}
const payloadBase = {
...formData,
equipment:
typeof formData.equipmentLines === 'string'
? formData.equipmentLines
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean)
: [],
}
let payload
try {
payload = buildExerciseApiPayload(payloadBase)
} catch (err) {
toast.error(err.message)
return false
}
setSaving(true)
try {
if (isEdit) {
const saveOnce = (extras = {}) =>
api.updateExercise(exerciseId, buildExerciseApiPayload(payloadBase, extras))
try {
await saveOnce()
} catch (firstErr) {
if (
firstErr.status === 422 &&
firstErr.code === 'OFFICIAL_MEDIA_LIFECYCLE' &&
firstErr.payload?.media_assets
) {
toast.error(
'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). ' +
'Bitte Medium wiederherstellen oder aus der Übung entfernen.',
)
throw firstErr
}
if (firstErr.status === 422 && firstErr.code === 'OFFICIAL_MEDIA_CONFIRM_REQUIRED') {
const promo = (firstErr.payload.assets_need_visibility_promotion || []).length
const miss = (firstErr.payload.assets_missing_copyright || []).length
let msg = 'Die Übung ist oder wird offiziell. '
if (promo > 0) {
msg += `${promo} verknüpfte Datei(en) werden dabei plattformweit („offiziell“) freigegeben. `
}
if (miss > 0) {
msg += `${miss} Datei(en) haben noch keinen ausreichenden Copyright-Vermerk (mind. 3 Zeichen). `
}
msg += 'Fortfahren?'
if (!window.confirm(msg)) throw firstErr
let defaultCopyright = ''
if (miss > 0) {
defaultCopyright = window.prompt(
'Copyright-Vermerk für betroffene Dateien ohne Eintrag (mind. 3 Zeichen):',
'© ',
)
if (!defaultCopyright || String(defaultCopyright).trim().length < 3) {
toast.error('Mindestens 3 Zeichen für den Copyright-Vermerk.')
throw firstErr
}
}
await saveOnce({
promote_attached_media_for_official: true,
...(miss > 0 ? { default_official_media_copyright: String(defaultCopyright).trim() } : {}),
})
} else if (
firstErr.status === 422 &&
firstErr.code === 'CLUB_MEDIA_COPYRIGHT_REQUIRED' &&
firstErr.payload?.media_assets
) {
const miss = firstErr.payload.media_assets.length
const msg =
`Vereinsöffentliche Übungen brauchen bei jeder verknüpften Datei einen Copyright-Vermerk (mind. 3 Zeichen). ` +
`${miss} Datei(en) sind noch ohne ausreichenden Vermerk. ` +
`Beim Speichern einen gemeinsamen Vermerk für diese Dateien setzen?`
if (!window.confirm(msg)) throw firstErr
const defaultCopyright = window.prompt(
'Copyright-Vermerk für die betroffenen Dateien (mind. 3 Zeichen):',
'© ',
)
if (!defaultCopyright || String(defaultCopyright).trim().length < 3) {
toast.error('Mindestens 3 Zeichen für den Copyright-Vermerk.')
throw firstErr
}
await saveOnce({
default_club_media_copyright: String(defaultCopyright).trim(),
})
} else if (firstErr.status === 422 && firstErr.code === 'CLUB_MEDIA_LIFECYCLE') {
toast.error(
'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). Bitte reaktivieren oder entfernen.',
)
throw firstErr
} else {
throw firstErr
}
}
const ex = await api.getExercise(exerciseId)
setMediaList(ex.media || [])
const variantRows = (ex.variants || []).map(apiVariantToRow)
setVariants(variantRows)
syncVariantsSavedSnapshot(variantRows)
setFormDirty(false)
toast.success('Gespeichert.')
if (closeAfter) goBack()
return true
}
const created = await api.createExercise(payload)
setFormDirty(false)
toast.success('Übung angelegt.')
if (closeAfter) {
goBack()
} else if (!fromUnsavedDialog) {
preserveAppReturnOnNavigate(navigate, location, `/exercises/${created.id}/edit`, { replace: true })
}
return true
} catch (err) {
toast.error('Fehler beim Speichern: ' + err.message)
return false
} finally {
setSaving(false)
}
},
[exerciseId, formData, isEdit, navigate, location, toast, goBack, persistPendingVariantChanges, syncVariantsSavedSnapshot],
)
const handleSubmit = useCallback(
async (e) => {
e?.preventDefault?.()
await performSaveAttempt({ fromUnsavedDialog: false, closeAfter: false })
},
[performSaveAttempt],
)
const handleSaveAndClose = useCallback(
async (e) => {
e?.preventDefault?.()
await performSaveAttempt({ fromUnsavedDialog: false, closeAfter: true })
},
[performSaveAttempt],
)
const actionConfig = useMemo(
() => ({
formId: 'exercise-form',
saving,
isNew: !isEdit,
onSave: handleSubmit,
onSaveAndClose: handleSaveAndClose,
onCancel: goBack,
showSave: true,
showSaveAndClose: true,
}),
[saving, isEdit, handleSubmit, handleSaveAndClose, goBack],
)
const handleUnsavedDialogSave = async () => {
const ok = await performSaveAttempt({ fromUnsavedDialog: true })
if (ok) blocker.proceed()
}
const refreshMedia = async () => {
if (!exerciseId) return
const ex = await api.getExercise(exerciseId)
setMediaList(ex.media || [])
}
const attachFromArchive = async (assetId) => {
if (!exerciseId) return
try {
await api.attachExerciseMediaFromAsset(exerciseId, {
media_asset_id: assetId,
context: 'ablauf',
title: '',
description: '',
is_primary: false,
})
setArchiveOpen(false)
await refreshMedia()
} catch (e) {
toast.error(e.message || String(e))
}
}
const linkedArchiveAssetIds = useMemo(
() => new Set((mediaList || []).map((m) => m.media_asset_id).filter(Boolean)),
[mediaList],
)
const handleDeleteMedia = async (mid) => {
if (
!confirm(
'Dieses Medium aus der Übung entfernen? Nur die Verknüpfung wird gelöscht. Die Datei bleibt im Archiv, solange sie noch woanders genutzt wird.\n\n' +
'Hinweis: Wenn dieser Eintrag noch als Platzhalter im Fließtext steht, zeigt die Vorschau [Medium nicht verfügbar] oder das Speichern der Übung schlägt fehl, bis der Platzhalter entfernt ist.',
)
) {
return
}
try {
const res = await api.deleteExerciseMedia(exerciseId, mid)
await refreshMedia()
const oid = res?.orphan_media_asset_id
if (oid != null) {
if (
confirm(
'Dieses Archiv-Medium wird danach nirgendwo mehr verwendet. In den Papierkorb (Stufe 1) legen? (Später in der Medienverwaltung wiederherstellbar.)',
)
) {
await api.postMediaAssetLifecycle(oid, 'trash_soft')
await refreshMedia()
}
}
} catch (err) {
toast.error(err.message)
}
}
const moveMediaRow = async (idx, dir) => {
if (!exerciseId) return
const j = idx + dir
if (j < 0 || j >= mediaList.length) return
const next = [...mediaList]
const tmp = next[idx]
next[idx] = next[j]
next[j] = tmp
try {
await api.reorderExerciseMedia(
exerciseId,
next.map((x) => x.id),
)
setMediaList(next)
} catch (e) {
toast.error(e.message || String(e))
}
}
const saveMediaMeta = async (mid) => {
if (!exerciseId) return
const fld = mediaFields[mid]
if (!fld) return
setMediaSavingId(mid)
try {
await api.updateExerciseMedia(exerciseId, mid, {
title: fld.title.trim() || null,
})
await refreshMedia()
} catch (e) {
toast.error(e.message || String(e))
} finally {
setMediaSavingId(null)
}
}
const updateVariantField = (id, patch) => {
setFormDirty(true)
setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v)))
}
const saveVariantRow = async (row) => {
const payload = buildVariantPayloadFromRow(row)
if (payload.variant_name.length < 3) {
toast.error('Variantenname mindestens 3 Zeichen')
return
}
setVariantSavingId(row.id)
try {
await api.updateExerciseVariant(exerciseId, row.id, payload)
await refreshVariants()
} catch (e) {
toast.error(e.message || String(e))
} finally {
setVariantSavingId(null)
}
}
const deleteVariantRow = async (id) => {
if (!confirm('Variante wirklich löschen?')) return
setVariantBusy(true)
try {
await api.deleteExerciseVariant(exerciseId, id)
if (variantEditSelection === id) setVariantEditSelection(null)
await refreshVariants()
} catch (e) {
toast.error(e.message || String(e))
} finally {
setVariantBusy(false)
}
}
const moveVariantRow = async (idx, dir) => {
const j = idx + dir
if (j < 0 || j >= variants.length) return
const next = [...variants]
const tmp = next[idx]
next[idx] = next[j]
next[j] = tmp
const ids = next.map((x) => x.id)
setVariantBusy(true)
try {
await api.reorderExerciseVariants(exerciseId, ids)
await refreshVariants()
} catch (e) {
toast.error(e.message || String(e))
} finally {
setVariantBusy(false)
}
}
const handleCreateVariantClick = useCallback(async () => {
await createVariantFromDraft({ showSuccessToast: true })
}, [createVariantFromDraft])
const selectedVariantForEdit =
typeof variantEditSelection === 'number' ? variants.find((v) => v.id === variantEditSelection) : null
const selectedVariantIdx = selectedVariantForEdit
? variants.findIndex((v) => v.id === selectedVariantForEdit.id)
: -1
if (loading) {
return (
)
}
return (
{isEdit ? (
Ansehen
) : null}
{aiSuggestionPreview &&
(() => {
const p = aiSuggestionPreview
const summaryBoxSx = {
padding: '10px 12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
fontSize: '13px',
lineHeight: 1.45,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
minHeight: '72px',
}
const canApplySomething =
(p.applySummary && p.summaryAfterHtml) ||
p.skillChoices.some((c) => c.include) ||
(p.instructionChoices || []).some((c) => c.include && c.afterHtml)
const dialogTitle =
p.instructionsRequested
? 'KI: Anleitung überarbeiten'
: 'KI-Vorschlag übernehmen'
return (
discardExerciseAiSuggestionPreview()}
onKeyDown={(e) => e.key === 'Escape' && discardExerciseAiSuggestionPreview()}
>
e.stopPropagation()}
>
{dialogTitle}
{p.instructionsRequested
? 'Vergleichen und nur die gewünschten Felder übernehmen. Eingebettete Medien bleiben erhalten, wenn die KI sie nicht erwähnt.'
: 'Vergleichen und nur die gewünschten Teile übernehmen. Es werden keine Daten automatisch gespeichert.'}
{p.hasInstructionChoices ? (
Anleitung ({p.instructionChoices.length}{' '}
{p.instructionChoices.length === 1 ? 'Feld' : 'Felder'})
{p.instructionChoices.map((c) => (
setAiSuggestionPreview((prev) =>
prev
? {
...prev,
instructionChoices: prev.instructionChoices.map((x) =>
x.key === c.key ? { ...x, include: e.target.checked } : x,
),
}
: prev,
)
}
/>
{c.label} übernehmen
Aktuell (Plaintext)
{c.beforePlain || '(leer)'}
KI-Vorschlag
{c.afterPlain || '(leer)'}
))}
) : null}
{p.hasSummaryProposal ? (
Kurzfassung
setAiSuggestionPreview((prev) =>
prev ? { ...prev, applySummary: e.target.checked } : prev,
)
}
/>
Kurzfassung durch Vorschlag ersetzen (bestehende Kurzbeschreibung wird überschrieben)
Aktuell (ohne Formatierung)
{p.summaryBeforePlain || '(leer)'}
KI-Vorschlag
{p.summaryAfterPlain || '(leer)'}
) : null}
{p.skillsRequested ? (
Fähigkeiten ({p.skillChoices.length}
{p.skillChoices.length === 1 ? ' Vorschlag' : ' Vorschläge'})
{p.skillChoices.length > 0 ? (
setAiSuggestionPreview((prev) =>
prev
? {
...prev,
skillChoices: prev.skillChoices.map((x) => ({ ...x, include: true })),
}
: prev,
)
}
>
Alle auswählen
setAiSuggestionPreview((prev) =>
prev
? {
...prev,
skillChoices: prev.skillChoices.map((x) => ({ ...x, include: false })),
}
: prev,
)
}
>
Alle abwählen
) : null}
{p.skillChoices.length === 0 ? (
Keine passenden Fähigkeiten — der Katalog-Vorschlag war leer oder enthielt nur ungültige IDs.
) : (
{p.skillChoices.map((c) => (
setAiSuggestionPreview((prev) =>
prev
? {
...prev,
skillChoices: prev.skillChoices.map((x) =>
x.skill_id === c.skill_id ? { ...x, include: !x.include } : x,
),
}
: prev,
)
}
style={{ marginTop: '4px' }}
/>
{c.kind === 'add' ? 'Neu hinzufügen' : 'Bestehende Zeile aktualisieren'}
{c.kind === 'update' && c.before ? (
Bisher
{describeExerciseSkillRowForPreview(c.before, skillsCatalog)}
Nach KI-Vorschlag
{describeExerciseSkillRowForPreview(c.after, skillsCatalog)}
) : (
{describeExerciseSkillRowForPreview(c.after, skillsCatalog)}
)}
))}
)}
) : null}
Abbrechen
applyExerciseAiSuggestionPreview()}
>
Ausgewähltes übernehmen
)
})()}
setComboStationPickerIx(null)}
exerciseKindAny={['simple']}
multiSelect
enableQuickCreateDraft
onSelectExercises={(picked) => {
if (comboStationPickerIx === null) return
mergePickedExercisesIntoSlot(comboStationPickerIx, picked)
setComboStationPickerIx(null)
}}
/>
KI-Unterstützung: OpenRouter-Vorschläge für Kurzfassung, Fähigkeiten und Anleitung
(suggestExerciseAi / regenerateExerciseAi). Übernahme im Dialog ins Formular; Speichern
wie gewohnt.
setFormDirty(false)}
detail="Du hast ungespeicherte Änderungen vorgenommen. Möchtest du die Seite wirklich verlassen?"
/>
)
}
export default ExerciseFormPageRoot