/** * Integrierter Slot-Editor für Progressionsgraphen (Phase B). */ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import api from '../utils/api' import { useAuth } from '../context/AuthContext' import { getDefaultClubIdForGovernanceForms } from '../utils/activeClub' import ExercisePickerModal from './ExercisePickerModal' import ExerciseGapFillPrepModal from './exercises/ExerciseGapFillPrepModal' import ProgressionSlotCard from './ProgressionSlotCard' import ProgressionFindingsPanel from './ProgressionFindingsPanel' import PlanningCatalogContextFields from './PlanningCatalogContextFields' import { aiPreviewToQuickCreateDraft, buildQuickCreateAiPreview, buildQuickCreateExercisePayloadFromDraft, ensureQuickCreateDraftFromAiSuggestion, } from '../utils/exerciseAiQuickCreate' import { buildPathGapPlanningContextForAi, buildSlotGapGoalForAi, gapOfferContextDisplayLines, initialStageLearningGoalFromOffer, } from '../utils/planningContextForExerciseAi' import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal' import ProgressionOptimizeCompareModal from './ProgressionOptimizeCompareModal' import { addSlotToDraft, applyEvaluateResponseToDraft, applyGapOfferToDraft, applySelectedCompareSteps, applySelectedSlotSuggestions, applyResolvedStructuredToDraft, buildPlanningArtifactFromDraft, buildProgressionComparePayload, collectGapOffersFromApiResponse, compareSlotDiffs, compareDiffsForDialog, dedupeGapOffersBySlot, draftHasLibrarySlotAssignments, EMPTY_PLANNING_CATALOG_CONTEXT, filterGapOffersForUnfilledSlots, hydrateProgressionGraphDraft, insertSlotInDraft, librarySlotExercise, majorStepsToOverridePayload, mergeGapOffersForDraft, moveSlotInDraft, patchSlotInDraft, pathQaQualityPercent, planningCatalogContextToApi, rejectedCompareDiffs, removeSlotFromDraft, saveProgressionGraphDraft, setCatalogSelectItems, setSlotPrimaryLibrary, SLOT_MAX, SLOT_MIN, slotsAsPathStepRows, slotsToEvaluateSteps, slotsToSlotAssignments, syncProgressionRoadmapFromSlots, syncSlotPhasesFromRoadmap, } from '../utils/progressionGraphDraft' function roadmapStructuredPayload(startSituation, targetState, roadmapNotes) { const body = {} const start = (startSituation || '').trim() const target = (targetState || '').trim() const notes = (roadmapNotes || '').trim() if (start) body.start_situation = start if (target) body.target_state = target if (notes) body.roadmap_notes = notes return body } function resolveDefaultFocusAreaId(targetSummary, focusAreas) { const targetName = targetSummary?.focus_areas?.[0] if (targetName && Array.isArray(focusAreas) && focusAreas.length) { const norm = String(targetName).trim().toLowerCase() const hit = focusAreas.find((fa) => String(fa.name || '').trim().toLowerCase() === norm) if (hit?.id) return Number(hit.id) } return focusAreas?.[0]?.id ? Number(focusAreas[0].id) : null } export default function ProgressionGraphEditor({ graphId, embedded = false, onSaved }) { const { user } = useAuth() const [graphMeta, setGraphMeta] = useState(null) const [draft, setDraft] = useState(null) const [busy, setBusy] = useState(false) const [loadErr, setLoadErr] = useState('') const [actionErr, setActionErr] = useState('') const [matchNotice, setMatchNotice] = useState('') const [pickContext, setPickContext] = useState(null) const [pathQa, setPathQa] = useState(null) const [gapFillOffers, setGapFillOffers] = useState([]) const [evaluating, setEvaluating] = useState(false) const [matching, setMatching] = useState(false) const [roadmapLoading, setRoadmapLoading] = useState(false) const [startTargetLoading, setStartTargetLoading] = useState(false) const [startTargetReady, setStartTargetReady] = useState(false) const [semanticBrief, setSemanticBrief] = useState(null) const [targetSummary, setTargetSummary] = useState(null) const [focusAreas, setFocusAreas] = useState([]) const [styleDirections, setStyleDirections] = useState([]) const [trainingTypes, setTrainingTypes] = useState([]) const [targetGroups, setTargetGroups] = useState([]) const [skillsCatalog, setSkillsCatalog] = useState([]) const [activeOffer, setActiveOffer] = useState(null) const [activeOfferSlotIndex, setActiveOfferSlotIndex] = useState(null) const [gapPrepOpen, setGapPrepOpen] = useState(false) const [gapPrepTitle, setGapPrepTitle] = useState('') const [gapPrepStageGoal, setGapPrepStageGoal] = useState('') const [gapPrepSupplements, setGapPrepSupplements] = useState('') const [gapPrepFocusAreaId, setGapPrepFocusAreaId] = useState('') const [gapPrepError, setGapPrepError] = useState('') const [generatingOfferId, setGeneratingOfferId] = useState(null) const [gapAiBusy, setGapAiBusy] = useState(false) const [currentEdges, setCurrentEdges] = useState([]) const [slotQuickCreateIndex, setSlotQuickCreateIndex] = useState(null) const [slotQuickCreateDraft, setSlotQuickCreateDraft] = useState(null) const [slotQuickSaving, setSlotQuickSaving] = useState(false) const [slotQuickError, setSlotQuickError] = useState('') const [activePlanningContextLines, setActivePlanningContextLines] = useState([]) const [compareOpen, setCompareOpen] = useState(false) const [comparePayload, setComparePayload] = useState(null) const [compareSource, setCompareSource] = useState('manual') const [comparing, setComparing] = useState(false) const [compareApplying, setCompareApplying] = useState(false) const [proposedPathQa, setProposedPathQa] = useState(null) const loadGraph = useCallback(async () => { if (!graphId) return setBusy(true) setLoadErr('') try { const [graph, edges] = await Promise.all([ api.getExerciseProgressionGraph(Number(graphId)), api.listExerciseProgressionEdges(Number(graphId)), ]) const edgeList = Array.isArray(edges) ? edges : [] setCurrentEdges(edgeList) setGraphMeta(graph) const hydrated = hydrateProgressionGraphDraft({ artifact: graph?.planning_roadmap, edges: edgeList, graphName: graph?.name, }) setDraft(hydrated) setStartTargetReady( Boolean((hydrated.startSituation || '').trim() && (hydrated.targetState || '').trim()), ) const findings = graph?.planning_roadmap?.last_findings if (findings) setPathQa(findings) } catch (e) { setLoadErr(e.message || 'Graph konnte nicht geladen werden') setDraft(null) } finally { setBusy(false) } }, [graphId]) useEffect(() => { loadGraph() }, [loadGraph]) useEffect(() => { let cancelled = false Promise.all([ api.listFocusAreas({ status: 'active' }), api.listStyleDirections({ status: 'active' }), api.listTrainingTypes({ status: 'active' }), api.listTargetGroups({ status: 'active' }), api.listSkillsCatalog({ status: 'active' }), ]) .then(([fa, sd, tt, tg, sk]) => { if (cancelled) return setFocusAreas(Array.isArray(fa) ? fa : []) setStyleDirections(Array.isArray(sd) ? sd : []) setTrainingTypes(Array.isArray(tt) ? tt : []) setTargetGroups(Array.isArray(tg) ? tg : []) setSkillsCatalog(Array.isArray(sk) ? sk : []) }) .catch(() => { if (!cancelled) { setFocusAreas([]) setStyleDirections([]) setTrainingTypes([]) setTargetGroups([]) setSkillsCatalog([]) } }) return () => { cancelled = true } }, []) const patchDraft = useCallback((patchFn) => { setDraft((prev) => { if (!prev) return prev const next = patchFn(prev) return { ...next, dirty: true, findingsStale: true } }) }, []) const gapContextParams = useMemo(() => { if (!draft) return {} return { goalQuery: draft.goalQuery, semanticBrief, graphId, pathSteps: slotsAsPathStepRows(draft), editableMajorSteps: draft.majorSteps, progressionRoadmap: draft.progressionRoadmap, startSituation: draft.startSituation, targetState: draft.targetState, roadmapNotes: draft.roadmapNotes, } }, [draft, semanticBrief, graphId]) const handlePickExercise = async (exercise) => { if (!pickContext || !exercise?.id) return const { slotIndex, role } = pickContext const entry = librarySlotExercise({ exerciseId: exercise.id, exerciseTitle: exercise.title || `Übung #${exercise.id}`, }) patchDraft((d) => { const slots = d.slots.map((s, i) => { if (i !== slotIndex) return s if (role === 'primary') return { ...s, primary: entry } const siblings = [...(s.siblings || [])] if (!siblings.some((x) => x.exerciseId === entry.exerciseId)) siblings.push(entry) return { ...s, siblings } }) return syncProgressionRoadmapFromSlots({ ...d, slots }) }) setPickContext(null) } const handlePatchLearningGoal = (slotIndex, value) => { patchDraft((d) => patchSlotInDraft(d, slotIndex, { learning_goal: value })) } const handlePatchPhase = (slotIndex, value) => { patchDraft((d) => patchSlotInDraft(d, slotIndex, { phase: value })) } const handleMoveSlot = (slotIndex, dir) => { patchDraft((d) => moveSlotInDraft(d, slotIndex, dir)) } const handleRemoveSlot = (slotIndex) => { if ((draft?.slots?.length || 0) <= 2) { alert('Mindestens zwei Slots müssen bleiben.') return } if (!window.confirm(`Slot ${slotIndex + 1} wirklich entfernen?`)) return patchDraft((d) => removeSlotFromDraft(d, slotIndex)) } const handleInsertAfter = (slotIndex) => { if ((draft?.slots?.length || 0) >= SLOT_MAX) { alert(`Maximal ${SLOT_MAX} Slots.`) return } patchDraft((d) => insertSlotInDraft(d, slotIndex)) } const handleAddSlot = () => { if ((draft?.slots?.length || 0) >= SLOT_MAX) { alert(`Maximal ${SLOT_MAX} Slots.`) return } patchDraft((d) => addSlotToDraft(d)) } const handleClearPrimary = (slotIndex) => { patchDraft((d) => { const slots = d.slots.map((s, i) => i === slotIndex ? { ...s, primary: { kind: 'empty', exerciseId: null, variantId: null, exerciseTitle: '', variantName: null, proposalKey: null, aiSuggestion: null, }, siblings: [], } : s, ) return syncProgressionRoadmapFromSlots({ ...d, slots }) }) } const handleRemoveSibling = (slotIndex, sibIdx) => { patchDraft((d) => { const slots = d.slots.map((s, i) => { if (i !== slotIndex) return s return { ...s, siblings: s.siblings.filter((_, j) => j !== sibIdx) } }) return { ...d, slots } }) } const validMajorSteps = useMemo(() => { if (!draft?.slots) return [] return draft.slots.filter((s) => (s.learning_goal || '').trim().length >= 3) }, [draft?.slots]) const catalogCtx = draft?.planningCatalogContext || EMPTY_PLANNING_CATALOG_CONTEXT const patchCatalogDimension = (key, value) => { patchDraft((d) => ({ ...d, dirty: true, planningCatalogContext: { ...(d.planningCatalogContext || EMPTY_PLANNING_CATALOG_CONTEXT), [key]: setCatalogSelectItems(d.planningCatalogContext?.[key], value), }, })) } const catalogApiPayload = useMemo( () => planningCatalogContextToApi(catalogCtx), [catalogCtx], ) const runAnalyzeStartTarget = async () => { const q = (draft?.goalQuery || '').trim() if (q.length < 3) { alert('Ziel-Anfrage: mindestens 3 Zeichen.') return } setStartTargetLoading(true) setActionErr('') try { const res = await api.suggestProgressionPath({ query: q, max_steps: draft.maxSteps || 5, include_llm_intent: false, include_path_qa: false, include_llm_path_qa: false, include_path_reorder: false, include_ai_gap_fill: false, include_roadmap_preview: false, include_llm_roadmap: false, include_llm_start_target: true, start_target_only: true, progression_graph_id: Number(graphId), ...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes), ...catalogApiPayload, }) const roadmap = res?.progression_roadmap if (!roadmap) throw new Error('Keine Start/Ziel-Analyse in der Antwort') setDraft((prev) => { const structured = applyResolvedStructuredToDraft( { ...prev, progressionRoadmap: roadmap }, roadmap, ) return { ...structured, dirty: true, findingsStale: true } }) setStartTargetReady(true) setSemanticBrief(res?.semantic_brief_summary || null) } catch (e) { setActionErr(e.message || 'Start/Ziel-Analyse fehlgeschlagen') } finally { setStartTargetLoading(false) } } const runRoadmapGenerate = async () => { const q = (draft?.goalQuery || '').trim() if (q.length < 3) { alert('Ziel-Anfrage: mindestens 3 Zeichen.') return } const fieldsEmpty = !(draft.startSituation || '').trim() && !(draft.targetState || '').trim() setRoadmapLoading(true) setActionErr('') try { const res = await api.suggestProgressionPath({ query: q, max_steps: draft.maxSteps || 5, include_llm_intent: true, include_path_qa: false, include_llm_path_qa: false, include_path_reorder: false, include_ai_gap_fill: false, include_roadmap_preview: true, include_llm_roadmap: true, include_llm_start_target: fieldsEmpty, roadmap_only: true, progression_graph_id: Number(graphId), ...roadmapStructuredPayload(draft.startSituation, draft.targetState, draft.roadmapNotes), ...catalogApiPayload, }) const roadmap = res?.progression_roadmap if (!roadmap) throw new Error('Keine Roadmap in der Antwort') const majorCount = (roadmap?.roadmap?.major_steps || []).length if (majorCount < SLOT_MIN) throw new Error('Roadmap hat zu wenig Stufen.') const preservedArtifact = buildPlanningArtifactFromDraft(draft) || {} let startSituation = draft.startSituation let targetState = draft.targetState let roadmapNotes = draft.roadmapNotes if (fieldsEmpty) { const patch = applyResolvedStructuredToDraft( { startSituation, targetState, roadmapNotes }, roadmap, ) startSituation = patch.startSituation targetState = patch.targetState roadmapNotes = patch.roadmapNotes setStartTargetReady(true) } const hydrated = hydrateProgressionGraphDraft({ artifact: { ...preservedArtifact, goal_query: q, progression_roadmap: roadmap, start_situation: startSituation, target_state: targetState, roadmap_notes: roadmapNotes, max_steps: majorCount || draft.maxSteps, }, edges: currentEdges, graphName: draft.graphName, }) const withPhases = syncSlotPhasesFromRoadmap(hydrated, roadmap) setDraft({ ...withPhases, goalQuery: q, maxSteps: majorCount || withPhases.maxSteps, dirty: true, findingsStale: true }) setSemanticBrief(res?.semantic_brief_summary || null) } catch (e) { setActionErr(e.message || 'Roadmap-Generierung fehlgeschlagen') } finally { setRoadmapLoading(false) } } const buildEvaluateRequest = (synced, { llmPathQa = true, aiGapFill = true } = {}) => { const override = majorStepsToOverridePayload(synced.slots) return { query: (synced.goalQuery || '').trim(), max_steps: synced.slots.length || draft?.maxSteps || 5, include_path_qa: true, include_llm_path_qa: llmPathQa, include_ai_gap_fill: aiGapFill, include_path_reorder: false, include_llm_intent: false, evaluate_only: true, evaluate_steps: slotsToEvaluateSteps(synced), roadmap_override: override, slot_assignments: slotsToSlotAssignments(synced), progression_graph_id: Number(graphId), ...roadmapStructuredPayload(synced.startSituation, synced.targetState, synced.roadmapNotes), ...catalogApiPayload, } } const fetchPathEvaluate = async (synced, options) => api.suggestProgressionPath(buildEvaluateRequest(synced, options)) const applyEvaluateResult = (synced, res) => { setSemanticBrief(res?.semantic_brief_summary || null) setPathQa(res?.path_qa || null) const { draft: evaluated, remainingOffers } = applyEvaluateResponseToDraft(synced, res) return { draft: { ...evaluated, lastFindings: res?.path_qa || null, findingsStale: false }, remainingOffers, } } const runMatchCompareFlow = async (synced, { source = 'match' } = {}) => { setMatchNotice('Schritt 1/2: Pfad bewerten (wie „Graph bewerten“)…') const baselineRes = await fetchPathEvaluate(synced) const { draft: evaluated, remainingOffers } = applyEvaluateResult(synced, baselineRes) setDraft(evaluated) const mergedAfterEval = mergeGapOffersForDraft(evaluated, baselineRes) setGapFillOffers(mergedAfterEval.length > 0 ? mergedAfterEval : remainingOffers) setMatchNotice('Schritt 2/2: Slot-Alternativen prüfen…') let compareRes let reviewError = null try { const reviewRes = await api.suggestProgressionPath({ ...buildEvaluateRequest(synced), evaluate_only: false, unified_slot_review: true, baseline_evaluate_steps: slotsToEvaluateSteps(synced), baseline_path_qa_snapshot: baselineRes?.path_qa || null, baseline_quality_score: baselineRes?.path_qa?.quality_score != null ? Number(baselineRes.path_qa.quality_score) : null, include_llm_path_qa: false, include_llm_intent: false, auto_rematch_after_qa: false, }) if (!reviewRes?.unified_slot_review) { reviewError = 'Slot-Review nicht verfügbar — Backend neu starten/deployen (unified_slot_review fehlt).' compareRes = buildProgressionComparePayload(baselineRes, { ...reviewRes, unified_slot_review: true, slot_reviews: [], review_error: reviewError, }) } else { compareRes = buildProgressionComparePayload(baselineRes, reviewRes) } setGapFillOffers(mergeGapOffersForDraft(evaluated, baselineRes, reviewRes)) } catch (e) { reviewError = e.message || 'Slot-Review fehlgeschlagen' compareRes = buildProgressionComparePayload(baselineRes, { unified_slot_review: true, slot_reviews: [], review_error: reviewError, path_qa: baselineRes?.path_qa, }) } presentMatchCompare(compareRes, { source, reviewError }) return compareRes } const presentMatchCompare = (res, { source = 'manual', reviewError = null } = {}) => { setSemanticBrief(res?.semantic_brief_summary || null) setTargetSummary(res?.target_profile_summary || null) setComparePayload(reviewError ? { ...res, review_error: reviewError } : res) setCompareSource(source) setProposedPathQa(res?.proposed_path_qa_pipeline || null) setCompareOpen(true) const baselineQa = res?.baseline_path_qa || null const slotReviews = res?.slot_reviews || [] const autoCount = slotReviews.filter((r) => r?.library_alternative?.auto_select).length const diffCount = autoCount || res?.slot_diff_count || 0 const rejectedCount = res?.slot_diff_count_rejected ?? rejectedCompareDiffs(res).length const problemCount = res?.match_summary?.problem_slot_count ?? (res?.problem_slots ? Object.keys(res.problem_slots).length : 0) const bPct = pathQaQualityPercent(baselineQa) let notice = reviewError ? `Match: Dialog geöffnet — ${reviewError}` : slotReviews.length > 0 ? `Match: ${slotReviews.length} Slot(s) geprüft, ${autoCount} Empfehlung(en) vorausgewählt.` : diffCount > 0 ? `Match: ${diffCount} Verbesserung(en).` : problemCount > 0 ? `Match: ${problemCount} Schachstelle(n), keine bessere Bibliotheks-Alternative.` : 'Match: Pfad geprüft — siehe Dialog.' if (rejectedCount > 0) { notice += ` ${rejectedCount} Vorschlag/Vorschläge verworfen (Verschlechterung oder neutral).` } const gapCount = collectGapOffersFromApiResponse(res).length if (gapCount > 0) { notice += ` ${gapCount} KI-Angebot(e) für leere Slots im Panel „Graph-Bewertung“.` } setMatchNotice(notice) } const runMatch = async () => { const q = (draft?.goalQuery || '').trim() if (q.length < 3) { alert('Ziel-Anfrage: mindestens 3 Zeichen.') return } if (validMajorSteps.length < 2) { alert('Mindestens zwei Slots mit Lernziel (je 3+ Zeichen) nötig.') return } setMatching(true) setActionErr('') setMatchNotice('') try { const synced = syncProgressionRoadmapFromSlots(draft) setProposedPathQa(null) await runMatchCompareFlow(synced, { source: 'match' }) } catch (e) { setActionErr(e.message || 'Übungs-Match fehlgeschlagen') } finally { setMatching(false) } } const runOptimizeCompare = async () => { const q = (draft?.goalQuery || '').trim() if (q.length < 3) { alert('Ziel-Anfrage: mindestens 3 Zeichen.') return } if (validMajorSteps.length < 2) { alert('Mindestens zwei Slots mit Lernziel (je 3+ Zeichen) nötig.') return } setComparing(true) setActionErr('') setMatchNotice('') try { const synced = syncProgressionRoadmapFromSlots(draft) setProposedPathQa(null) await runMatchCompareFlow(synced, { source: 'manual' }) } catch (e) { setActionErr(e.message || 'Optimierungs-Vergleich fehlgeschlagen') } finally { setComparing(false) } } const applyOptimizeCompare = async (selectedMajorIndices) => { if (!comparePayload || !draft) return setCompareApplying(true) setMatchNotice('Übernahme: Slots aktualisieren …') try { const synced = syncProgressionRoadmapFromSlots(draft) const nextDraft = comparePayload?.unified_slot_review ? applySelectedSlotSuggestions(synced, comparePayload, selectedMajorIndices) : applySelectedCompareSteps( synced, comparePayload.proposed_steps || comparePayload.steps, selectedMajorIndices, ) const syncedNext = syncProgressionRoadmapFromSlots(nextDraft) setDraft({ ...syncedNext, dirty: false, findingsStale: true }) setCompareOpen(false) setComparePayload(null) setProposedPathQa(null) await saveProgressionGraphDraft(api, graphId, { ...syncedNext, findingsStale: true }) setMatchNotice( 'Übernommen und gespeichert. Bewertung bezieht sich noch auf den vorherigen Stand — bitte „Graph bewerten“.', ) } catch (e) { setActionErr(e.message || 'Übernahme fehlgeschlagen') } finally { setCompareApplying(false) } } const runEvaluate = async () => { const q = (draft?.goalQuery || '').trim() if (q.length < 3) { alert('Ziel-Anfrage: mindestens 3 Zeichen.') return } setEvaluating(true) setActionErr('') setProposedPathQa(null) try { const synced = syncProgressionRoadmapFromSlots(draft) const res = await fetchPathEvaluate(synced) const { draft: evaluated, remainingOffers } = applyEvaluateResult(synced, res) setDraft(evaluated) const mergedOffers = mergeGapOffersForDraft(evaluated, res) setGapFillOffers(mergedOffers.length > 0 ? mergedOffers : remainingOffers) } catch (e) { setActionErr(e.message || 'Bewertung fehlgeschlagen') } finally { setEvaluating(false) } } const handleSave = async () => { if (!draft || !graphId) return setBusy(true) setActionErr('') try { await saveProgressionGraphDraft(api, graphId, { ...draft, lastFindings: pathQa }) await loadGraph() if (typeof onSaved === 'function') await onSaved() alert('Progressionsgraph gespeichert.') } catch (e) { setActionErr(e.message || 'Speichern fehlgeschlagen') } finally { setBusy(false) } } const handleApplyGapOffer = (offer, slotIndex) => { setDraft((prev) => { const next = applyGapOfferToDraft(prev, offer, { slotIndex }) return { ...next, dirty: true, findingsStale: true } }) setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id)) } const handleInsertGapSlot = (offer) => { if ((draft?.slots?.length || 0) >= SLOT_MAX) { alert(`Maximal ${SLOT_MAX} Slots — zuerst einen Slot entfernen.`) return } setDraft((prev) => { const next = applyGapOfferToDraft(prev, offer, { insertNewSlot: true }) return { ...next, dirty: true, findingsStale: true } }) setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id)) } const slotOfferContext = (slotIndex) => { const slot = draft?.slots?.[slotIndex] if (!draft || !slot) return null const goalForAi = buildSlotGapGoalForAi(draft, slotIndex, { goalQuery: draft.goalQuery }) || slot.learning_goal const priorSlot = slotIndex > 0 && draft.slots[slotIndex - 1] ? draft.slots[slotIndex - 1] : null return { offer_id: `slot-${slotIndex}`, title_hint: slot.primary?.exerciseTitle || slot.learning_goal, roadmap_major_step_index: slot.majorStepIndex, phase: slot.phase, source: 'roadmap_unfilled', goal_for_ai: goalForAi, sketch: goalForAi, from_title: priorSlot?.primary?.exerciseTitle || null, } } const openGapFillPrep = (offer, slotIndex = null) => { const defaultFocus = resolveDefaultFocusAreaId(targetSummary, focusAreas) setActiveOffer(offer) setActiveOfferSlotIndex(slotIndex) setGapPrepTitle((offer?.title_hint || '').trim()) setGapPrepStageGoal(initialStageLearningGoalFromOffer(offer, gapContextParams)) setGapPrepSupplements('') setGapPrepFocusAreaId(defaultFocus ? String(defaultFocus) : '') setActivePlanningContextLines(gapOfferContextDisplayLines(offer, gapContextParams)) setGapPrepError('') setGapPrepOpen(true) } const runGapFillAiSuggest = async (offer, prep, slotIndex) => { const title = (prep?.title || offer?.title_hint || '').trim() if (title.length < 3) { alert('Titel: mindestens 3 Zeichen.') return } const supplements = (prep?.supplements || '').trim() const stageGoal = (prep?.stageLearningGoal || '').trim() let goalText = (offer?.goal_for_ai || offer?.sketch || '').trim() if (supplements) { goalText = `${goalText}\n\nTrainer-Ergänzungen:\n${supplements}`.trim() } const focusId = prep?.focusAreaId != null && Number.isFinite(Number(prep.focusAreaId)) ? Number(prep.focusAreaId) : resolveDefaultFocusAreaId(targetSummary, focusAreas) if (!focusId) { alert('Bitte einen Fokusbereich wählen.') return } const focusRow = (focusAreas || []).find((x) => Number(x.id) === focusId) const focusHint = (focusRow?.name || offer?.primary_topic || '').trim() setGapAiBusy(true) setGeneratingOfferId(offer?.offer_id || null) setGapPrepError('') setSlotQuickError('') const contextParams = { ...gapContextParams, stageLearningGoalOverride: stageGoal, gapTrainerSupplements: supplements, } setActivePlanningContextLines(gapOfferContextDisplayLines(offer, contextParams)) try { const planningContext = buildPathGapPlanningContextForAi({ offer, ...contextParams, }) const aiRes = await api.suggestExerciseAi({ title, goal: goalText || undefined, execution: '', preparation: '', trainer_notes: supplements || '', focus_area_hint: focusHint || undefined, focus_areas_context: [{ focus_area_id: focusId, is_primary: true }], planning_context: planningContext || undefined, include_summary: true, include_skills: true, include_instructions: true, }) const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: goalText }) if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) { throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.') } const aiDraft = aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: goalText, }) const enrichedOffer = { ...offer, proposal_title: title, ai_suggestion: aiDraft, has_ai_payload: true, } const resolvedSlot = slotIndex != null && Number.isFinite(slotIndex) ? slotIndex : activeOfferSlotIndex != null && Number.isFinite(activeOfferSlotIndex) ? activeOfferSlotIndex : null if (resolvedSlot != null) { setSlotQuickCreateIndex(resolvedSlot) setDraft((prev) => ({ ...applyGapOfferToDraft(prev, enrichedOffer, { slotIndex: resolvedSlot }), findingsStale: true, })) } setSlotQuickCreateDraft(aiDraft) setGapFillOffers((prev) => prev.filter((o) => o.offer_id !== offer?.offer_id)) setGapPrepOpen(false) } catch (e) { setGapPrepError(e.message || 'KI-Anlage fehlgeschlagen') } finally { setGapAiBusy(false) setGeneratingOfferId(null) } } const openSlotQuickCreate = (slotIndex) => { const slot = draft?.slots?.[slotIndex] if (!slot) return const primary = slot.primary const offer = slotOfferContext(slotIndex) setSlotQuickCreateIndex(slotIndex) setSlotQuickError('') setActiveOffer(offer) setActiveOfferSlotIndex(slotIndex) setActivePlanningContextLines(gapOfferContextDisplayLines(offer, gapContextParams)) if (primary?.kind === 'proposal' && primary.aiSuggestion) { const focusId = resolveDefaultFocusAreaId(targetSummary, focusAreas) const draftReady = ensureQuickCreateDraftFromAiSuggestion(primary.aiSuggestion, { title: primary.exerciseTitle || slot.learning_goal, focusAreaId: focusId, sketchPlain: (offer?.goal_for_ai || slot.learning_goal || '').trim(), }) if (draftReady) { setSlotQuickCreateDraft(draftReady) return } } openGapFillPrep(offer, slotIndex) } const applySlotQuickCreate = async () => { if (slotQuickCreateIndex == null || !slotQuickCreateDraft) return setSlotQuickSaving(true) setSlotQuickError('') try { const graphVis = (graphMeta?.visibility || 'private').trim().toLowerCase() const graphClubId = graphMeta?.club_id != null ? graphMeta.club_id : graphVis === 'club' ? getDefaultClubIdForGovernanceForms(user) : null const payload = buildQuickCreateExercisePayloadFromDraft(slotQuickCreateDraft, { visibility: graphVis, clubId: graphClubId, }) const created = await api.createExercise(payload) if (!created?.id) throw new Error('Anlegen fehlgeschlagen') setDraft((prev) => ({ ...setSlotPrimaryLibrary(prev, slotQuickCreateIndex, created), dirty: true, findingsStale: true, })) setSlotQuickCreateDraft(null) setSlotQuickCreateIndex(null) setActiveOffer(null) setActiveOfferSlotIndex(null) setActivePlanningContextLines([]) } catch (e) { const msg = e.message || 'Übung konnte nicht angelegt werden' setSlotQuickError(msg) alert(msg) } finally { setSlotQuickSaving(false) } } const submitGapFillPrep = async () => { const title = (gapPrepTitle || '').trim() if (title.length < 3) { alert('Titel: mindestens 3 Zeichen.') return } const focusId = parseInt(String(gapPrepFocusAreaId).trim(), 10) if (!Number.isFinite(focusId) || focusId < 1) { alert('Bitte einen Fokusbereich wählen.') return } if (!activeOffer) return await runGapFillAiSuggest( activeOffer, { title, stageLearningGoal: (gapPrepStageGoal || '').trim(), supplements: (gapPrepSupplements || '').trim(), focusAreaId: focusId, }, activeOfferSlotIndex, ) } if (loadErr) { return (

{loadErr}

Zurück
) } if (!draft) { return (
) } return (
{!embedded ? (

{graphMeta?.name || draft.graphName || `Graph #${graphId}`}

Roadmap-Slots · KI-Match · Graph-Bewertung (max. {SLOT_MAX} Slots)

Zur Übersicht
) : null} {actionErr ? (

{actionErr}

) : null}

Ziel & Roadmap

patchDraft((d) => ({ ...d, goalQuery: e.target.value }))} placeholder="z. B. Von Erlernen bis zur Perfektion des Fußtritts Mae Geri …" />
patchDraft((d) => ({ ...d, maxSteps: Math.max(SLOT_MIN, Math.min(SLOT_MAX, Number(e.target.value) || 5)), })) } />